Skip to content

Commit 9d0cec6

Browse files
authored
Merge pull request #1216 from robsdedude/feat/gql-error-cause-chain-helper
Introduce `GqlError.find_by_gql_status`
2 parents 449d7cf + 6b8424a commit 9d0cec6

File tree

3 files changed

+84
-2
lines changed

3 files changed

+84
-2
lines changed

docs/source/api.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,7 +1960,9 @@ GQL Errors
19601960
==========
19611961
.. autoexception:: neo4j.exceptions.GqlError()
19621962
:show-inheritance:
1963-
:members: gql_status, message, gql_status_description, gql_raw_classification, gql_classification, diagnostic_record, __cause__
1963+
:members:
1964+
gql_status, message, gql_status_description, gql_raw_classification, gql_classification, diagnostic_record,
1965+
find_by_gql_status, __cause__
19641966

19651967
.. autoclass:: neo4j.exceptions.GqlErrorClassification()
19661968
:show-inheritance:
@@ -2002,7 +2004,7 @@ Server-side errors
20022004

20032005
.. autoexception:: neo4j.exceptions.Neo4jError()
20042006
:show-inheritance:
2005-
:members: message, code, is_retriable, is_retryable
2007+
:members: message, code, is_retryable
20062008

20072009
.. autoexception:: neo4j.exceptions.ClientError()
20082010
:show-inheritance:

src/neo4j/exceptions.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,14 @@ class GqlError(Exception):
219219
Instead, only subclasses are raised.
220220
Further, it is used as the :attr:`__cause__` of GqlError subclasses.
221221
222+
Sometimes it is helpful or necessary to traverse the cause chain of
223+
GqlErrors to fully understand or appropriately handle the error. In such
224+
cases, users can either traverse the :attr:`__cause__` attribute of the
225+
error(s) or use the helper method :meth:`.find_by_gql_status`. Note that
226+
:attr:`__cause__` is a standard attribute of all Python
227+
:class:`BaseException` s: the cause chain may contain other exception types
228+
besides GqlError.
229+
222230
.. versionadded: 5.26
223231
224232
.. versionchanged:: 6.0 Stabilized from preview.
@@ -233,6 +241,16 @@ class GqlError(Exception):
233241
_diagnostic_record: dict[str, _t.Any] # copy to be used externally
234242
_gql_cause: GqlError | None
235243

244+
__cause__: BaseException | None
245+
"""
246+
The GqlError's cause, if any.
247+
248+
Sometimes it is helpful or necessary to traverse the cause chain of
249+
GqlErrors to fully understand or appropriately handle the error.
250+
251+
.. seealso:: :meth:`.find_by_gql_status`
252+
"""
253+
236254
@staticmethod
237255
def _hydrate_cause(**metadata: _t.Any) -> GqlError:
238256
meta_extractor = _MetaExtractor(metadata)
@@ -427,6 +445,36 @@ def _get_status_diagnostic_record(self) -> dict[str, _t.Any]:
427445
self._status_diagnostic_record = dict(_UNKNOWN_GQL_DIAGNOSTIC_RECORD)
428446
return self._status_diagnostic_record
429447

448+
def find_by_gql_status(self, status: str) -> GqlError | None:
449+
"""
450+
Return the first GqlError in the cause chain with the given GQL status.
451+
452+
This method traverses this GQLErorrs's :attr:`__cause__` chain,
453+
starting with this error itself, and returns the first error that has
454+
the given GQL status. If no error matches, :data:`None` is returned.
455+
456+
Example::
457+
458+
def invalid_syntax(err: GqlError) -> bool:
459+
return err.find_by_gql_status("42001") is not None
460+
461+
:param status: The GQL status to search for.
462+
463+
:returns: The first matching error or :data:`None`.
464+
465+
.. versionadded:: 6.0
466+
"""
467+
if self.gql_status == status:
468+
return self
469+
470+
cause = self.__cause__
471+
while cause is not None:
472+
if isinstance(cause, GqlError) and cause.gql_status == status:
473+
return cause
474+
cause = getattr(cause, "__cause__", None)
475+
476+
return None
477+
430478
def __str__(self):
431479
return (
432480
f"{{gql_status: {self.gql_status}}} "

tests/unit/common/test_exceptions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,3 +868,35 @@ def test_deprecated_setter(attr):
868868
setattr(error, attr, obj)
869869

870870
assert getattr(error, attr) is not obj
871+
872+
873+
@pytest.mark.parametrize("insert_after", range(-1, 3))
874+
def test_find_by_gql_status(insert_after: int) -> None:
875+
root = error_to_find = None
876+
if insert_after == -1:
877+
root = error_to_find = _make_test_gql_error("12345")
878+
for i in range(3):
879+
root = _make_test_gql_error(f"{i + 2}2345", cause=root)
880+
if i == insert_after:
881+
root = error_to_find = _make_test_gql_error("12345", cause=root)
882+
883+
if root is None:
884+
raise RuntimeError("unreachable, loop is not empty")
885+
if error_to_find is None:
886+
raise ValueError(
887+
f"insert_after is out of range [-1, 3), got {insert_after}"
888+
)
889+
890+
assert root.find_by_gql_status("12345") is error_to_find
891+
892+
893+
def test_find_by_gql_status_no_match() -> None:
894+
root = None
895+
for i in range(3):
896+
root = _make_test_gql_error(f"{i + 1}2345", cause=root)
897+
898+
if root is None:
899+
raise RuntimeError("unreachable, loop is not empty")
900+
901+
for status in ("2345", "02345", "42345", "54321"):
902+
assert root.find_by_gql_status(status) is None

0 commit comments

Comments
 (0)