Skip to content

Commit b935a6b

Browse files
committed
Adopt cause in favor of original_error
Python has no native Error.cause: the cause is stored as a dedicated ``cause`` attribute and additionally exposed as ``__cause__`` when it is an exception, while an exception cause is still surfaced as ``original_error`` for backward compatibility. Replicates graphql/graphql-js@550e511, graphql/graphql-js@d2bad2a, graphql/graphql-js@4c9d754
1 parent 68d1ea5 commit b935a6b

3 files changed

Lines changed: 131 additions & 13 deletions

File tree

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
# be nitpicky
101101
nitpicky = True
102102
# but not too nitpicky
103-
suppress_warnings = ["ref.class", "ref.obj", "ref.python"]
103+
suppress_warnings = ["ref.attr", "ref.class", "ref.obj", "ref.python"]
104104

105105
# The name of the Pygments (syntax highlighting) style to use.
106106
pygments_style = "sphinx"

src/graphql/error/graphql_error.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from sys import exc_info
66
from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict
77

8+
from ..pyutils import Undefined
9+
810
if TYPE_CHECKING:
911
from collections.abc import Collection
1012

@@ -108,12 +110,26 @@ class GraphQLError(Exception):
108110
"""
109111

110112
original_error: Exception | None
111-
"""The original error thrown from a field resolver during execution"""
113+
"""Original error that caused this GraphQLError, if one exists
114+
115+
.. deprecated:: 3.3
116+
Use :attr:`cause` instead to better align with the standard exception
117+
:attr:`~BaseException.__cause__`.
118+
"""
119+
120+
cause: Any
121+
"""The cause of this error, if one exists
122+
123+
The value passed as the ``cause`` when creating this error. Adopts the
124+
convention of the standard exception cause and is also exposed as the
125+
:attr:`~BaseException.__cause__` when it is an exception.
126+
"""
112127

113128
extensions: GraphQLErrorExtensions | None
114129
"""Extension fields to add to the formatted error"""
115130

116131
__slots__ = (
132+
"cause",
117133
"extensions",
118134
"locations",
119135
"message",
@@ -135,15 +151,27 @@ def __init__(
135151
path: Collection[str | int] | None = None,
136152
original_error: Exception | None = None,
137153
extensions: GraphQLErrorExtensions | None = None,
154+
cause: Any = Undefined,
138155
) -> None:
139156
"""Initialize a GraphQLError."""
140157
super().__init__(message)
141158
self.message = message
142159

160+
# The cause defaults to the (deprecated) original error when not given.
161+
has_cause = cause is not Undefined
162+
self.cause = cause if has_cause else original_error
163+
143164
if path and not isinstance(path, list):
144165
path = list(path)
145166
self.path = path or None # type: ignore
146-
self.original_error = original_error
167+
168+
# An Error provided as the cause is also exposed as the original error
169+
# for backward compatibility.
170+
cause_value = cause if has_cause else None
171+
underlying_error = original_error
172+
if underlying_error is None and isinstance(cause_value, Exception):
173+
underlying_error = cause_value
174+
self.original_error = underlying_error
147175

148176
# Compute list of blame nodes.
149177
if nodes and not isinstance(nodes, list):
@@ -170,16 +198,25 @@ def __init__(
170198
locations = [loc.source.get_location(loc.start) for loc in node_locations]
171199
self.locations = locations or None
172200

201+
# Adopt the cause as the standard exception cause when it is one, so
202+
# that the cause chain is reported without duplicating the traceback.
203+
if isinstance(cause_value, BaseException):
204+
self.__cause__ = cause_value
205+
206+
# Preserve the traceback of an explicit original error. The traceback of
207+
# a cause is not copied over, since Python already reports cause chains.
173208
if original_error:
174209
self.__traceback__ = original_error.__traceback__
175-
if original_error.__cause__:
176-
self.__cause__ = original_error.__cause__
177-
elif original_error.__context__:
178-
self.__context__ = original_error.__context__
179-
if extensions is None:
180-
original_extensions = getattr(original_error, "extensions", None)
181-
if isinstance(original_extensions, dict):
182-
extensions = original_extensions
210+
if not self.__cause__:
211+
if original_error.__cause__:
212+
self.__cause__ = original_error.__cause__
213+
elif original_error.__context__:
214+
self.__context__ = original_error.__context__
215+
216+
if extensions is None and underlying_error is not None:
217+
original_extensions = getattr(underlying_error, "extensions", None)
218+
if isinstance(original_extensions, dict):
219+
extensions = original_extensions
183220
self.extensions = extensions or {}
184221
if not self.__traceback__:
185222
self.__traceback__ = exc_info()[2]
@@ -217,15 +254,15 @@ def __eq__(self, other: object) -> bool:
217254
and all(
218255
getattr(self, slot) == getattr(other, slot)
219256
for slot in self.__slots__
220-
if slot != "original_error"
257+
if slot not in ("cause", "original_error")
221258
)
222259
) or (
223260
isinstance(other, dict)
224261
and "message" in other
225262
and all(
226263
slot in self.__slots__ and getattr(self, slot) == other.get(slot)
227264
for slot in other
228-
if slot != "original_error"
265+
if slot not in ("cause", "original_error")
229266
)
230267
)
231268

tests/error/test_graphql_error.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def formatted_dict_has_only_keys_prescribed_in_the_spec():
7777
["a", "b", "c"],
7878
Exception("test"),
7979
{"foo": "bar"},
80+
cause=Exception("test"),
8081
)
8182
assert set(e.formatted) == {"message", "path", "locations", "extensions"}
8283

@@ -128,6 +129,67 @@ def creates_new_stack_if_original_error_has_no_stack():
128129
assert e.original_error is original
129130
assert str(e.original_error) == "original"
130131

132+
def does_not_add_a_cause_without_a_cause():
133+
e = GraphQLError("msg")
134+
assert e.cause is None
135+
136+
def does_not_copy_over_the_traceback_of_cause():
137+
try:
138+
raise RuntimeError("cause")
139+
except RuntimeError as runtime_error:
140+
cause = runtime_error
141+
e = GraphQLError("msg", cause=cause)
142+
assert e.cause is cause
143+
assert e.__cause__ is cause
144+
assert cause.__traceback__ is not None
145+
assert e.__traceback__ is not cause.__traceback__
146+
147+
def uses_an_error_cause_as_the_original_error_for_compatibility():
148+
class CustomError(Exception):
149+
def __init__(self, message: str) -> None:
150+
super().__init__(message)
151+
self.extensions = {"original": "extensions"}
152+
153+
cause = CustomError("cause")
154+
e = GraphQLError("msg", cause=cause)
155+
assert e.message == "msg"
156+
assert e.cause is cause
157+
assert e.original_error is cause
158+
assert e.__cause__ is cause
159+
assert e.extensions == {"original": "extensions"}
160+
161+
def preserves_a_non_error_cause_without_setting_original_error():
162+
e = GraphQLError("msg", cause="cause")
163+
assert e.cause == "cause"
164+
assert e.original_error is None
165+
assert e.__cause__ is None
166+
167+
def prefers_cause_and_original_error_separately():
168+
try:
169+
raise RuntimeError("original")
170+
except RuntimeError as runtime_error:
171+
original_error = runtime_error
172+
cause = ValueError("cause")
173+
e = GraphQLError("msg", original_error=original_error, cause=cause)
174+
assert e.cause is cause
175+
assert e.original_error is original_error
176+
assert e.__cause__ is cause
177+
assert e.__traceback__ is original_error.__traceback__
178+
179+
def creates_new_stack_if_cause_has_no_stack():
180+
try:
181+
raise RuntimeError
182+
except RuntimeError as runtime_error:
183+
current_traceback = runtime_error.__traceback__
184+
cause = RuntimeError("cause")
185+
e = GraphQLError("msg", cause=cause)
186+
assert cause.__traceback__ is None
187+
assert current_traceback is not None
188+
assert e.__traceback__ is current_traceback
189+
assert e.message == "msg"
190+
assert e.cause is cause
191+
assert e.original_error is cause
192+
131193
def converts_nodes_to_positions_and_locations():
132194
e = GraphQLError("msg", [field_node])
133195
assert e.nodes == [field_node]
@@ -188,6 +250,25 @@ def defaults_to_original_error_extension_only_if_arg_is_not_passed():
188250
assert own_empty_error.original_error is original_error
189251
assert own_empty_error.extensions == {}
190252

253+
def defaults_to_cause_extension_only_if_arg_is_not_passed():
254+
original_extensions = {"original": "extensions"}
255+
cause = GraphQLError("original", extensions=original_extensions)
256+
inherited_error = GraphQLError("InheritedError", cause=cause)
257+
assert inherited_error.message == "InheritedError"
258+
assert inherited_error.cause is cause
259+
assert inherited_error.extensions is original_extensions
260+
261+
own_extensions = {"own": "extensions"}
262+
own_error = GraphQLError("OwnError", cause=cause, extensions=own_extensions)
263+
assert own_error.message == "OwnError"
264+
assert own_error.cause is cause
265+
assert own_error.extensions is own_extensions
266+
267+
own_empty_error = GraphQLError("OwnEmptyError", cause=cause, extensions={})
268+
assert own_empty_error.message == "OwnEmptyError"
269+
assert own_empty_error.cause is cause
270+
assert own_empty_error.extensions == {}
271+
191272
def serializes_to_include_message():
192273
e = GraphQLError("msg")
193274
assert str(e) == "msg"

0 commit comments

Comments
 (0)