Skip to content

Commit 17d79f6

Browse files
Add Failure base class with spec-compliant metadata/details (#45)
* Preserve raw HandlerError type - Make raw_error_type optional in constructor, defaults to error_type.value - Preserve raw_error_type separately to handle unknown types from the wire - Remove unnecessary retryable_override property wrapper - Update docstring examples to show from_error_type() as primary API - Update usages to use from_error_type() - Enhance tests with explicit assertions for error_type and raw_error_type * Remove class methods and make constructor accept either a HandlerErrorType or a str to simplify the API surface * Add additional assertions to string HanderError test * Fix spacing in docstring * Run formatter. Declare classvars in HandlerErrorType. Add ignore for inaccurate mypy linting error * Update ruff and target python 3.10 for ruff linting. Swap to using a match statement rather than the classvar frozensets. * Remove ruff from direct dependencies * Add Failure base class and set spec-compliant metadata/details Add Failure as a base class for HandlerError and OperationError, representing protocol-level failures with message, stack_trace, metadata, details, and cause fields. Update HandlerError and OperationError to set their inherited Failure metadata and details properties according to the spec representation: - HandlerError: metadata["type"] = "nexus.HandlerError", details contains "type" (error type) and optionally "retryableOverride" - OperationError: metadata["type"] = "nexus.OperationError", details contains "state" (failed/canceled) User-provided metadata/details are merged but spec-required keys cannot be overridden. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Make Failure.stack_trace a property that captures traceback - Change stack_trace from a simple attribute to a property - Return explicit stack_trace if provided, otherwise format __traceback__ - Return None when no stack trace is available (instead of empty string) - Add docstring explaining the property behavior - Add test verifying traceback capture for Failure, HandlerError, and OperationError Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Ignore IDE setting files in gitignore * Add __repr__ methods and make metadata/details immutable - Add __repr__ to Failure, HandlerError, and OperationError for debugging - Make metadata and details immutable using MappingProxyType - Change Failure.details type from Any to Mapping[str, Any] | None - Add tests for explicit stack_trace precedence and immutability Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Use Python's native __cause__ for Failure exception chaining Replace custom `cause` attribute with Python's built-in exception chaining mechanism. This allows users to use standard Python syntax: `raise Failure(...) from other_exception` Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Make Failure.stack_trace a plain attribute instead of a property This allows consumers to distinguish between explicit stack traces (from deserialization/remote sources) and local tracebacks (via Python's native __traceback__). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert renaming of HandlerError.type * Finish error_type -> type revert * Fix HandlerError docstring to use 'type' parameter name The docstring examples and :param documentation incorrectly referenced 'error_type' but the actual parameter is named 'type'. Updated docs and __repr__ output to be consistent with the parameter name. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add default case to provide runtime default to retryable property * Swap Failure to basic dataclass. Remove creation of details/metadata out of HandlerError and OperationError in favor of having the transport layer handle that as necessary. * Fix linter errors and docstrings --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7df967f commit 17d79f6

6 files changed

Lines changed: 180 additions & 128 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ __pycache__
33
apidocs
44
dist
55
docs
6+
.idea
7+
.vscode

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ dev = [
2525
"pytest-asyncio>=0.26.0",
2626
"pytest-cov>=6.1.1",
2727
"pytest-pretty>=1.3.0",
28-
"ruff>=0.12.0",
28+
"ruff>=0.14.0",
2929
]
3030

3131
[build-system]
@@ -71,7 +71,7 @@ include = ["src", "tests"]
7171
disable_error_code = ["empty-body"]
7272

7373
[tool.ruff]
74-
target-version = "py39"
74+
target-version = "py310"
7575

7676
[tool.ruff.lint.isort]
7777
combine-as-imports = true

src/nexusrpc/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from . import handler
1919
from ._common import (
20+
Failure,
2021
HandlerError,
2122
HandlerErrorType,
2223
InputT,
@@ -35,6 +36,7 @@
3536

3637
__all__ = [
3738
"Content",
39+
"Failure",
3840
"get_operation",
3941
"get_service_definition",
4042
"handler",

src/nexusrpc/_common.py

Lines changed: 113 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from dataclasses import dataclass
44
from enum import Enum
5-
from typing import Optional, TypeVar
5+
from logging import getLogger
6+
from typing import Any, Mapping, TypeVar
7+
8+
from typing_extensions import Never
9+
10+
logger = getLogger(__name__)
611

712
InputT = TypeVar("InputT", contravariant=True)
813
"""Operation input type"""
@@ -17,6 +22,21 @@
1722
"""A user's service definition class, typically decorated with @service"""
1823

1924

25+
@dataclass
26+
class Failure:
27+
"""
28+
A Nexus Failure represents protocol-level failures.
29+
30+
See https://github.com/nexus-rpc/api/blob/main/SPEC.md#failure
31+
"""
32+
33+
message: str
34+
stack_trace: str | None = None
35+
metadata: Mapping[str, str] | None = None
36+
details: Mapping[str, Any] | None = None
37+
cause: Failure | None = None
38+
39+
2040
class HandlerError(Exception):
2141
"""
2242
A Nexus handler error.
@@ -39,39 +59,55 @@ class HandlerError(Exception):
3959
raise nexusrpc.HandlerError(
4060
"Database unavailable",
4161
type=nexusrpc.HandlerErrorType.INTERNAL,
42-
retryable=True
62+
retryable_override=True
4363
)
4464
"""
4565

4666
def __init__(
4767
self,
4868
message: str,
4969
*,
50-
type: HandlerErrorType,
51-
retryable_override: Optional[bool] = None,
70+
type: HandlerErrorType | str,
71+
retryable_override: bool | None = None,
72+
stack_trace: str | None = None,
73+
original_failure: Failure | None = None,
5274
):
5375
"""
5476
Initialize a new HandlerError.
5577
56-
:param message: A descriptive message for the error. This will become
57-
the `message` in the resulting Nexus Failure object.
78+
:param message: A descriptive message for the error.
5879
59-
:param type: The :py:class:`HandlerErrorType` of the error.
80+
:param type: The :py:class:`HandlerErrorType` of the error, or a
81+
string representation of the error type. If a string is
82+
provided and doesn't match a known error type, it will
83+
be treated as UNKNOWN and a warning will be logged.
6084
6185
:param retryable_override: Optionally set whether the error should be
6286
retried. By default, the error type is used
6387
to determine this.
64-
"""
65-
super().__init__(message)
66-
self._type = type
67-
self._retryable_override = retryable_override
6888
69-
@property
70-
def retryable_override(self) -> Optional[bool]:
71-
"""
72-
The optional retryability override set when this error was created.
89+
:param stack_trace: An optional stack trace string.
90+
91+
:param original_failure: Set if this error is constructed from a failure object.
7392
"""
74-
return self._retryable_override
93+
# Handle string error types (must be done before super().__init__ to build details)
94+
if isinstance(type, str):
95+
raw_error_type = type
96+
try:
97+
type = HandlerErrorType[type]
98+
except KeyError:
99+
logger.warning(f"Unknown Nexus HandlerErrorType: {type}")
100+
type = HandlerErrorType.UNKNOWN
101+
else:
102+
raw_error_type = type.value
103+
104+
self.message = message
105+
self.type = type
106+
self.raw_error_type = raw_error_type
107+
self.retryable_override = retryable_override
108+
self.stack_trace = stack_trace
109+
self.original_failure = original_failure
110+
super().__init__(message)
75111

76112
@property
77113
def retryable(self) -> bool:
@@ -82,40 +118,36 @@ def retryable(self) -> bool:
82118
error type is used. See
83119
https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors
84120
"""
85-
if self._retryable_override is not None:
86-
return self._retryable_override
87-
88-
non_retryable_types = {
89-
HandlerErrorType.BAD_REQUEST,
90-
HandlerErrorType.UNAUTHENTICATED,
91-
HandlerErrorType.UNAUTHORIZED,
92-
HandlerErrorType.NOT_FOUND,
93-
HandlerErrorType.CONFLICT,
94-
HandlerErrorType.NOT_IMPLEMENTED,
95-
}
96-
retryable_types = {
97-
HandlerErrorType.REQUEST_TIMEOUT,
98-
HandlerErrorType.RESOURCE_EXHAUSTED,
99-
HandlerErrorType.INTERNAL,
100-
HandlerErrorType.UNAVAILABLE,
101-
HandlerErrorType.UPSTREAM_TIMEOUT,
102-
}
103-
if self._type in non_retryable_types:
104-
return False
105-
elif self._type in retryable_types:
106-
return True
107-
else:
108-
return True
109-
110-
@property
111-
def type(self) -> HandlerErrorType:
112-
"""
113-
The type of handler error.
114-
115-
See :py:class:`HandlerErrorType` and
116-
https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors.
117-
"""
118-
return self._type
121+
if self.retryable_override is not None:
122+
return self.retryable_override
123+
124+
match self.type:
125+
case (
126+
HandlerErrorType.BAD_REQUEST
127+
| HandlerErrorType.UNAUTHENTICATED
128+
| HandlerErrorType.UNAUTHORIZED
129+
| HandlerErrorType.NOT_FOUND
130+
| HandlerErrorType.CONFLICT
131+
| HandlerErrorType.NOT_IMPLEMENTED
132+
):
133+
return False
134+
case (
135+
HandlerErrorType.RESOURCE_EXHAUSTED
136+
| HandlerErrorType.REQUEST_TIMEOUT
137+
| HandlerErrorType.INTERNAL
138+
| HandlerErrorType.UNAVAILABLE
139+
| HandlerErrorType.UPSTREAM_TIMEOUT
140+
| HandlerErrorType.UNKNOWN
141+
):
142+
return True
143+
144+
# Type checking enforces exhaustive matching but
145+
# the default case is included to provide a runtime default.
146+
# If a case is missing from above, the assignment to Never
147+
# will cause a type checking error.
148+
case _ as unreachable: # pyright: ignore[reportUnnecessaryComparison]
149+
_: Never = unreachable # pyright: ignore[reportUnreachable]
150+
return True # pyright: ignore[reportUnreachable]
119151

120152

121153
class HandlerErrorType(Enum):
@@ -124,6 +156,11 @@ class HandlerErrorType(Enum):
124156
See https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors
125157
"""
126158

159+
UNKNOWN = "UNKNOWN"
160+
"""
161+
The error type is unknown. Subsequent requests by the client are permissible.
162+
"""
163+
127164
BAD_REQUEST = "BAD_REQUEST"
128165
"""
129166
The handler cannot or will not process the request due to an apparent client error.
@@ -208,11 +245,6 @@ class OperationError(Exception):
208245
"""
209246
An error that represents "failed" and "canceled" operation results.
210247
211-
:param message: A descriptive message for the error. This will become the
212-
`message` in the resulting Nexus Failure object.
213-
214-
:param state:
215-
216248
Example:
217249
.. code-block:: python
218250
@@ -231,16 +263,34 @@ class OperationError(Exception):
231263
)
232264
"""
233265

234-
def __init__(self, message: str, *, state: OperationErrorState):
235-
super().__init__(message)
236-
self._state = state
237-
238-
@property
239-
def state(self) -> OperationErrorState:
266+
def __init__(
267+
self,
268+
message: str,
269+
*,
270+
state: OperationErrorState,
271+
stack_trace: str | None = None,
272+
original_failure: Failure | None = None,
273+
):
240274
"""
241-
The state of the operation.
275+
Initialize a new OperationError.
276+
277+
:param message: A descriptive message for the error.
278+
279+
:param state: The state of the operation (:py:class:`OperationErrorState`).
280+
281+
:param stack_trace: An optional stack trace string.
282+
283+
:param original_failure: Set if this error is constructed from a failure object.
284+
242285
"""
243-
return self._state
286+
287+
self.message = message
288+
self.state = state
289+
self.stack_trace = stack_trace
290+
self.original_failure = original_failure
291+
292+
self.state = state
293+
super().__init__(message)
244294

245295

246296
class OperationErrorState(Enum):

tests/test_common.py

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,38 @@
1-
from nexusrpc._common import HandlerError, HandlerErrorType
2-
3-
4-
def test_handler_error_retryable_type():
5-
retryable_error_type = HandlerErrorType.RESOURCE_EXHAUSTED
6-
assert HandlerError(
7-
"test",
8-
type=retryable_error_type,
9-
retryable_override=True,
10-
).retryable
11-
12-
assert not HandlerError(
13-
"test",
14-
type=retryable_error_type,
15-
retryable_override=False,
16-
).retryable
17-
18-
assert HandlerError(
19-
"test",
20-
type=retryable_error_type,
21-
).retryable
22-
23-
24-
def test_handler_error_non_retryable_type():
25-
non_retryable_error_type = HandlerErrorType.BAD_REQUEST
26-
assert HandlerError(
27-
"test",
28-
type=non_retryable_error_type,
29-
retryable_override=True,
30-
).retryable
31-
32-
assert not HandlerError(
33-
"test",
34-
type=non_retryable_error_type,
35-
retryable_override=False,
36-
).retryable
37-
38-
assert not HandlerError(
39-
"test",
40-
type=non_retryable_error_type,
41-
).retryable
1+
from nexusrpc._common import (
2+
HandlerError,
3+
HandlerErrorType,
4+
)
5+
6+
7+
def test_handler_error_retryable_behavior():
8+
"""Test retryable behavior based on error type and override."""
9+
# Retryable error type (RESOURCE_EXHAUSTED)
10+
retryable_type = HandlerErrorType.RESOURCE_EXHAUSTED
11+
err = HandlerError("test", type=retryable_type)
12+
assert err.retryable
13+
assert err.type == retryable_type
14+
assert err.raw_error_type == retryable_type.value
15+
16+
err = HandlerError("test", type=retryable_type, retryable_override=False)
17+
assert not err.retryable
18+
19+
# Non-retryable error type (BAD_REQUEST)
20+
non_retryable_type = HandlerErrorType.BAD_REQUEST
21+
err = HandlerError("test", type=non_retryable_type)
22+
assert not err.retryable
23+
assert err.type == non_retryable_type
24+
assert err.raw_error_type == non_retryable_type.value
25+
26+
err = HandlerError("test", type=non_retryable_type, retryable_override=True)
27+
assert err.retryable
28+
29+
30+
def test_handler_error_unknown_error_type():
31+
"""Test handling of unknown error type strings."""
32+
err = HandlerError("test", type="SOME_UNKNOWN_TYPE")
33+
assert err.retryable
34+
assert err.type == HandlerErrorType.UNKNOWN
35+
assert err.raw_error_type == "SOME_UNKNOWN_TYPE"
36+
37+
err = HandlerError("test", type="SOME_UNKNOWN_TYPE", retryable_override=False)
38+
assert not err.retryable

0 commit comments

Comments
 (0)