Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e26c188
Preserve raw HandlerError type
VegetarianOrc Jan 5, 2026
416b397
Remove class methods and make constructor accept either a HandlerErro…
VegetarianOrc Jan 5, 2026
052cfe6
Add additional assertions to string HanderError test
VegetarianOrc Jan 6, 2026
007c139
Fix spacing in docstring
VegetarianOrc Jan 6, 2026
f3d7c53
Run formatter. Declare classvars in HandlerErrorType. Add ignore for …
VegetarianOrc Jan 6, 2026
69fecf4
Update ruff and target python 3.10 for ruff linting. Swap to using a …
VegetarianOrc Jan 6, 2026
3df57af
Remove ruff from direct dependencies
VegetarianOrc Jan 6, 2026
7d383b6
Add Failure base class and set spec-compliant metadata/details
VegetarianOrc Jan 28, 2026
d95b401
Make Failure.stack_trace a property that captures traceback
VegetarianOrc Jan 28, 2026
5ab1952
Ignore IDE setting files in gitignore
VegetarianOrc Jan 28, 2026
b55626d
Add __repr__ methods and make metadata/details immutable
VegetarianOrc Jan 28, 2026
486986d
Use Python's native __cause__ for Failure exception chaining
VegetarianOrc Jan 29, 2026
46e524b
Make Failure.stack_trace a plain attribute instead of a property
VegetarianOrc Jan 30, 2026
6cfad4a
Revert renaming of HandlerError.type
VegetarianOrc Jan 30, 2026
d0c4695
Finish error_type -> type revert
VegetarianOrc Jan 30, 2026
20ec759
Fix HandlerError docstring to use 'type' parameter name
VegetarianOrc Feb 2, 2026
c84730d
Add default case to provide runtime default to retryable property
VegetarianOrc Feb 2, 2026
30b8945
Swap Failure to basic dataclass. Remove creation of details/metadata …
VegetarianOrc Feb 12, 2026
a18eb62
Fix linter errors and docstrings
VegetarianOrc Feb 12, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ __pycache__
apidocs
dist
docs
.idea
.vscode
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dev = [
"pytest-asyncio>=0.26.0",
"pytest-cov>=6.1.1",
"pytest-pretty>=1.3.0",
"ruff>=0.12.0",
"ruff>=0.14.0",
]

[build-system]
Expand Down Expand Up @@ -71,7 +71,7 @@ include = ["src", "tests"]
disable_error_code = ["empty-body"]

[tool.ruff]
target-version = "py39"
target-version = "py310"

