Skip to content

Commit 66adfb4

Browse files
committed
Sibling errors should not be added after propagation
Replicates graphql/graphql-js@abddd098
1 parent 20de4b0 commit 66adfb4

3 files changed

Lines changed: 195 additions & 18 deletions

File tree

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
GraphQLFormattedErrorExtensions
152152
GraphQLOutputType
153153
asyncio.events.AbstractEventLoop
154+
graphql.execution.execute.CollectedErrors
154155
graphql.execution.map_async_iterator.MapAsyncIterator
155156
graphql.language.lexer.EscapeSequence
156157
graphql.language.visitor.EnterLeaveVisitor

src/graphql/execution/execute.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Iterable,
1212
List,
1313
Optional,
14+
Set,
1415
Tuple,
1516
Type,
1617
Union,
@@ -174,6 +175,42 @@ def __ne__(self, other: Any) -> bool:
174175
Middleware = Optional[Union[Tuple, List, MiddlewareManager]]
175176

176177

178+
class CollectedErrors:
179+
"""A list of errors collected during execution, ignoring nulled positions.
180+
181+
For internal use only.
182+
"""
183+
184+
_error_positions: Set[Optional[Path]]
185+
_errors: List[GraphQLError]
186+
187+
def __init__(self) -> None:
188+
self._error_positions = set()
189+
self._errors = []
190+
191+
@property
192+
def errors(self) -> List[GraphQLError]:
193+
return self._errors
194+
195+
def add(self, error: GraphQLError, path: Optional[Path]) -> None:
196+
# Do not modify errors list if the execution position for this error or
197+
# any of its ancestors has already been nulled via error propagation.
198+
# This check should be unnecessary for implementations able to implement
199+
# actual cancellation.
200+
if self._has_nulled_position(path):
201+
return
202+
self._error_positions.add(path)
203+
self._errors.append(error)
204+
205+
def _has_nulled_position(self, start_path: Optional[Path]) -> bool:
206+
path = start_path
207+
while path is not None:
208+
if path in self._error_positions:
209+
return True
210+
path = path.prev
211+
return None in self._error_positions
212+
213+
177214
class ExecutionContext:
178215
"""Data that must be available at all points during query execution.
179216
@@ -190,7 +227,7 @@ class ExecutionContext:
190227
field_resolver: GraphQLFieldResolver
191228
type_resolver: GraphQLTypeResolver
192229
subscribe_field_resolver: GraphQLFieldResolver
193-
errors: List[GraphQLError]
230+
collected_errors: CollectedErrors
194231
middleware_manager: Optional[MiddlewareManager]
195232

196233
is_awaitable = staticmethod(default_is_awaitable)
@@ -206,7 +243,7 @@ def __init__(
206243
field_resolver: GraphQLFieldResolver,
207244
type_resolver: GraphQLTypeResolver,
208245
subscribe_field_resolver: GraphQLFieldResolver,
209-
errors: List[GraphQLError],
246+
collected_errors: CollectedErrors,
210247
middleware_manager: Optional[MiddlewareManager],
211248
is_awaitable: Optional[Callable[[Any], bool]],
212249
) -> None:
@@ -219,7 +256,7 @@ def __init__(
219256
self.field_resolver = field_resolver
220257
self.type_resolver = type_resolver
221258
self.subscribe_field_resolver = subscribe_field_resolver
222-
self.errors = errors
259+
self.collected_errors = collected_errors
223260
self.middleware_manager = middleware_manager
224261
if is_awaitable:
225262
self.is_awaitable = is_awaitable # type: ignore
@@ -306,7 +343,7 @@ def build(
306343
field_resolver or default_field_resolver,
307344
type_resolver or default_type_resolver,
308345
subscribe_field_resolver or default_field_resolver,
309-
[],
346+
CollectedErrors(),
310347
middleware_manager,
311348
is_awaitable,
312349
)
@@ -536,7 +573,7 @@ async def await_result() -> Any:
536573
return completed
537574
except Exception as raw_error:
538575
error = located_error(raw_error, field_nodes, path.as_list())
539-
self.handle_field_error(error, return_type)
576+
self.handle_field_error(error, return_type, path)
540577
return None
541578

542579
return await_result()
@@ -551,29 +588,30 @@ async def await_completed() -> Any:
551588
return await completed
552589
except Exception as raw_error:
553590
error = located_error(raw_error, field_nodes, path.as_list())
554-
self.handle_field_error(error, return_type)
591+
self.handle_field_error(error, return_type, path)
555592
return None
556593

557594
return await_completed()
558595

559596
return completed
560597
except Exception as raw_error:
561598
error = located_error(raw_error, field_nodes, path.as_list())
562-
self.handle_field_error(error, return_type)
599+
self.handle_field_error(error, return_type, path)
563600
return None
564601

565602
def handle_field_error(
566603
self,
567604
error: GraphQLError,
568605
return_type: GraphQLOutputType,
606+
path: Path,
569607
) -> None:
570608
# If the field type is non-nullable, then it is resolved without any protection
571609
# from errors, however it still properly locates the error.
572610
if is_non_null_type(return_type):
573611
raise error
574612
# Otherwise, error protection is applied, logging the error and resolving a
575613
# null value for this field if one is encountered.
576-
self.errors.append(error)
614+
self.collected_errors.add(error, path)
577615
return None
578616

579617
def complete_value(
@@ -721,7 +759,7 @@ async def await_completed(item: Any, item_path: Path) -> Any:
721759
error = located_error(
722760
raw_error, field_nodes, item_path.as_list()
723761
)
724-
self.handle_field_error(error, item_type)
762+
self.handle_field_error(error, item_type, item_path)
725763
return None
726764

727765
completed_item = await_completed(item, item_path)
@@ -739,13 +777,13 @@ async def await_completed(item: Any, item_path: Path) -> Any:
739777
error = located_error(
740778
raw_error, field_nodes, item_path.as_list()
741779
)
742-
self.handle_field_error(error, item_type)
780+
self.handle_field_error(error, item_type, item_path)
743781
return None
744782

745783
completed_item = await_completed(completed_item, item_path)
746784
except Exception as raw_error:
747785
error = located_error(raw_error, field_nodes, item_path.as_list())
748-
self.handle_field_error(error, item_type)
786+
self.handle_field_error(error, item_type, item_path)
749787
completed_item = None
750788

751789
if is_awaitable(completed_item):
@@ -1029,7 +1067,7 @@ def execute(
10291067
# Errors from sub-fields of a NonNull type may propagate to the top level,
10301068
# at which point we still log the error and null the parent field, which
10311069
# in this case is the entire response.
1032-
errors = exe_context.errors
1070+
collected_errors = exe_context.collected_errors
10331071
build_response = exe_context.build_response
10341072
try:
10351073
operation = exe_context.operation
@@ -1039,17 +1077,18 @@ def execute(
10391077
# noinspection PyShadowingNames
10401078
async def await_result() -> Any:
10411079
try:
1042-
return build_response(await result, errors) # type: ignore
1080+
data = await result # type: ignore
1081+
return build_response(data, collected_errors.errors)
10431082
except GraphQLError as error:
1044-
errors.append(error)
1045-
return build_response(None, errors)
1083+
collected_errors.add(error, None)
1084+
return build_response(None, collected_errors.errors)
10461085

10471086
return await_result()
10481087
except GraphQLError as error:
1049-
errors.append(error)
1050-
return build_response(None, errors)
1088+
collected_errors.add(error, None)
1089+
return build_response(None, collected_errors.errors)
10511090
else:
1052-
return build_response(result, errors) # type: ignore
1091+
return build_response(result, collected_errors.errors) # type: ignore
10531092

10541093

10551094
def assume_not_awaitable(_value: Any) -> bool:

tests/execution/test_nonnull.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from asyncio import sleep
23
from typing import Any, Awaitable, cast
34

45
from pytest import mark
@@ -505,6 +506,142 @@ async def throws():
505506
],
506507
)
507508

509+
def describe_handles_multiple_errors_for_a_single_response_position():
510+
@mark.asyncio
511+
async def nullable_and_non_nullable_root_fields_throw_nested_errors():
512+
query = """
513+
{
514+
promiseNonNullNest {
515+
syncNonNull
516+
}
517+
promiseNest {
518+
syncNonNull
519+
}
520+
}
521+
"""
522+
result = await cast(
523+
Awaitable[ExecutionResult], execute_query(query, ThrowingData())
524+
)
525+
assert result == (
526+
None,
527+
[
528+
{
529+
"message": str(sync_non_null_error),
530+
"path": ["promiseNonNullNest", "syncNonNull"],
531+
"locations": [(4, 21)],
532+
},
533+
{
534+
"message": str(sync_non_null_error),
535+
"path": ["promiseNest", "syncNonNull"],
536+
"locations": [(7, 21)],
537+
},
538+
],
539+
)
540+
541+
@mark.asyncio
542+
async def slower_nullable_after_non_nullable_root_field():
543+
query = """
544+
{
545+
promiseNonNullNest {
546+
syncNonNull
547+
}
548+
promiseNest {
549+
promiseNonNull
550+
}
551+
}
552+
"""
553+
result = await cast(
554+
Awaitable[ExecutionResult], execute_query(query, ThrowingData())
555+
)
556+
assert result == (
557+
None,
558+
[
559+
{
560+
"message": str(sync_non_null_error),
561+
"path": ["promiseNonNullNest", "syncNonNull"],
562+
"locations": [(4, 21)],
563+
},
564+
],
565+
)
566+
567+
# allow time for slower error to reject
568+
assert result.errors is not None
569+
initial_errors = list(result.errors)
570+
for _ in range(5):
571+
await sleep(0)
572+
assert result.errors == initial_errors
573+
574+
@mark.asyncio
575+
async def nullable_and_non_nullable_nested_fields_throw_nested_errors():
576+
query = """
577+
{
578+
syncNest {
579+
promiseNonNullNest {
580+
syncNonNull
581+
}
582+
promiseNest {
583+
syncNonNull
584+
}
585+
}
586+
}
587+
"""
588+
result = await cast(
589+
Awaitable[ExecutionResult], execute_query(query, ThrowingData())
590+
)
591+
assert result == (
592+
{"syncNest": None},
593+
[
594+
{
595+
"message": str(sync_non_null_error),
596+
"path": ["syncNest", "promiseNonNullNest", "syncNonNull"],
597+
"locations": [(5, 23)],
598+
},
599+
{
600+
"message": str(sync_non_null_error),
601+
"path": ["syncNest", "promiseNest", "syncNonNull"],
602+
"locations": [(8, 23)],
603+
},
604+
],
605+
)
606+
607+
@mark.asyncio
608+
async def slower_nullable_after_non_nullable_nested_field():
609+
query = """
610+
{
611+
syncNest {
612+
promiseNonNullNest {
613+
syncNonNull
614+
}
615+
promiseNest {
616+
promiseNest {
617+
promiseNest {
618+
promiseNonNull
619+
}
620+
}
621+
}
622+
}
623+
}
624+
"""
625+
result = await cast(
626+
Awaitable[ExecutionResult], execute_query(query, ThrowingData())
627+
)
628+
assert result == (
629+
{"syncNest": None},
630+
[
631+
{
632+
"message": str(sync_non_null_error),
633+
"path": ["syncNest", "promiseNonNullNest", "syncNonNull"],
634+
"locations": [(5, 23)],
635+
},
636+
],
637+
)
638+
639+
assert result.errors is not None
640+
initial_errors = list(result.errors)
641+
for _ in range(20):
642+
await sleep(0)
643+
assert result.errors == initial_errors
644+
508645
def describe_handles_non_null_argument():
509646

510647
# noinspection PyPep8Naming

0 commit comments

Comments
 (0)