Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
275 changes: 212 additions & 63 deletions src/nexusrpc/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

from dataclasses import dataclass
from enum import Enum
from typing import Optional, TypeVar
from logging import getLogger
from types import MappingProxyType
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,7 +23,61 @@
"""A user's service definition class, typically decorated with @service"""


class HandlerError(Exception):
class Failure(Exception):
"""
A Nexus Failure represents protocol-level failures.

Base class for :py:class:`HandlerError` and :py:class:`OperationError`.

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

def __init__(
self,
message: str,
*,
stack_trace: str | None = None,
metadata: Mapping[str, str] | None = None,
details: Mapping[str, Any] | None = None,
cause: Failure | None = None,
):
"""
Initialize a new Failure.

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

:param stack_trace: An optional explicit stack trace string, typically from
deserialization or remote sources. This is not auto-captured;
consumers should check ``__traceback__`` for local tracebacks.

:param metadata: Optional key-value metadata associated with the failure.

:param details: Optional additional details about the failure.

:param cause: An optional Failure that caused this failure.
"""
super().__init__(message)
self.message = message
self.stack_trace = stack_trace
self.metadata: Mapping[str, str] | None = (
MappingProxyType(dict(metadata)) if metadata else None
)
self.details: Mapping[str, Any] | None = (
MappingProxyType(dict(details)) if details else None
)

if cause is not None:
self.__cause__ = cause

def __repr__(self) -> str:
return (
f"Failure(message={self.message!r}, metadata={self.metadata!r}, "
f"details={self.details!r}, cause={self.__cause__!r})"
)
Comment on lines +73 to +77
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this repr and those for the subclasses should be included or not. Open to opinions here!



class HandlerError(Failure):
"""
A Nexus handler error.

Expand All @@ -39,39 +99,89 @@ 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,
metadata: Mapping[str, str] | None = None,
details: Mapping[str, Any] | None = None,
cause: 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 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. This is used for
cross-language interoperability where native Python
exception chaining may not be available.

:param metadata: Optional key-value metadata associated with the error.
The key ``"type"`` is reserved and will be set to
``"nexus.HandlerError"`` per the Nexus spec.

:param details: Optional additional details about the error. The keys
``"type"`` and ``"retryableOverride"`` are reserved and
will be set per the Nexus spec.

:param cause: An optional Failure that caused this error.
"""
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

# Build metadata: user values first, then spec-required "type" (cannot be overridden)
failure_metadata: dict[str, str] = dict(metadata) if metadata else {}
failure_metadata["type"] = "nexus.HandlerError"

# Build details: user values first, then spec-required fields (cannot be overridden)
failure_details: dict[str, Any] = dict(details) if details else {}
failure_details["type"] = raw_error_type
if retryable_override is not None:
failure_details["retryableOverride"] = retryable_override

super().__init__(
message,
stack_trace=stack_trace,
metadata=failure_metadata,
details=failure_details,
cause=cause,
)

self.type = type
self.raw_error_type = raw_error_type
self.retryable_override = retryable_override

def __repr__(self) -> str:
return (
f"HandlerError(message={self.message!r}, type={self.type!r}, "
f"retryable={self.retryable}, metadata={self.metadata!r}, "
f"details={self.details!r}, cause={self.__cause__!r})"
)

@property
def retryable(self) -> bool:
Expand All @@ -82,40 +192,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 +230,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 @@ -204,15 +315,10 @@ class HandlerErrorType(Enum):
"""


class OperationError(Exception):
class OperationError(Failure):
"""
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 +337,59 @@ 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,
metadata: Mapping[str, str] | None = None,
details: Mapping[str, Any] | None = None,
cause: Failure | None = None,
):
"""
The state of the operation.
Initialize a new OperationError.

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

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

:param stack_trace: An optional stack trace string. This is used for
cross-language interoperability where native Python
exception chaining may not be available.

:param metadata: Optional key-value metadata associated with the error.
The key ``"type"`` is reserved and will be set to
``"nexus.OperationError"`` per the Nexus spec.

:param details: Optional additional details about the error. The key
``"state"`` is reserved and will be set per the Nexus spec.

:param cause: An optional Failure that caused this error.
"""
return self._state
# Build metadata: user values first, then spec-required "type" (cannot be overridden)
failure_metadata: dict[str, str] = dict(metadata) if metadata else {}
failure_metadata["type"] = "nexus.OperationError"

# Build details: user values first, then spec-required "state" (cannot be overridden)
failure_details: dict[str, Any] = dict(details) if details else {}
failure_details["state"] = state.value

super().__init__(
message,
stack_trace=stack_trace,
metadata=failure_metadata,
details=failure_details,
cause=cause,
)
self.state = state

def __repr__(self) -> str:
return (
f"OperationError(message={self.message!r}, state={self.state!r}, "
f"metadata={self.metadata!r}, details={self.details!r}, cause={self.__cause__!r})"
)


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
Loading