Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions src/graphql/error/graphql_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

from sys import exc_info
from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict

from ..pyutils import Undefined
Expand Down Expand Up @@ -316,8 +315,6 @@ def __init__(
if isinstance(original_extensions, dict):
extensions = original_extensions
self.extensions = extensions or {}
if not self.__traceback__:
self.__traceback__ = exc_info()[2]

def __str__(self) -> str:
"""Get this error as a human-readable message with source locations.
Expand Down
9 changes: 7 additions & 2 deletions src/graphql/execution/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1211,9 +1211,14 @@ def abort_error(self) -> Exception:

:meta private:
"""
reason = self.abort_signal.reason # type: ignore[union-attr]
abort_signal = self.abort_signal
reason = abort_signal.reason # type: ignore[union-attr]
if isinstance(reason, Exception):
return reason
# Restore the traceback the reason had when the signal was aborted,
# since raising the same reason again and again would extend it.
return reason.with_traceback(
getattr(abort_signal, "_reason_traceback", reason.__traceback__)
)
msg = f"Unexpected error value: {inspect(reason)}"
return TypeError(msg)

Expand Down
30 changes: 18 additions & 12 deletions src/graphql/execution/incremental/computation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
if TYPE_CHECKING:
from asyncio import Future
from collections.abc import Callable
from types import TracebackType

from ...pyutils import AwaitableOrValue

Expand Down Expand Up @@ -40,9 +41,10 @@ class Computation(Generic[T]):
For internal use only.
"""

__slots__ = "_fn", "_on_abort", "_status", "_value"
__slots__ = "_fn", "_on_abort", "_status", "_traceback", "_value"

_status: str | None
_traceback: TracebackType | None
_value: Any

def __init__(
Expand All @@ -55,6 +57,7 @@ def __init__(
self._fn = fn
self._on_abort = on_abort
self._status = None
self._traceback = None
self._value = None

def prime(self) -> None:
Expand All @@ -64,8 +67,7 @@ def prime(self) -> None:
try:
result = self._fn()
except Exception as reason:
self._status = _REJECTED
self._value = reason
self._reject(reason)
return
if is_awaitable(result):
future = ensure_future(result)
Expand Down Expand Up @@ -95,7 +97,9 @@ def result(self) -> AwaitableOrValue[T]:
reason = self._value
if reason is None:
raise CancelledError
raise reason
# Restore the original traceback, which would grow otherwise,
# since the same reason is raised every time the result is requested.
raise reason.with_traceback(self._traceback)
return self._value # the pending future

def abort(self, reason: BaseException | None = None) -> AwaitableOrValue[None]:
Expand All @@ -106,12 +110,10 @@ def abort(self, reason: BaseException | None = None) -> AwaitableOrValue[None]:
"""
status = self._status
if status is None:
self._status = _REJECTED
self._value = reason
self._reject(reason)
elif status is _PENDING:
future = self._value
self._status = _REJECTED
self._value = reason
self._reject(reason)
future.cancel()
on_abort = self._on_abort
if on_abort is not None:
Expand All @@ -123,15 +125,19 @@ def _settle(self, future: Future[T]) -> None:
if future.cancelled():
if self._status is _PENDING:
# cancelled externally, not via abort
self._status = _REJECTED
self._value = CancelledError()
self._reject(CancelledError())
return
reason = future.exception() # always retrieve, avoiding warnings
if self._status is not _PENDING:
return # aborted after the future had already completed
if reason is not None:
self._status = _REJECTED
self._value = reason
self._reject(reason)
else:
self._status = _FULFILLED
self._value = future.result()

def _reject(self, reason: BaseException | None) -> None:
"""Memoize the reason why the computation failed or was aborted."""
self._status = _REJECTED
self._value = reason
self._traceback = None if reason is None else reason.__traceback__
8 changes: 7 additions & 1 deletion src/graphql/pyutils/abort_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
from __future__ import annotations

from asyncio import Event
from typing import Any
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from types import TracebackType

__all__ = ["AbortController", "AbortError", "AbortSignal"]

Expand Down Expand Up @@ -42,6 +45,7 @@ class AbortSignal:
def __init__(self) -> None:
self.aborted = False
self.reason = None
self._reason_traceback: TracebackType | None = None
self._event = Event()

async def wait(self) -> Any:
Expand Down Expand Up @@ -110,4 +114,6 @@ def abort(self, reason: Any = None) -> None:
signal.reason = (
AbortError("This operation was aborted") if reason is None else reason
)
if isinstance(reason, BaseException):
signal._reason_traceback = reason.__traceback__ # noqa: SLF001
signal._event.set() # noqa: SLF001
17 changes: 9 additions & 8 deletions src/graphql/validation/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@ class ValidationAbortedError(GraphQLError):
"""Error when a validation has been aborted (error limit reached)."""


validation_aborted_error = ValidationAbortedError(
"Too many validation errors, error limit reached. Validation aborted."
)


# Per the specification, descriptions must not affect validation.
# See https://spec.graphql.org/draft/#sec-Descriptions
query_document_keys_to_validate: dict[str, tuple[str, ...]] = {
Expand Down Expand Up @@ -113,7 +108,10 @@ def validate(

def on_error(error: GraphQLError) -> None:
if len(errors) >= max_errors:
raise validation_aborted_error
# Unlike GraphQL.js, create a new error each time, because raising a
# shared error would add the frames of every validation to its traceback.
msg = "Too many validation errors, error limit reached. Validation aborted."
raise ValidationAbortedError(msg)
errors.append(error)

context = ValidationContext(
Expand All @@ -131,8 +129,11 @@ def on_error(error: GraphQLError) -> None:
TypeInfoVisitor(type_info, ParallelVisitor(visitors)),
query_document_keys_to_validate,
)
except ValidationAbortedError:
errors.append(validation_aborted_error)
except ValidationAbortedError as error:
# Do not keep the frames of the validation alive with the returned error,
# also not via the context of an error that was handled while aborting.
error.__context__ = None
errors.append(error.with_traceback(None))
return errors


Expand Down
30 changes: 22 additions & 8 deletions tests/error/test_graphql_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from typing import cast

import pytest

from graphql.error import GraphQLError
from graphql.language import (
NameNode,
Expand Down Expand Up @@ -32,6 +34,18 @@
assert field_node


def assert_raised_with_new_traceback(error: GraphQLError, handled_error: Exception):
# In Python, an error gets its traceback when it is raised, and an error that
# is being handled at this time becomes its context, not part of its traceback.
with pytest.raises(GraphQLError) as exc_info:
raise error
assert exc_info.value is error
assert error.__traceback__ is not None
assert error.__traceback__ is not handled_error.__traceback__
assert error.__traceback__.tb_next is None
assert error.__context__ is handled_error


def describe_graphql_error():
def is_a_class_and_is_a_subclass_of_exception():
assert type(GraphQLError) is type
Expand Down Expand Up @@ -117,14 +131,14 @@ def passes_the_cause_of_an_original_error():
def creates_new_stack_if_original_error_has_no_stack():
try:
raise RuntimeError
except RuntimeError as original_with_traceback:
original_traceback = original_with_traceback.__traceback__
except RuntimeError as handled_error:
original = RuntimeError("original")
e = GraphQLError("msg", original_error=original)
# the traceback of the handled error is not adopted
assert e.__traceback__ is None
assert_raised_with_new_traceback(e, handled_error)
assert e.__class__.__name__ == "GraphQLError"
assert original.__traceback__ is None
assert original_traceback is not None
assert e.__traceback__ is original_traceback
assert e.message == "msg"
assert e.original_error is original
assert str(e.original_error) == "original"
Expand Down Expand Up @@ -179,13 +193,13 @@ def prefers_cause_and_original_error_separately():
def creates_new_stack_if_cause_has_no_stack():
try:
raise RuntimeError
except RuntimeError as runtime_error:
current_traceback = runtime_error.__traceback__
except RuntimeError as handled_error:
cause = RuntimeError("cause")
e = GraphQLError("msg", cause=cause)
# the traceback of the handled error is not adopted
assert e.__traceback__ is None
assert_raised_with_new_traceback(e, handled_error)
assert cause.__traceback__ is None
assert current_traceback is not None
assert e.__traceback__ is current_traceback
assert e.message == "msg"
assert e.cause is cause
assert e.original_error is cause
Expand Down
60 changes: 60 additions & 0 deletions tests/execution/incremental/test_computation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

if TYPE_CHECKING:
from collections.abc import Callable
from types import TracebackType

pytestmark = pytest.mark.anyio

Expand All @@ -25,6 +26,15 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self.fn(*args, **kwargs)


def traceback_entries(error: BaseException) -> list[TracebackType]:
entries = []
traceback = error.__traceback__
while traceback:
entries.append(traceback)
traceback = traceback.tb_next
return entries


def describe_computation():
def can_return_a_result():
computation = Computation(lambda: {"value": 123})
Expand Down Expand Up @@ -95,6 +105,56 @@ async def run() -> None:
computation.result()
assert run_spy.call_count == 1

def raises_a_sync_error_again_with_its_original_traceback():
def run() -> None:
raise RuntimeError("failure")

computation = Computation(run)

lengths = []
for _ in range(3):
with pytest.raises(RuntimeError, match="failure") as exc_info:
computation.result()
lengths.append(len(traceback_entries(exc_info.value)))
assert lengths[0] > 1
assert lengths[1] == lengths[2] == lengths[0]

async def raises_an_async_error_again_with_its_original_traceback():
async def run() -> None:
await sleep(0)
raise RuntimeError("failure")

computation = Computation(Spy(run))

computation.prime()
with pytest.raises(RuntimeError, match="failure"):
await computation.result()
lengths = []
for _ in range(3):
with pytest.raises(RuntimeError, match="failure") as exc_info:
computation.result()
lengths.append(len(traceback_entries(exc_info.value)))
assert lengths[1] == lengths[2] == lengths[0]

def raises_an_abort_reason_again_with_its_original_traceback():
try:
raise RuntimeError("aborted")
except RuntimeError as error:
abort_reason = error
original_traceback = abort_reason.__traceback__
computation = Computation(lambda: {"value": 123})

computation.abort(abort_reason)
lengths = []
for _ in range(3):
with pytest.raises(RuntimeError, match="aborted") as exc_info:
computation.result()
assert exc_info.value is abort_reason
entries = traceback_entries(abort_reason)
assert entries[-1] is original_traceback
lengths.append(len(entries))
assert lengths[1] == lengths[2] == lengths[0]

def can_be_aborted_before_running():
on_abort_spy = Spy(lambda _reason: None)
run_spy = Spy(lambda: {"value": 123})
Expand Down
Loading
Loading