[tool.ruff.lint.isort]
combine-as-imports = true
Expand Down
2 changes: 2 additions & 0 deletions src/nexusrpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from . import handler
from ._common import (
Failure,
HandlerError,
HandlerErrorType,
InputT,
Expand All @@ -35,6 +36,7 @@

__all__ = [
"Content",
"Failure",
"get_operation",
"get_service_definition",
"handler",
Expand Down
176 changes: 113 additions & 63 deletions src/nexusrpc/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

from dataclasses import dataclass
from enum import Enum
from typing import Optional, TypeVar
from logging import getLogger
from typing import Any, Mapping, TypeVar

from typing_extensions import Never

logger = getLogger(__name__)

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


@dataclass
class Failure:
"""
A Nexus Failure represents protocol-level failures.

See https://github.com/nexus-rpc/api/blob/main/SPEC.md#failure
"""

message: str
stack_trace: str | None = None
metadata: Mapping[str, str] | None = None
details: Mapping[str, Any] | None = None
cause: Failure | None = None


class HandlerError(Exception):
"""
A Nexus handler error.
Expand All @@ -39,39 +59,55 @@ class HandlerError(Exception):
raise nexusrpc.HandlerError(
"Database unavailable",
type=nexusrpc.HandlerErrorType.INTERNAL,
retryable=True
retryable_override=True
)
"""

def __init__(
self,
message: str,
*,
type: HandlerErrorType,
retryable_override: Optional[bool] = None,
type: HandlerErrorType | str,
retryable_override: bool | None = None,
stack_trace: str | None = None,
original_failure: Failure | None = None,
):
"""
Initialize a new HandlerError.

:param message: A descriptive message for the error. This will become
the `message` in the resulting Nexus Failure object.
:param message: A descriptive message for the error.

:param type: The :py:class:`HandlerErrorType` of the error.
:param type: The :py:class:`HandlerErrorType` of the error, or a
string representation of the error type. If a string is
provided and doesn't match a known error type, it will
be treated as UNKNOWN and a warning will be logged.

:param retryable_override: Optionally set whether the error should be
retried. By default, the error type is used
to determine this.
"""
super().__init__(message)
self._type = type
self._retryable_override = retryable_override

@property
def retryable_override(self) -> Optional[bool]:
"""
The optional retryability override set when this error was created.
:param stack_trace: An optional stack trace string.

:param original_failure: Set if this error is constructed from a failure object.
"""
return self._retryable_override
# Handle string error types (must be done before super().__init__ to build details)
if isinstance(type, str):
raw_error_type = type
try:
type = HandlerErrorType[type]
except KeyError:
logger.warning(f"Unknown Nexus HandlerErrorType: {type}")
type = HandlerErrorType.UNKNOWN
else:
raw_error_type = type.value

self.message = message
self.type = type
self.raw_error_type = raw_error_type
self.retryable_override = retryable_override
self.stack_trace = stack_trace
self.original_failure = original_failure
super().__init__(message)

@property
def retryable(self) -> bool:
Expand All @@ -82,40 +118,36 @@ def retryable(self) -> bool:
error type is used. See
https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors
"""
if self._retryable_override is not None:
return self._retryable_override

non_retryable_types = {
HandlerErrorType.BAD_REQUEST,
HandlerErrorType.UNAUTHENTICATED,
HandlerErrorType.UNAUTHORIZED,
HandlerErrorType.NOT_FOUND,
HandlerErrorType.CONFLICT,
HandlerErrorType.NOT_IMPLEMENTED,
}
retryable_types = {
HandlerErrorType.REQUEST_TIMEOUT,
HandlerErrorType.RESOURCE_EXHAUSTED,
HandlerErrorType.INTERNAL,
HandlerErrorType.UNAVAILABLE,
HandlerErrorType.UPSTREAM_TIMEOUT,
}
if self._type in non_retryable_types:
return False
elif self._type in retryable_types:
return True
else:
return True

@property
def type(self) -> HandlerErrorType:
"""
The type of handler error.

See :py:class:`HandlerErrorType` and
https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors.
"""
return self._type
if self.retryable_override is not None:
return self.retryable_override

match self.type:
case (
HandlerErrorType.BAD_REQUEST
| HandlerErrorType.UNAUTHENTICATED
| HandlerErrorType.UNAUTHORIZED
| HandlerErrorType.NOT_FOUND
| HandlerErrorType.CONFLICT
| HandlerErrorType.NOT_IMPLEMENTED
):
return False
case (
HandlerErrorType.RESOURCE_EXHAUSTED
| HandlerErrorType.REQUEST_TIMEOUT
| HandlerErrorType.INTERNAL
| HandlerErrorType.UNAVAILABLE
| HandlerErrorType.UPSTREAM_TIMEOUT
| HandlerErrorType.UNKNOWN
):
return True

# Type checking enforces exhaustive matching but
# the default case is included to provide a runtime default.
# If a case is missing from above, the assignment to Never
# will cause a type checking error.
case _ as unreachable: # pyright: ignore[reportUnnecessaryComparison]
_: Never = unreachable # pyright: ignore[reportUnreachable]
return True # pyright: ignore[reportUnreachable]


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

UNKNOWN = "UNKNOWN"
"""
The error type is unknown. Subsequent requests by the client are permissible.
"""

BAD_REQUEST = "BAD_REQUEST"
"""
The handler cannot or will not process the request due to an apparent client error.
Expand Down Expand Up @@ -208,11 +245,6 @@ class OperationError(Exception):
"""
An error that represents "failed" and "canceled" operation results.

:param message: A descriptive message for the error. This will become the
`message` in the resulting Nexus Failure object.

:param state:

Example:
.. code-block:: python

Expand All @@ -231,16 +263,34 @@ class OperationError(Exception):
)
"""

def __init__(self, message: str, *, state: OperationErrorState):
super().__init__(message)
self._state = state

@property
def state(self) -> OperationErrorState:
def __init__(
self,
message: str,
*,
state: OperationErrorState,
stack_trace: str | None = None,
original_failure: Failure | None = None,
):
"""
The state of the operation.
Initialize a new OperationError.

:param message: A descriptive message for the error.

:param state: The state of the operation (:py:class:`OperationErrorState`).

:param stack_trace: An optional stack trace string.

:param original_failure: Set if this error is constructed from a failure object.

"""
return self._state

self.message = message
self.state = state
self.stack_trace = stack_trace
self.original_failure = original_failure

self.state = state
super().__init__(message)


class OperationErrorState(Enum):
Expand Down
2 changes: 1 addition & 1 deletion src/nexusrpc/handler/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ class OperationHandlerMiddleware(ABC):
"""
Middleware for operation handlers.

This should be extended by any operation handler middelware.
This should be extended by any operation handler middleware.
"""

@abstractmethod
Expand Down
79 changes: 38 additions & 41 deletions tests/test_common.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,38 @@
from nexusrpc._common import HandlerError, HandlerErrorType


def test_handler_error_retryable_type():
retryable_error_type = HandlerErrorType.RESOURCE_EXHAUSTED
assert HandlerError(
"test",
type=retryable_error_type,
retryable_override=True,
).retryable

assert not HandlerError(
"test",
type=retryable_error_type,
retryable_override=False,
).retryable

assert HandlerError(
"test",
type=retryable_error_type,
).retryable


def test_handler_error_non_retryable_type():
non_retryable_error_type = HandlerErrorType.BAD_REQUEST
assert HandlerError(
"test",
type=non_retryable_error_type,
retryable_override=True,
).retryable

assert not HandlerError(
"test",
type=non_retryable_error_type,
retryable_override=False,
).retryable

assert not HandlerError(
"test",
type=non_retryable_error_type,
).retryable
from nexusrpc._common import (
HandlerError,
HandlerErrorType,
)


def test_handler_error_retryable_behavior():
"""Test retryable behavior based on error type and override."""
# Retryable error type (RESOURCE_EXHAUSTED)
retryable_type = HandlerErrorType.RESOURCE_EXHAUSTED
err = HandlerError("test", type=retryable_type)
assert err.retryable
assert err.type == retryable_type
assert err.raw_error_type == retryable_type.value

err = HandlerError("test", type=retryable_type, retryable_override=False)
assert not err.retryable

# Non-retryable error type (BAD_REQUEST)
non_retryable_type = HandlerErrorType.BAD_REQUEST
err = HandlerError("test", type=non_retryable_type)
assert not err.retryable
assert err.type == non_retryable_type
assert err.raw_error_type == non_retryable_type.value

err = HandlerError("test", type=non_retryable_type, retryable_override=True)
assert err.retryable


def test_handler_error_unknown_error_type():
"""Test handling of unknown error type strings."""
err = HandlerError("test", type="SOME_UNKNOWN_TYPE")
assert err.retryable
assert err.type == HandlerErrorType.UNKNOWN
assert err.raw_error_type == "SOME_UNKNOWN_TYPE"

err = HandlerError("test", type="SOME_UNKNOWN_TYPE", retryable_override=False)
assert not err.retryable
Loading