From e26c188ec8145cd1d516fb92f3e2bc2d1b42434d Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 5 Jan 2026 14:53:00 -0800 Subject: [PATCH 01/17] 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 --- src/nexusrpc/_common.py | 127 ++++++++++++++++++++-------------- src/nexusrpc/handler/_core.py | 14 ++-- tests/test_common.py | 61 +++++++++++----- 3 files changed, 126 insertions(+), 76 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index d69aaec0..b9f138a3 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -2,7 +2,10 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional, TypeVar +from typing import Optional, TypeVar, Self +from logging import getLogger + +logger = getLogger(__name__) InputT = TypeVar("InputT", contravariant=True) """Operation input type""" @@ -30,25 +33,55 @@ class HandlerError(Exception): import nexusrpc # Raise a bad request error - raise nexusrpc.HandlerError( + raise nexusrpc.HandlerError.from_error_type( "Invalid input provided", - type=nexusrpc.HandlerErrorType.BAD_REQUEST + error_type=nexusrpc.HandlerErrorType.BAD_REQUEST ) # Raise a retryable internal error - raise nexusrpc.HandlerError( + raise nexusrpc.HandlerError.from_error_type( "Database unavailable", - type=nexusrpc.HandlerErrorType.INTERNAL, - retryable=True + error_type=nexusrpc.HandlerErrorType.INTERNAL, + retryable_override=True ) """ + @classmethod + def from_raw_error( + cls, + message: str, + *, + raw_error_type: str, + retryable_override: bool | None = None, + ) -> Self: + try: + error_type = HandlerErrorType[raw_error_type] + except KeyError: + logger.warning( + f"Unknown Nexus HandlerErrorType: {raw_error_type}" + ) + error_type = HandlerErrorType.UNKNOWN + return cls(message, error_type=error_type, raw_error_type=raw_error_type, retryable_override=retryable_override) + + @classmethod + def from_error_type( + cls, + message: str, + *, + error_type: HandlerErrorType, + retryable_override: bool | None = None, + ) -> Self: + return cls(message, + error_type=error_type, + retryable_override=retryable_override) + def __init__( self, message: str, *, - type: HandlerErrorType, - retryable_override: Optional[bool] = None, + error_type: HandlerErrorType, + raw_error_type: str | None = None, + retryable_override: bool | None = None, ): """ Initialize a new HandlerError. @@ -56,22 +89,19 @@ def __init__( :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 error_type: The :py:class:`HandlerErrorType` of the error. + + :param raw_error_type: The type of the error as a string. If not provided, + defaults to the string value of the error type. :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. - """ - return self._retryable_override + self.error_type = error_type + self.raw_error_type = raw_error_type if raw_error_type is not None else error_type.value + self.retryable_override = retryable_override @property def retryable(self) -> bool: @@ -82,40 +112,12 @@ 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 + if self.retryable_override is not None: + return self.retryable_override - @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 + # Error types are retriable by default so anything not in NON_RETRYABLE_ERRORS + # is considered retriable even if it's not in RETRYABLE_ERRORS + return self.error_type not in HandlerErrorType.NON_RETRYABLE_ERRORS class HandlerErrorType(Enum): @@ -124,6 +126,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. @@ -203,6 +210,24 @@ class HandlerErrorType(Enum): Subsequent requests by the client are permissible. """ +HandlerErrorType.NON_RETRYABLE_ERRORS = frozenset({ + HandlerErrorType.BAD_REQUEST, + HandlerErrorType.UNAUTHENTICATED, + HandlerErrorType.UNAUTHORIZED, + HandlerErrorType.NOT_FOUND, + HandlerErrorType.CONFLICT, + HandlerErrorType.NOT_IMPLEMENTED, +}) + +HandlerErrorType.RETRYABLE_ERRORS = frozenset({ + HandlerErrorType.REQUEST_TIMEOUT, + HandlerErrorType.RESOURCE_EXHAUSTED, + HandlerErrorType.INTERNAL, + HandlerErrorType.UNAVAILABLE, + HandlerErrorType.UPSTREAM_TIMEOUT, + HandlerErrorType.UNKNOWN, +}) + class OperationError(Exception): """ diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index eb3b01a7..e7e3de96 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -206,9 +206,9 @@ def _get_service_handler(self, service_name: str) -> ServiceHandler: """Return a service handler, given the service name.""" service = self.service_handlers.get(service_name) if service is None: - raise HandlerError( + raise HandlerError.from_error_type( f"No handler for service '{service_name}'.", - type=HandlerErrorType.NOT_FOUND, + error_type=HandlerErrorType.NOT_FOUND, ) return service @@ -372,19 +372,19 @@ def from_user_instance(cls, user_instance: Any) -> Self: def get_operation_handler(self, operation_name: str) -> OperationHandler[Any, Any]: """Return an operation handler, given the operation name.""" if operation_name not in self.service.operation_definitions: - raise HandlerError( + raise HandlerError.from_error_type( f"Nexus service definition '{self.service.name}' has no operation " f"'{operation_name}'. There are {len(self.service.operation_definitions)} operations " f"in the definition.", - type=HandlerErrorType.NOT_FOUND, + error_type=HandlerErrorType.NOT_FOUND, ) operation_handler = self.operation_handlers.get(operation_name) if operation_handler is None: - raise HandlerError( + raise HandlerError.from_error_type( f"Nexus service implementation '{self.service.name}' has no handler for " f"operation '{operation_name}'. There are {len(self.operation_handlers)} " f"available operation handlers.", - type=HandlerErrorType.NOT_FOUND, + error_type=HandlerErrorType.NOT_FOUND, ) return operation_handler @@ -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 diff --git a/tests/test_common.py b/tests/test_common.py index 11e0abb5..640a2056 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -3,39 +3,64 @@ def test_handler_error_retryable_type(): retryable_error_type = HandlerErrorType.RESOURCE_EXHAUSTED - assert HandlerError( + err = HandlerError.from_error_type( "test", - type=retryable_error_type, + error_type=retryable_error_type, retryable_override=True, - ).retryable + ) + assert err.retryable + assert err.error_type == retryable_error_type + assert err.raw_error_type == retryable_error_type.value - assert not HandlerError( + err = HandlerError.from_error_type( "test", - type=retryable_error_type, + error_type=retryable_error_type, retryable_override=False, - ).retryable + ) + assert not err.retryable + assert err.error_type == retryable_error_type + assert err.raw_error_type == retryable_error_type.value - assert HandlerError( + err = HandlerError.from_error_type( "test", - type=retryable_error_type, - ).retryable + error_type=retryable_error_type, + ) + assert err.retryable + assert err.error_type == retryable_error_type + assert err.raw_error_type == retryable_error_type.value def test_handler_error_non_retryable_type(): non_retryable_error_type = HandlerErrorType.BAD_REQUEST - assert HandlerError( + err = HandlerError.from_error_type( "test", - type=non_retryable_error_type, + error_type=non_retryable_error_type, retryable_override=True, - ).retryable + ) + assert err.retryable + assert err.error_type == non_retryable_error_type + assert err.raw_error_type == non_retryable_error_type.value - assert not HandlerError( + err = HandlerError.from_error_type( "test", - type=non_retryable_error_type, + error_type=non_retryable_error_type, retryable_override=False, - ).retryable + ) + assert not err.retryable + assert err.error_type == non_retryable_error_type + assert err.raw_error_type == non_retryable_error_type.value - assert not HandlerError( + err = HandlerError.from_error_type( "test", - type=non_retryable_error_type, - ).retryable + error_type=non_retryable_error_type, + ) + assert not err.retryable + assert err.error_type == non_retryable_error_type + assert err.raw_error_type == non_retryable_error_type.value + +def test_handler_error_unknown_error_type(): + # Verify that unknown raw errors are retriable and the error_type is unknown + err = HandlerError.from_raw_error("test", raw_error_type="SOME_UNKNOWN_TYPE") + assert err.retryable + assert err.error_type == HandlerErrorType.UNKNOWN + assert err.raw_error_type == "SOME_UNKNOWN_TYPE" From 416b3970d732c73c6febf67a0c338d74a2a7b8fb Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 5 Jan 2026 15:13:02 -0800 Subject: [PATCH 02/17] Remove class methods and make constructor accept either a HandlerErrorType or a str to simplify the API surface --- src/nexusrpc/_common.py | 62 +++++++++++++---------------------- src/nexusrpc/handler/_core.py | 6 ++-- tests/test_common.py | 14 ++++---- 3 files changed, 33 insertions(+), 49 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index b9f138a3..82c77321 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional, TypeVar, Self +from typing import Optional, TypeVar from logging import getLogger logger = getLogger(__name__) @@ -33,54 +33,24 @@ class HandlerError(Exception): import nexusrpc # Raise a bad request error - raise nexusrpc.HandlerError.from_error_type( + raise nexusrpc.HandlerError( "Invalid input provided", error_type=nexusrpc.HandlerErrorType.BAD_REQUEST ) # Raise a retryable internal error - raise nexusrpc.HandlerError.from_error_type( + raise nexusrpc.HandlerError( "Database unavailable", error_type=nexusrpc.HandlerErrorType.INTERNAL, retryable_override=True ) """ - @classmethod - def from_raw_error( - cls, - message: str, - *, - raw_error_type: str, - retryable_override: bool | None = None, - ) -> Self: - try: - error_type = HandlerErrorType[raw_error_type] - except KeyError: - logger.warning( - f"Unknown Nexus HandlerErrorType: {raw_error_type}" - ) - error_type = HandlerErrorType.UNKNOWN - return cls(message, error_type=error_type, raw_error_type=raw_error_type, retryable_override=retryable_override) - - @classmethod - def from_error_type( - cls, - message: str, - *, - error_type: HandlerErrorType, - retryable_override: bool | None = None, - ) -> Self: - return cls(message, - error_type=error_type, - retryable_override=retryable_override) - def __init__( self, message: str, *, - error_type: HandlerErrorType, - raw_error_type: str | None = None, + error_type: HandlerErrorType | str, retryable_override: bool | None = None, ): """ @@ -89,18 +59,32 @@ def __init__( :param message: A descriptive message for the error. This will become the `message` in the resulting Nexus Failure object. - :param error_type: The :py:class:`HandlerErrorType` of the error. - - :param raw_error_type: The type of the error as a string. If not provided, - defaults to the string value of the error type. + :param error_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) + + # Handle string error types + if isinstance(error_type, str): + raw_error_type = error_type + try: + error_type = HandlerErrorType[error_type] + except KeyError: + logger.warning( + f"Unknown Nexus HandlerErrorType: {error_type}" + ) + error_type = HandlerErrorType.UNKNOWN + else: + raw_error_type = error_type.value + self.error_type = error_type - self.raw_error_type = raw_error_type if raw_error_type is not None else error_type.value + self.raw_error_type = raw_error_type self.retryable_override = retryable_override @property diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index e7e3de96..b67dfa44 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -206,7 +206,7 @@ def _get_service_handler(self, service_name: str) -> ServiceHandler: """Return a service handler, given the service name.""" service = self.service_handlers.get(service_name) if service is None: - raise HandlerError.from_error_type( + raise HandlerError( f"No handler for service '{service_name}'.", error_type=HandlerErrorType.NOT_FOUND, ) @@ -372,7 +372,7 @@ def from_user_instance(cls, user_instance: Any) -> Self: def get_operation_handler(self, operation_name: str) -> OperationHandler[Any, Any]: """Return an operation handler, given the operation name.""" if operation_name not in self.service.operation_definitions: - raise HandlerError.from_error_type( + raise HandlerError( f"Nexus service definition '{self.service.name}' has no operation " f"'{operation_name}'. There are {len(self.service.operation_definitions)} operations " f"in the definition.", @@ -380,7 +380,7 @@ def get_operation_handler(self, operation_name: str) -> OperationHandler[Any, An ) operation_handler = self.operation_handlers.get(operation_name) if operation_handler is None: - raise HandlerError.from_error_type( + raise HandlerError( f"Nexus service implementation '{self.service.name}' has no handler for " f"operation '{operation_name}'. There are {len(self.operation_handlers)} " f"available operation handlers.", diff --git a/tests/test_common.py b/tests/test_common.py index 640a2056..150a9575 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -3,7 +3,7 @@ def test_handler_error_retryable_type(): retryable_error_type = HandlerErrorType.RESOURCE_EXHAUSTED - err = HandlerError.from_error_type( + err = HandlerError( "test", error_type=retryable_error_type, retryable_override=True, @@ -12,7 +12,7 @@ def test_handler_error_retryable_type(): assert err.error_type == retryable_error_type assert err.raw_error_type == retryable_error_type.value - err = HandlerError.from_error_type( + err = HandlerError( "test", error_type=retryable_error_type, retryable_override=False, @@ -21,7 +21,7 @@ def test_handler_error_retryable_type(): assert err.error_type == retryable_error_type assert err.raw_error_type == retryable_error_type.value - err = HandlerError.from_error_type( + err = HandlerError( "test", error_type=retryable_error_type, ) @@ -32,7 +32,7 @@ def test_handler_error_retryable_type(): def test_handler_error_non_retryable_type(): non_retryable_error_type = HandlerErrorType.BAD_REQUEST - err = HandlerError.from_error_type( + err = HandlerError( "test", error_type=non_retryable_error_type, retryable_override=True, @@ -41,7 +41,7 @@ def test_handler_error_non_retryable_type(): assert err.error_type == non_retryable_error_type assert err.raw_error_type == non_retryable_error_type.value - err = HandlerError.from_error_type( + err = HandlerError( "test", error_type=non_retryable_error_type, retryable_override=False, @@ -50,7 +50,7 @@ def test_handler_error_non_retryable_type(): assert err.error_type == non_retryable_error_type assert err.raw_error_type == non_retryable_error_type.value - err = HandlerError.from_error_type( + err = HandlerError( "test", error_type=non_retryable_error_type, ) @@ -60,7 +60,7 @@ def test_handler_error_non_retryable_type(): def test_handler_error_unknown_error_type(): # Verify that unknown raw errors are retriable and the error_type is unknown - err = HandlerError.from_raw_error("test", raw_error_type="SOME_UNKNOWN_TYPE") + err = HandlerError("test", error_type="SOME_UNKNOWN_TYPE") assert err.retryable assert err.error_type == HandlerErrorType.UNKNOWN assert err.raw_error_type == "SOME_UNKNOWN_TYPE" From 052cfe6739f4c792d7b97cd99acc1c0fc56a066c Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 5 Jan 2026 16:05:54 -0800 Subject: [PATCH 03/17] Add additional assertions to string HanderError test --- tests/test_common.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_common.py b/tests/test_common.py index 150a9575..60862a71 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -59,8 +59,12 @@ def test_handler_error_non_retryable_type(): assert err.raw_error_type == non_retryable_error_type.value def test_handler_error_unknown_error_type(): - # Verify that unknown raw errors are retriable and the error_type is unknown err = HandlerError("test", error_type="SOME_UNKNOWN_TYPE") assert err.retryable assert err.error_type == HandlerErrorType.UNKNOWN assert err.raw_error_type == "SOME_UNKNOWN_TYPE" + + err = HandlerError("test", error_type="SOME_UNKNOWN_TYPE", retryable_override=False) + assert not err.retryable + assert err.error_type == HandlerErrorType.UNKNOWN + assert err.raw_error_type == "SOME_UNKNOWN_TYPE" From 007c139e236673c33e191c14380e40f9a1310ea8 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 5 Jan 2026 16:13:05 -0800 Subject: [PATCH 04/17] Fix spacing in docstring --- src/nexusrpc/_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index 82c77321..90dcf99d 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -112,7 +112,7 @@ class HandlerErrorType(Enum): UNKNOWN = "UNKNOWN" """ - The error type is unknown.Subsequent requests by the client are permissible. + The error type is unknown. Subsequent requests by the client are permissible. """ BAD_REQUEST = "BAD_REQUEST" From f3d7c53e0c5b03ad911cd352527d431dd628f0c6 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 5 Jan 2026 16:54:28 -0800 Subject: [PATCH 05/17] Run formatter. Declare classvars in HandlerErrorType. Add ignore for inaccurate mypy linting error --- src/nexusrpc/_common.py | 50 +++++++++++++++++++++++------------------ tests/test_common.py | 1 + 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index 90dcf99d..49c24191 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional, TypeVar from logging import getLogger +from typing import ClassVar, TypeVar logger = getLogger(__name__) @@ -76,9 +76,7 @@ def __init__( try: error_type = HandlerErrorType[error_type] except KeyError: - logger.warning( - f"Unknown Nexus HandlerErrorType: {error_type}" - ) + logger.warning(f"Unknown Nexus HandlerErrorType: {error_type}") error_type = HandlerErrorType.UNKNOWN else: raw_error_type = error_type.value @@ -101,7 +99,7 @@ def retryable(self) -> bool: # Error types are retriable by default so anything not in NON_RETRYABLE_ERRORS # is considered retriable even if it's not in RETRYABLE_ERRORS - return self.error_type not in HandlerErrorType.NON_RETRYABLE_ERRORS + return self.error_type not in HandlerErrorType.NON_RETRYABLE_ERRORS # type: ignore[operator] class HandlerErrorType(Enum): @@ -194,23 +192,31 @@ class HandlerErrorType(Enum): Subsequent requests by the client are permissible. """ -HandlerErrorType.NON_RETRYABLE_ERRORS = frozenset({ - HandlerErrorType.BAD_REQUEST, - HandlerErrorType.UNAUTHENTICATED, - HandlerErrorType.UNAUTHORIZED, - HandlerErrorType.NOT_FOUND, - HandlerErrorType.CONFLICT, - HandlerErrorType.NOT_IMPLEMENTED, -}) - -HandlerErrorType.RETRYABLE_ERRORS = frozenset({ - HandlerErrorType.REQUEST_TIMEOUT, - HandlerErrorType.RESOURCE_EXHAUSTED, - HandlerErrorType.INTERNAL, - HandlerErrorType.UNAVAILABLE, - HandlerErrorType.UPSTREAM_TIMEOUT, - HandlerErrorType.UNKNOWN, -}) + NON_RETRYABLE_ERRORS: ClassVar[frozenset[HandlerErrorType]] + RETRYABLE_ERRORS: ClassVar[frozenset[HandlerErrorType]] + + +HandlerErrorType.NON_RETRYABLE_ERRORS = frozenset( + { + HandlerErrorType.BAD_REQUEST, + HandlerErrorType.UNAUTHENTICATED, + HandlerErrorType.UNAUTHORIZED, + HandlerErrorType.NOT_FOUND, + HandlerErrorType.CONFLICT, + HandlerErrorType.NOT_IMPLEMENTED, + } +) + +HandlerErrorType.RETRYABLE_ERRORS = frozenset( + { + HandlerErrorType.REQUEST_TIMEOUT, + HandlerErrorType.RESOURCE_EXHAUSTED, + HandlerErrorType.INTERNAL, + HandlerErrorType.UNAVAILABLE, + HandlerErrorType.UPSTREAM_TIMEOUT, + HandlerErrorType.UNKNOWN, + } +) class OperationError(Exception): diff --git a/tests/test_common.py b/tests/test_common.py index 60862a71..4df1f9ea 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -58,6 +58,7 @@ def test_handler_error_non_retryable_type(): assert err.error_type == non_retryable_error_type assert err.raw_error_type == non_retryable_error_type.value + def test_handler_error_unknown_error_type(): err = HandlerError("test", error_type="SOME_UNKNOWN_TYPE") assert err.retryable From 69fecf467d0a304c9246582dca28ee1f1bb4ca1b Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Tue, 6 Jan 2026 09:28:15 -0800 Subject: [PATCH 06/17] Update ruff and target python 3.10 for ruff linting. Swap to using a match statement rather than the classvar frozensets. --- pyproject.toml | 6 ++--- src/nexusrpc/_common.py | 50 ++++++++++++++++------------------------ uv.lock | 51 ++++++++++++++++++++++------------------- 3 files changed, 51 insertions(+), 56 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cf139a5b..c6ac3639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [{ name = "Temporal Technologies", email = "sdk@temporal.io" }] requires-python = ">=3.10" license = "MIT" license-files = ["LICENSE"] -dependencies = ["typing-extensions>=4.12.2"] +dependencies = ["ruff>=0.12.0", "typing-extensions>=4.12.2"] [dependency-groups] dev = [ @@ -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] @@ -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 diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index 49c24191..c28650ac 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from enum import Enum from logging import getLogger -from typing import ClassVar, TypeVar +from typing import TypeVar logger = getLogger(__name__) @@ -97,9 +97,25 @@ def retryable(self) -> bool: if self.retryable_override is not None: return self.retryable_override - # Error types are retriable by default so anything not in NON_RETRYABLE_ERRORS - # is considered retriable even if it's not in RETRYABLE_ERRORS - return self.error_type not in HandlerErrorType.NON_RETRYABLE_ERRORS # type: ignore[operator] + match self.error_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 class HandlerErrorType(Enum): @@ -192,32 +208,6 @@ class HandlerErrorType(Enum): Subsequent requests by the client are permissible. """ - NON_RETRYABLE_ERRORS: ClassVar[frozenset[HandlerErrorType]] - RETRYABLE_ERRORS: ClassVar[frozenset[HandlerErrorType]] - - -HandlerErrorType.NON_RETRYABLE_ERRORS = frozenset( - { - HandlerErrorType.BAD_REQUEST, - HandlerErrorType.UNAUTHENTICATED, - HandlerErrorType.UNAUTHORIZED, - HandlerErrorType.NOT_FOUND, - HandlerErrorType.CONFLICT, - HandlerErrorType.NOT_IMPLEMENTED, - } -) - -HandlerErrorType.RETRYABLE_ERRORS = frozenset( - { - HandlerErrorType.REQUEST_TIMEOUT, - HandlerErrorType.RESOURCE_EXHAUSTED, - HandlerErrorType.INTERNAL, - HandlerErrorType.UNAVAILABLE, - HandlerErrorType.UPSTREAM_TIMEOUT, - HandlerErrorType.UNKNOWN, - } -) - class OperationError(Exception): """ diff --git a/uv.lock b/uv.lock index 08553f3f..41536c69 100644 --- a/uv.lock +++ b/uv.lock @@ -420,6 +420,7 @@ name = "nexus-rpc" version = "1.3.0" source = { editable = "." } dependencies = [ + { name = "ruff" }, { name = "typing-extensions" }, ] @@ -438,7 +439,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "typing-extensions", specifier = ">=4.12.2" }] +requires-dist = [ + { name = "ruff", specifier = ">=0.12.0" }, + { name = "typing-extensions", specifier = ">=4.12.2" }, +] [package.metadata.requires-dev] dev = [ @@ -451,7 +455,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.26.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-pretty", specifier = ">=1.3.0" }, - { name = "ruff", specifier = ">=0.12.0" }, + { name = "ruff", specifier = ">=0.14.0" }, ] [[package]] @@ -702,27 +706,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/90/5255432602c0b196a0da6720f6f76b93eb50baef46d3c9b0025e2f9acbf3/ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c", size = 4376101, upload-time = "2025-06-17T15:19:26.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/fd/b46bb20e14b11ff49dbc74c61de352e0dc07fb650189513631f6fb5fc69f/ruff-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:5652a9ecdb308a1754d96a68827755f28d5dfb416b06f60fd9e13f26191a8848", size = 10311554, upload-time = "2025-06-17T15:18:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d3/021dde5a988fa3e25d2468d1dadeea0ae89dc4bc67d0140c6e68818a12a1/ruff-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:05ed0c914fabc602fc1f3b42c53aa219e5736cb030cdd85640c32dbc73da74a6", size = 11118435, upload-time = "2025-06-17T15:18:49.064Z" }, - { url = "https://files.pythonhosted.org/packages/07/a2/01a5acf495265c667686ec418f19fd5c32bcc326d4c79ac28824aecd6a32/ruff-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:07a7aa9b69ac3fcfda3c507916d5d1bca10821fe3797d46bad10f2c6de1edda0", size = 10466010, upload-time = "2025-06-17T15:18:51.341Z" }, - { url = "https://files.pythonhosted.org/packages/4c/57/7caf31dd947d72e7aa06c60ecb19c135cad871a0a8a251723088132ce801/ruff-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7731c3eec50af71597243bace7ec6104616ca56dda2b99c89935fe926bdcd48", size = 10661366, upload-time = "2025-06-17T15:18:53.29Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/aa393b972a782b4bc9ea121e0e358a18981980856190d7d2b6187f63e03a/ruff-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d0630eae628250ab1c70a7fffb641b03e6b4a2d3f3ec6c1d19b4ab6c6c807", size = 10173492, upload-time = "2025-06-17T15:18:55.262Z" }, - { url = "https://files.pythonhosted.org/packages/d7/50/9349ee777614bc3062fc6b038503a59b2034d09dd259daf8192f56c06720/ruff-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c021f04ea06966b02614d442e94071781c424ab8e02ec7af2f037b4c1e01cc82", size = 11761739, upload-time = "2025-06-17T15:18:58.906Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/ad459de67c70ec112e2ba7206841c8f4eb340a03ee6a5cabc159fe558b8e/ruff-0.12.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d235618283718ee2fe14db07f954f9b2423700919dc688eacf3f8797a11315c", size = 12537098, upload-time = "2025-06-17T15:19:01.316Z" }, - { url = "https://files.pythonhosted.org/packages/ed/50/15ad9c80ebd3c4819f5bd8883e57329f538704ed57bac680d95cb6627527/ruff-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0758038f81beec8cc52ca22de9685b8ae7f7cc18c013ec2050012862cc9165", size = 12154122, upload-time = "2025-06-17T15:19:03.727Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/79b91e41bc8cc3e78ee95c87093c6cacfa275c786e53c9b11b9358026b3d/ruff-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:139b3d28027987b78fc8d6cfb61165447bdf3740e650b7c480744873688808c2", size = 11363374, upload-time = "2025-06-17T15:19:05.875Z" }, - { url = "https://files.pythonhosted.org/packages/db/c3/82b292ff8a561850934549aa9dc39e2c4e783ab3c21debe55a495ddf7827/ruff-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68853e8517b17bba004152aebd9dd77d5213e503a5f2789395b25f26acac0da4", size = 11587647, upload-time = "2025-06-17T15:19:08.246Z" }, - { url = "https://files.pythonhosted.org/packages/2b/42/d5760d742669f285909de1bbf50289baccb647b53e99b8a3b4f7ce1b2001/ruff-0.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a9512af224b9ac4757f7010843771da6b2b0935a9e5e76bb407caa901a1a514", size = 10527284, upload-time = "2025-06-17T15:19:10.37Z" }, - { url = "https://files.pythonhosted.org/packages/19/f6/fcee9935f25a8a8bba4adbae62495c39ef281256693962c2159e8b284c5f/ruff-0.12.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b08df3d96db798e5beb488d4df03011874aff919a97dcc2dd8539bb2be5d6a88", size = 10158609, upload-time = "2025-06-17T15:19:12.286Z" }, - { url = "https://files.pythonhosted.org/packages/37/fb/057febf0eea07b9384787bfe197e8b3384aa05faa0d6bd844b94ceb29945/ruff-0.12.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a315992297a7435a66259073681bb0d8647a826b7a6de45c6934b2ca3a9ed51", size = 11141462, upload-time = "2025-06-17T15:19:15.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/7c/1be8571011585914b9d23c95b15d07eec2d2303e94a03df58294bc9274d4/ruff-0.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e55e44e770e061f55a7dbc6e9aed47feea07731d809a3710feda2262d2d4d8a", size = 11641616, upload-time = "2025-06-17T15:19:17.6Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ef/b960ab4818f90ff59e571d03c3f992828d4683561095e80f9ef31f3d58b7/ruff-0.12.0-py3-none-win32.whl", hash = "sha256:7162a4c816f8d1555eb195c46ae0bd819834d2a3f18f98cc63819a7b46f474fb", size = 10525289, upload-time = "2025-06-17T15:19:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/34/93/8b16034d493ef958a500f17cda3496c63a537ce9d5a6479feec9558f1695/ruff-0.12.0-py3-none-win_amd64.whl", hash = "sha256:d00b7a157b8fb6d3827b49d3324da34a1e3f93492c1f97b08e222ad7e9b291e0", size = 11598311, upload-time = "2025-06-17T15:19:21.785Z" }, - { url = "https://files.pythonhosted.org/packages/d0/33/4d3e79e4a84533d6cd526bfb42c020a23256ae5e4265d858bd1287831f7d/ruff-0.12.0-py3-none-win_arm64.whl", hash = "sha256:8cd24580405ad8c1cc64d61725bca091d6b6da7eb3d36f72cc605467069d7e8b", size = 10724946, upload-time = "2025-06-17T15:19:23.952Z" }, +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]] From 3df57af2e45e19391c38e820ff0d08be5b1d2e53 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Tue, 6 Jan 2026 10:00:29 -0800 Subject: [PATCH 07/17] Remove ruff from direct dependencies --- pyproject.toml | 2 +- uv.lock | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6ac3639..71222e17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [{ name = "Temporal Technologies", email = "sdk@temporal.io" }] requires-python = ">=3.10" license = "MIT" license-files = ["LICENSE"] -dependencies = ["ruff>=0.12.0", "typing-extensions>=4.12.2"] +dependencies = ["typing-extensions>=4.12.2"] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index 41536c69..5819903b 100644 --- a/uv.lock +++ b/uv.lock @@ -420,7 +420,6 @@ name = "nexus-rpc" version = "1.3.0" source = { editable = "." } dependencies = [ - { name = "ruff" }, { name = "typing-extensions" }, ] @@ -439,10 +438,7 @@ dev = [ ] [package.metadata] -requires-dist = [ - { name = "ruff", specifier = ">=0.12.0" }, - { name = "typing-extensions", specifier = ">=4.12.2" }, -] +requires-dist = [{ name = "typing-extensions", specifier = ">=4.12.2" }] [package.metadata.requires-dev] dev = [ From 7d383b6fc1f5250ea6191cde36cdc5a5c98b5771 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Tue, 27 Jan 2026 20:43:15 -0800 Subject: [PATCH 08/17] 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 --- src/nexusrpc/__init__.py | 2 + src/nexusrpc/_common.py | 146 +++++++++++++++++++++++++---- tests/test_common.py | 192 +++++++++++++++++++++++++++++++-------- 3 files changed, 282 insertions(+), 58 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 595ce5c4..63cec8cb 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -17,6 +17,7 @@ from . import handler from ._common import ( + Failure, HandlerError, HandlerErrorType, InputT, @@ -35,6 +36,7 @@ __all__ = [ "Content", + "Failure", "get_operation", "get_service_definition", "handler", diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index c28650ac..53b7a140 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from enum import Enum from logging import getLogger -from typing import TypeVar +from typing import Any, Mapping, TypeVar logger = getLogger(__name__) @@ -20,7 +20,49 @@ """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: Any = 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 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 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 = metadata + self.details = details + self.cause = cause + + +class HandlerError(Failure): """ A Nexus handler error. @@ -52,6 +94,10 @@ def __init__( *, error_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. @@ -67,10 +113,22 @@ def __init__( :param retryable_override: Optionally set whether the error should be retried. By default, the error type is used to determine this. - """ - super().__init__(message) - # Handle string error types + :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. + """ + # Handle string error types (must be done before super().__init__ to build details) if isinstance(error_type, str): raw_error_type = error_type try: @@ -81,6 +139,24 @@ def __init__( else: raw_error_type = error_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.error_type = error_type self.raw_error_type = raw_error_type self.retryable_override = retryable_override @@ -209,15 +285,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 @@ -236,16 +307,53 @@ 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 class OperationErrorState(Enum): diff --git a/tests/test_common.py b/tests/test_common.py index 4df1f9ea..406b2d0d 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1,65 +1,126 @@ -from nexusrpc._common import HandlerError, HandlerErrorType +from nexusrpc._common import ( + Failure, + HandlerError, + HandlerErrorType, + OperationError, + OperationErrorState, +) -def test_handler_error_retryable_type(): - retryable_error_type = HandlerErrorType.RESOURCE_EXHAUSTED - err = HandlerError( +def test_failure_basic(): + f = Failure("test message") + assert str(f) == "test message" + assert f.message == "test message" + assert f.stack_trace is None + assert f.metadata is None + assert f.details is None + assert f.cause is None + assert isinstance(f, Exception) + + +def test_failure_with_all_fields(): + cause = Failure("root cause") + f = Failure( "test", - error_type=retryable_error_type, - retryable_override=True, + stack_trace="Traceback:\n File 'test.py', line 1", + metadata={"key": "value"}, + details={"code": 123}, + cause=cause, ) - assert err.retryable - assert err.error_type == retryable_error_type - assert err.raw_error_type == retryable_error_type.value + assert f.message == "test" + assert f.stack_trace == "Traceback:\n File 'test.py', line 1" + assert f.metadata == {"key": "value"} + assert f.details == {"code": 123} + assert f.cause is cause - err = HandlerError( + +def test_handler_error_spec_representation(): + """Test that HandlerError is a Failure and sets metadata/details per spec.""" + # Basic error with spec-compliant metadata and details + err = HandlerError("test", error_type=HandlerErrorType.INTERNAL) + assert isinstance(err, Failure) + assert isinstance(err, Exception) + assert err.message == "test" + assert err.metadata == {"type": "nexus.HandlerError"} + assert err.details == {"type": "INTERNAL"} + + # With retryable_override + err_with_retry = HandlerError( "test", - error_type=retryable_error_type, - retryable_override=False, + error_type=HandlerErrorType.INTERNAL, + retryable_override=True, ) - assert not err.retryable - assert err.error_type == retryable_error_type - assert err.raw_error_type == retryable_error_type.value + assert err_with_retry.details == {"type": "INTERNAL", "retryableOverride": True} + +def test_handler_error_with_all_fields(): + """Test HandlerError with all Failure fields populated.""" + cause = Failure("root cause") err = HandlerError( "test", - error_type=retryable_error_type, + error_type=HandlerErrorType.INTERNAL, + stack_trace="stack trace", + metadata={"k": "v"}, + details={"code": 1}, + cause=cause, ) - assert err.retryable - assert err.error_type == retryable_error_type - assert err.raw_error_type == retryable_error_type.value + assert err.message == "test" + assert err.stack_trace == "stack trace" + # User-provided keys merged with spec-required keys + assert err.metadata == {"type": "nexus.HandlerError", "k": "v"} + assert err.details == {"type": "INTERNAL", "code": 1} + assert err.cause is cause -def test_handler_error_non_retryable_type(): - non_retryable_error_type = HandlerErrorType.BAD_REQUEST +def test_handler_error_spec_keys_cannot_be_overridden(): + """Test that user-provided values cannot override spec-required keys.""" err = HandlerError( "test", - error_type=non_retryable_error_type, + error_type=HandlerErrorType.INTERNAL, retryable_override=True, + metadata={"type": "user-type", "user-key": "user-value"}, + details={ + "type": "user-type", + "retryableOverride": False, + "user-key": "user-value", + }, ) + assert err.metadata is not None + assert err.details is not None + # Spec keys take precedence + assert err.metadata["type"] == "nexus.HandlerError" + assert err.details["type"] == "INTERNAL" + assert err.details["retryableOverride"] is True + # User keys are preserved + assert err.metadata["user-key"] == "user-value" + assert err.details["user-key"] == "user-value" + + +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", error_type=retryable_type) assert err.retryable - assert err.error_type == non_retryable_error_type - assert err.raw_error_type == non_retryable_error_type.value + assert err.error_type == retryable_type + assert err.raw_error_type == retryable_type.value - err = HandlerError( - "test", - error_type=non_retryable_error_type, - retryable_override=False, - ) + err = HandlerError("test", error_type=retryable_type, retryable_override=False) assert not err.retryable - assert err.error_type == non_retryable_error_type - assert err.raw_error_type == non_retryable_error_type.value - err = HandlerError( - "test", - error_type=non_retryable_error_type, - ) + # Non-retryable error type (BAD_REQUEST) + non_retryable_type = HandlerErrorType.BAD_REQUEST + err = HandlerError("test", error_type=non_retryable_type) assert not err.retryable - assert err.error_type == non_retryable_error_type - assert err.raw_error_type == non_retryable_error_type.value + assert err.error_type == non_retryable_type + assert err.raw_error_type == non_retryable_type.value + + err = HandlerError("test", error_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", error_type="SOME_UNKNOWN_TYPE") assert err.retryable assert err.error_type == HandlerErrorType.UNKNOWN @@ -67,5 +128,58 @@ def test_handler_error_unknown_error_type(): err = HandlerError("test", error_type="SOME_UNKNOWN_TYPE", retryable_override=False) assert not err.retryable - assert err.error_type == HandlerErrorType.UNKNOWN - assert err.raw_error_type == "SOME_UNKNOWN_TYPE" + + +def test_operation_error_spec_representation(): + """Test that OperationError is a Failure and sets metadata/details per spec.""" + # Failed state + err = OperationError("test", state=OperationErrorState.FAILED) + assert isinstance(err, Failure) + assert isinstance(err, Exception) + assert err.message == "test" + assert err.state == OperationErrorState.FAILED + assert err.metadata == {"type": "nexus.OperationError"} + assert err.details == {"state": "failed"} + + # Canceled state + err_canceled = OperationError("test", state=OperationErrorState.CANCELED) + assert err_canceled.state == OperationErrorState.CANCELED + assert err_canceled.details == {"state": "canceled"} + + +def test_operation_error_with_all_fields(): + """Test OperationError with all Failure fields populated.""" + cause = Failure("root cause") + err = OperationError( + "test", + state=OperationErrorState.CANCELED, + stack_trace="stack trace", + metadata={"k": "v"}, + details={"code": 1}, + cause=cause, + ) + assert err.message == "test" + assert err.state == OperationErrorState.CANCELED + assert err.stack_trace == "stack trace" + # User-provided keys merged with spec-required keys + assert err.metadata == {"type": "nexus.OperationError", "k": "v"} + assert err.details == {"state": "canceled", "code": 1} + assert err.cause is cause + + +def test_operation_error_spec_keys_cannot_be_overridden(): + """Test that user-provided values cannot override spec-required keys.""" + err = OperationError( + "test", + state=OperationErrorState.FAILED, + metadata={"type": "user-type", "user-key": "user-value"}, + details={"state": "user-state", "user-key": "user-value"}, + ) + assert err.metadata is not None + assert err.details is not None + # Spec keys take precedence + assert err.metadata["type"] == "nexus.OperationError" + assert err.details["state"] == "failed" + # User keys are preserved + assert err.metadata["user-key"] == "user-value" + assert err.details["user-key"] == "user-value" From d95b401473fdbb140a4441defb914fb5a483b74d Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Wed, 28 Jan 2026 14:05:57 -0800 Subject: [PATCH 09/17] 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 --- src/nexusrpc/_common.py | 21 ++++++++++++++++++++- tests/test_common.py | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index 53b7a140..a222e81f 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -1,5 +1,6 @@ from __future__ import annotations +import traceback from dataclasses import dataclass from enum import Enum from logging import getLogger @@ -29,6 +30,8 @@ class Failure(Exception): See https://github.com/nexus-rpc/api/blob/main/SPEC.md#failure """ + _stack_trace: str | None + def __init__( self, message: str, @@ -56,11 +59,27 @@ def __init__( """ super().__init__(message) self.message = message - self.stack_trace = stack_trace + self._stack_trace = stack_trace self.metadata = metadata self.details = details self.cause = cause + @property + def stack_trace(self) -> str | None: + """ + The stack trace associated with this failure. + + Returns the explicit stack trace if one was provided during construction. + Otherwise, if this exception has been raised and caught, returns the + traceback from ``self.__traceback__``. Returns ``None`` if no stack trace + is available. + """ + if self._stack_trace: + return self._stack_trace + if self.__traceback__ is None: + return None + return "\n".join(traceback.format_tb(self.__traceback__)) + class HandlerError(Failure): """ diff --git a/tests/test_common.py b/tests/test_common.py index 406b2d0d..c8224eab 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -183,3 +183,30 @@ def test_operation_error_spec_keys_cannot_be_overridden(): # User keys are preserved assert err.metadata["user-key"] == "user-value" assert err.details["user-key"] == "user-value" + + +def test_failure_traceback_when_raised(): + """Test that stack_trace captures traceback when exception is raised.""" + # Failure + try: + raise Failure("raised failure") + except Failure as f: + assert f.stack_trace is not None + assert "test_failure_traceback_when_raised" in f.stack_trace + assert "raise Failure" in f.stack_trace + + # HandlerError + try: + raise HandlerError("raised handler error", error_type=HandlerErrorType.INTERNAL) + except HandlerError as e: + assert e.stack_trace is not None + assert "test_failure_traceback_when_raised" in e.stack_trace + assert "raise HandlerError" in e.stack_trace + + # OperationError + try: + raise OperationError("raised operation error", state=OperationErrorState.FAILED) + except OperationError as e: + assert e.stack_trace is not None + assert "test_failure_traceback_when_raised" in e.stack_trace + assert "raise OperationError" in e.stack_trace From 5ab1952cf4f4f81b03a920f3c65b366462313dc9 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Wed, 28 Jan 2026 14:08:17 -0800 Subject: [PATCH 10/17] Ignore IDE setting files in gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 60cde983..2ab88f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__ apidocs dist docs +.idea +.vscode From b55626dbffc454b13887295e3f91481cf2956fac Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Wed, 28 Jan 2026 15:11:07 -0800 Subject: [PATCH 11/17] 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 --- src/nexusrpc/_common.py | 30 +++++++++++++++++++++++--- tests/test_common.py | 47 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index a222e81f..ae316e16 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from enum import Enum from logging import getLogger +from types import MappingProxyType from typing import Any, Mapping, TypeVar logger = getLogger(__name__) @@ -38,7 +39,7 @@ def __init__( *, stack_trace: str | None = None, metadata: Mapping[str, str] | None = None, - details: Any = None, + details: Mapping[str, Any] | None = None, cause: Failure | None = None, ): """ @@ -60,10 +61,20 @@ def __init__( super().__init__(message) self.message = message self._stack_trace = stack_trace - self.metadata = metadata - self.details = details + 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 + ) 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})" + ) + @property def stack_trace(self) -> str | None: """ @@ -180,6 +191,13 @@ def __init__( self.raw_error_type = raw_error_type self.retryable_override = retryable_override + def __repr__(self) -> str: + return ( + f"HandlerError(message={self.message!r}, error_type={self.error_type!r}, " + f"retryable={self.retryable}, metadata={self.metadata!r}, " + f"details={self.details!r}, cause={self.cause!r})" + ) + @property def retryable(self) -> bool: """ @@ -374,6 +392,12 @@ def __init__( ) 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): """ diff --git a/tests/test_common.py b/tests/test_common.py index c8224eab..812f44f6 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1,3 +1,5 @@ +import pytest + from nexusrpc._common import ( Failure, HandlerError, @@ -210,3 +212,48 @@ def test_failure_traceback_when_raised(): assert e.stack_trace is not None assert "test_failure_traceback_when_raised" in e.stack_trace assert "raise OperationError" in e.stack_trace + + +def test_explicit_stack_trace_takes_precedence(): + """Test that explicit stack_trace takes precedence over __traceback__.""" + try: + raise Failure("test", stack_trace="explicit trace") + except Failure as f: + # Even though __traceback__ is set, explicit stack_trace wins + assert f.stack_trace == "explicit trace" + assert f.__traceback__ is not None # Verify traceback exists + + +def test_metadata_details_immutable(): + """Test that metadata and details cannot be modified after construction.""" + err = HandlerError("test", error_type=HandlerErrorType.INTERNAL) + + with pytest.raises(TypeError): + err.metadata["new_key"] = "value" # type: ignore[index] + + with pytest.raises(TypeError): + err.details["new_key"] = "value" # type: ignore[index] + + +def test_failure_repr(): + """Test __repr__ methods for debugging.""" + # Failure + f = Failure("test message", metadata={"k": "v"}, details={"code": 1}) + repr_str = repr(f) + assert "Failure(" in repr_str + assert "message='test message'" in repr_str + + # HandlerError + err = HandlerError("test", error_type=HandlerErrorType.INTERNAL) + repr_str = repr(err) + assert "HandlerError(" in repr_str + assert "message='test'" in repr_str + assert "error_type=" in repr_str + assert "retryable=" in repr_str + + # OperationError + op_err = OperationError("test", state=OperationErrorState.FAILED) + repr_str = repr(op_err) + assert "OperationError(" in repr_str + assert "message='test'" in repr_str + assert "state=" in repr_str From 486986d9f3beef1b4a72a3d4a3dc48eedf747e0e Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Wed, 28 Jan 2026 16:34:48 -0800 Subject: [PATCH 12/17] 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 --- src/nexusrpc/_common.py | 10 +++++--- tests/test_common.py | 54 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index ae316e16..e8f63417 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -67,12 +67,14 @@ def __init__( self.details: Mapping[str, Any] | None = ( MappingProxyType(dict(details)) if details else None ) - self.cause = cause + + 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})" + f"details={self.details!r}, cause={self.__cause__!r})" ) @property @@ -195,7 +197,7 @@ def __repr__(self) -> str: return ( f"HandlerError(message={self.message!r}, error_type={self.error_type!r}, " f"retryable={self.retryable}, metadata={self.metadata!r}, " - f"details={self.details!r}, cause={self.cause!r})" + f"details={self.details!r}, cause={self.__cause__!r})" ) @property @@ -395,7 +397,7 @@ def __init__( 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})" + f"metadata={self.metadata!r}, details={self.details!r}, cause={self.__cause__!r})" ) diff --git a/tests/test_common.py b/tests/test_common.py index 812f44f6..57d572cf 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -16,7 +16,7 @@ def test_failure_basic(): assert f.stack_trace is None assert f.metadata is None assert f.details is None - assert f.cause is None + assert f.__cause__ is None assert isinstance(f, Exception) @@ -33,7 +33,7 @@ def test_failure_with_all_fields(): assert f.stack_trace == "Traceback:\n File 'test.py', line 1" assert f.metadata == {"key": "value"} assert f.details == {"code": 123} - assert f.cause is cause + assert f.__cause__ is cause def test_handler_error_spec_representation(): @@ -71,7 +71,7 @@ def test_handler_error_with_all_fields(): # User-provided keys merged with spec-required keys assert err.metadata == {"type": "nexus.HandlerError", "k": "v"} assert err.details == {"type": "INTERNAL", "code": 1} - assert err.cause is cause + assert err.__cause__ is cause def test_handler_error_spec_keys_cannot_be_overridden(): @@ -166,7 +166,7 @@ def test_operation_error_with_all_fields(): # User-provided keys merged with spec-required keys assert err.metadata == {"type": "nexus.OperationError", "k": "v"} assert err.details == {"state": "canceled", "code": 1} - assert err.cause is cause + assert err.__cause__ is cause def test_operation_error_spec_keys_cannot_be_overridden(): @@ -235,6 +235,52 @@ def test_metadata_details_immutable(): err.details["new_key"] = "value" # type: ignore[index] +def test_failure_native_exception_chaining(): + """Test that Python's native 'raise ... from ...' syntax works with Failure.""" + root_cause = Failure("root cause") + + # Test raise ... from ... syntax + try: + try: + raise root_cause + except Failure: + raise Failure("outer failure") from root_cause + except Failure as f: + assert f.__cause__ is root_cause + assert f.message == "outer failure" + assert f.__cause__.message == "root cause" # type: ignore[union-attr] + + # Test that constructor cause= parameter also sets __cause__ + f2 = Failure("test", cause=root_cause) + assert f2.__cause__ is root_cause + + # Test chaining with HandlerError + try: + raise HandlerError( + "handler error", error_type=HandlerErrorType.INTERNAL + ) from root_cause + except HandlerError as e: + assert e.__cause__ is root_cause + + # Test chaining with OperationError + try: + raise OperationError( + "op error", state=OperationErrorState.FAILED + ) from root_cause + except OperationError as e: + assert e.__cause__ is root_cause + + # Test chaining from a non-Failure exception + try: + try: + raise ValueError("invalid value") + except ValueError as e: + raise Failure("wrapped error") from e + except Failure as f: + assert isinstance(f.__cause__, ValueError) + assert str(f.__cause__) == "invalid value" + + def test_failure_repr(): """Test __repr__ methods for debugging.""" # Failure From 46e524b474ec7381af2945c8a8e6c814bed79846 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 30 Jan 2026 10:14:06 -0800 Subject: [PATCH 13/17] 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 --- src/nexusrpc/_common.py | 27 ++++----------------------- tests/test_common.py | 37 ------------------------------------- 2 files changed, 4 insertions(+), 60 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index e8f63417..87a62415 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -1,6 +1,5 @@ from __future__ import annotations -import traceback from dataclasses import dataclass from enum import Enum from logging import getLogger @@ -31,8 +30,6 @@ class Failure(Exception): See https://github.com/nexus-rpc/api/blob/main/SPEC.md#failure """ - _stack_trace: str | None - def __init__( self, message: str, @@ -48,9 +45,9 @@ def __init__( :param message: A descriptive message for the failure. This will become the `message` in the resulting Nexus Failure object. - :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 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. @@ -60,7 +57,7 @@ def __init__( """ super().__init__(message) self.message = message - self._stack_trace = stack_trace + self.stack_trace = stack_trace self.metadata: Mapping[str, str] | None = ( MappingProxyType(dict(metadata)) if metadata else None ) @@ -77,22 +74,6 @@ def __repr__(self) -> str: f"details={self.details!r}, cause={self.__cause__!r})" ) - @property - def stack_trace(self) -> str | None: - """ - The stack trace associated with this failure. - - Returns the explicit stack trace if one was provided during construction. - Otherwise, if this exception has been raised and caught, returns the - traceback from ``self.__traceback__``. Returns ``None`` if no stack trace - is available. - """ - if self._stack_trace: - return self._stack_trace - if self.__traceback__ is None: - return None - return "\n".join(traceback.format_tb(self.__traceback__)) - class HandlerError(Failure): """ diff --git a/tests/test_common.py b/tests/test_common.py index 57d572cf..d005d744 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -187,43 +187,6 @@ def test_operation_error_spec_keys_cannot_be_overridden(): assert err.details["user-key"] == "user-value" -def test_failure_traceback_when_raised(): - """Test that stack_trace captures traceback when exception is raised.""" - # Failure - try: - raise Failure("raised failure") - except Failure as f: - assert f.stack_trace is not None - assert "test_failure_traceback_when_raised" in f.stack_trace - assert "raise Failure" in f.stack_trace - - # HandlerError - try: - raise HandlerError("raised handler error", error_type=HandlerErrorType.INTERNAL) - except HandlerError as e: - assert e.stack_trace is not None - assert "test_failure_traceback_when_raised" in e.stack_trace - assert "raise HandlerError" in e.stack_trace - - # OperationError - try: - raise OperationError("raised operation error", state=OperationErrorState.FAILED) - except OperationError as e: - assert e.stack_trace is not None - assert "test_failure_traceback_when_raised" in e.stack_trace - assert "raise OperationError" in e.stack_trace - - -def test_explicit_stack_trace_takes_precedence(): - """Test that explicit stack_trace takes precedence over __traceback__.""" - try: - raise Failure("test", stack_trace="explicit trace") - except Failure as f: - # Even though __traceback__ is set, explicit stack_trace wins - assert f.stack_trace == "explicit trace" - assert f.__traceback__ is not None # Verify traceback exists - - def test_metadata_details_immutable(): """Test that metadata and details cannot be modified after construction.""" err = HandlerError("test", error_type=HandlerErrorType.INTERNAL) From 6cfad4ab6718235bc4c25fb724245f4b19ac7280 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 30 Jan 2026 10:21:05 -0800 Subject: [PATCH 14/17] Revert renaming of HandlerError.type --- src/nexusrpc/_common.py | 16 ++++++++-------- src/nexusrpc/handler/_core.py | 6 +++--- tests/test_common.py | 26 +++++++++++++------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index 87a62415..ff3d3d4f 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -105,7 +105,7 @@ def __init__( self, message: str, *, - error_type: HandlerErrorType | str, + type: HandlerErrorType | str, retryable_override: bool | None = None, stack_trace: str | None = None, metadata: Mapping[str, str] | None = None, @@ -142,15 +142,15 @@ def __init__( :param cause: An optional Failure that caused this error. """ # Handle string error types (must be done before super().__init__ to build details) - if isinstance(error_type, str): - raw_error_type = error_type + if isinstance(type, str): + raw_error_type = type try: - error_type = HandlerErrorType[error_type] + type = HandlerErrorType[type] except KeyError: - logger.warning(f"Unknown Nexus HandlerErrorType: {error_type}") - error_type = HandlerErrorType.UNKNOWN + logger.warning(f"Unknown Nexus HandlerErrorType: {type}") + type = HandlerErrorType.UNKNOWN else: - raw_error_type = error_type.value + 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 {} @@ -170,7 +170,7 @@ def __init__( cause=cause, ) - self.error_type = error_type + self.error_type = type self.raw_error_type = raw_error_type self.retryable_override = retryable_override diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index b67dfa44..68ceef4e 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -208,7 +208,7 @@ def _get_service_handler(self, service_name: str) -> ServiceHandler: if service is None: raise HandlerError( f"No handler for service '{service_name}'.", - error_type=HandlerErrorType.NOT_FOUND, + type=HandlerErrorType.NOT_FOUND, ) return service @@ -376,7 +376,7 @@ def get_operation_handler(self, operation_name: str) -> OperationHandler[Any, An f"Nexus service definition '{self.service.name}' has no operation " f"'{operation_name}'. There are {len(self.service.operation_definitions)} operations " f"in the definition.", - error_type=HandlerErrorType.NOT_FOUND, + type=HandlerErrorType.NOT_FOUND, ) operation_handler = self.operation_handlers.get(operation_name) if operation_handler is None: @@ -384,7 +384,7 @@ def get_operation_handler(self, operation_name: str) -> OperationHandler[Any, An f"Nexus service implementation '{self.service.name}' has no handler for " f"operation '{operation_name}'. There are {len(self.operation_handlers)} " f"available operation handlers.", - error_type=HandlerErrorType.NOT_FOUND, + type=HandlerErrorType.NOT_FOUND, ) return operation_handler diff --git a/tests/test_common.py b/tests/test_common.py index d005d744..c1b3ab10 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -39,7 +39,7 @@ def test_failure_with_all_fields(): def test_handler_error_spec_representation(): """Test that HandlerError is a Failure and sets metadata/details per spec.""" # Basic error with spec-compliant metadata and details - err = HandlerError("test", error_type=HandlerErrorType.INTERNAL) + err = HandlerError("test", type=HandlerErrorType.INTERNAL) assert isinstance(err, Failure) assert isinstance(err, Exception) assert err.message == "test" @@ -49,7 +49,7 @@ def test_handler_error_spec_representation(): # With retryable_override err_with_retry = HandlerError( "test", - error_type=HandlerErrorType.INTERNAL, + type=HandlerErrorType.INTERNAL, retryable_override=True, ) assert err_with_retry.details == {"type": "INTERNAL", "retryableOverride": True} @@ -60,7 +60,7 @@ def test_handler_error_with_all_fields(): cause = Failure("root cause") err = HandlerError( "test", - error_type=HandlerErrorType.INTERNAL, + type=HandlerErrorType.INTERNAL, stack_trace="stack trace", metadata={"k": "v"}, details={"code": 1}, @@ -78,7 +78,7 @@ def test_handler_error_spec_keys_cannot_be_overridden(): """Test that user-provided values cannot override spec-required keys.""" err = HandlerError( "test", - error_type=HandlerErrorType.INTERNAL, + type=HandlerErrorType.INTERNAL, retryable_override=True, metadata={"type": "user-type", "user-key": "user-value"}, details={ @@ -102,33 +102,33 @@ 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", error_type=retryable_type) + err = HandlerError("test", type=retryable_type) assert err.retryable assert err.error_type == retryable_type assert err.raw_error_type == retryable_type.value - err = HandlerError("test", error_type=retryable_type, retryable_override=False) + 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", error_type=non_retryable_type) + err = HandlerError("test", type=non_retryable_type) assert not err.retryable assert err.error_type == non_retryable_type assert err.raw_error_type == non_retryable_type.value - err = HandlerError("test", error_type=non_retryable_type, retryable_override=True) + 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", error_type="SOME_UNKNOWN_TYPE") + err = HandlerError("test", type="SOME_UNKNOWN_TYPE") assert err.retryable assert err.error_type == HandlerErrorType.UNKNOWN assert err.raw_error_type == "SOME_UNKNOWN_TYPE" - err = HandlerError("test", error_type="SOME_UNKNOWN_TYPE", retryable_override=False) + err = HandlerError("test", type="SOME_UNKNOWN_TYPE", retryable_override=False) assert not err.retryable @@ -189,7 +189,7 @@ def test_operation_error_spec_keys_cannot_be_overridden(): def test_metadata_details_immutable(): """Test that metadata and details cannot be modified after construction.""" - err = HandlerError("test", error_type=HandlerErrorType.INTERNAL) + err = HandlerError("test", type=HandlerErrorType.INTERNAL) with pytest.raises(TypeError): err.metadata["new_key"] = "value" # type: ignore[index] @@ -220,7 +220,7 @@ def test_failure_native_exception_chaining(): # Test chaining with HandlerError try: raise HandlerError( - "handler error", error_type=HandlerErrorType.INTERNAL + "handler error", type=HandlerErrorType.INTERNAL ) from root_cause except HandlerError as e: assert e.__cause__ is root_cause @@ -253,7 +253,7 @@ def test_failure_repr(): assert "message='test message'" in repr_str # HandlerError - err = HandlerError("test", error_type=HandlerErrorType.INTERNAL) + err = HandlerError("test", type=HandlerErrorType.INTERNAL) repr_str = repr(err) assert "HandlerError(" in repr_str assert "message='test'" in repr_str From d0c4695f1433979d37eb2af57f689cc044ea6efd Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 30 Jan 2026 11:03:43 -0800 Subject: [PATCH 15/17] Finish error_type -> type revert --- src/nexusrpc/_common.py | 6 +++--- tests/test_common.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index ff3d3d4f..130ef212 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -170,13 +170,13 @@ def __init__( cause=cause, ) - self.error_type = type + 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}, error_type={self.error_type!r}, " + f"HandlerError(message={self.message!r}, error_type={self.type!r}, " f"retryable={self.retryable}, metadata={self.metadata!r}, " f"details={self.details!r}, cause={self.__cause__!r})" ) @@ -193,7 +193,7 @@ def retryable(self) -> bool: if self.retryable_override is not None: return self.retryable_override - match self.error_type: + match self.type: case ( HandlerErrorType.BAD_REQUEST | HandlerErrorType.UNAUTHENTICATED diff --git a/tests/test_common.py b/tests/test_common.py index c1b3ab10..7dce0323 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -104,7 +104,7 @@ def test_handler_error_retryable_behavior(): retryable_type = HandlerErrorType.RESOURCE_EXHAUSTED err = HandlerError("test", type=retryable_type) assert err.retryable - assert err.error_type == retryable_type + assert err.type == retryable_type assert err.raw_error_type == retryable_type.value err = HandlerError("test", type=retryable_type, retryable_override=False) @@ -114,7 +114,7 @@ def test_handler_error_retryable_behavior(): non_retryable_type = HandlerErrorType.BAD_REQUEST err = HandlerError("test", type=non_retryable_type) assert not err.retryable - assert err.error_type == non_retryable_type + 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) @@ -125,7 +125,7 @@ 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.error_type == HandlerErrorType.UNKNOWN + assert err.type == HandlerErrorType.UNKNOWN assert err.raw_error_type == "SOME_UNKNOWN_TYPE" err = HandlerError("test", type="SOME_UNKNOWN_TYPE", retryable_override=False) @@ -211,7 +211,7 @@ def test_failure_native_exception_chaining(): except Failure as f: assert f.__cause__ is root_cause assert f.message == "outer failure" - assert f.__cause__.message == "root cause" # type: ignore[union-attr] + assert getattr(f.__cause__, "message") == "root cause" # Test that constructor cause= parameter also sets __cause__ f2 = Failure("test", cause=root_cause) From 20ec75978f3573337f87961e9c42442642e79e6d Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 2 Feb 2026 12:34:57 -0800 Subject: [PATCH 16/17] 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 --- src/nexusrpc/_common.py | 14 +++++++------- tests/test_common.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index 130ef212..93ed143b 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -90,13 +90,13 @@ class HandlerError(Failure): # Raise a bad request error raise nexusrpc.HandlerError( "Invalid input provided", - error_type=nexusrpc.HandlerErrorType.BAD_REQUEST + type=nexusrpc.HandlerErrorType.BAD_REQUEST ) # Raise a retryable internal error raise nexusrpc.HandlerError( "Database unavailable", - error_type=nexusrpc.HandlerErrorType.INTERNAL, + type=nexusrpc.HandlerErrorType.INTERNAL, retryable_override=True ) """ @@ -118,10 +118,10 @@ def __init__( :param message: A descriptive message for the error. This will become the `message` in the resulting Nexus Failure object. - :param error_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 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 @@ -176,7 +176,7 @@ def __init__( def __repr__(self) -> str: return ( - f"HandlerError(message={self.message!r}, error_type={self.type!r}, " + 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})" ) diff --git a/tests/test_common.py b/tests/test_common.py index 7dce0323..135c028f 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -257,7 +257,7 @@ def test_failure_repr(): repr_str = repr(err) assert "HandlerError(" in repr_str assert "message='test'" in repr_str - assert "error_type=" in repr_str + assert "type=" in repr_str assert "retryable=" in repr_str # OperationError From c84730d4561669cadcd2af1878a043a2a70e0201 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 2 Feb 2026 13:30:19 -0800 Subject: [PATCH 17/17] Add default case to provide runtime default to retryable property --- src/nexusrpc/_common.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py index 93ed143b..47ac1af2 100644 --- a/src/nexusrpc/_common.py +++ b/src/nexusrpc/_common.py @@ -6,6 +6,8 @@ from types import MappingProxyType from typing import Any, Mapping, TypeVar +from typing_extensions import Never + logger = getLogger(__name__) InputT = TypeVar("InputT", contravariant=True) @@ -213,6 +215,14 @@ def retryable(self) -> bool: ): 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): """Nexus handler error types.