From 9c37b3f9566c63c72f9b6ada79880a1899b3f2df Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Mar 2026 14:09:16 -0400 Subject: [PATCH 1/6] Add `reg_err_types()` test suite for remote exc relay Verify registered custom error types round-trip correctly over IPC via `reg_err_types()` + `get_err_type()`. Deats, - `TestRegErrTypesPlumbing`: 5 unit tests for the type-registry plumbing (register, lookup, builtins, tractor-native types, unregistered returns `None`) - `test_registered_custom_err_relayed`: IPC end-to-end for a registered `CustomAppError` checking `.boxed_type`, `.src_type`, and `.tb_str` - `test_registered_another_err_relayed`: same for `AnotherAppError` (multi-type coverage) - `test_unregistered_custom_err_fails_lookup`: `xfail` documenting that `.boxed_type` can't resolve without `reg_err_types()` registration (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_reg_err_types.py | 313 ++++++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 tests/test_reg_err_types.py diff --git a/tests/test_reg_err_types.py b/tests/test_reg_err_types.py new file mode 100644 index 000000000..e1f4f249c --- /dev/null +++ b/tests/test_reg_err_types.py @@ -0,0 +1,313 @@ +''' +Verify that externally registered remote actor error +types are correctly relayed, boxed, and re-raised across +IPC actor hops via `reg_err_types()`. + +Also ensure that when custom error types are NOT registered +the framework indicates the lookup failure to the user. + +''' +import pytest +import trio +import tractor +from tractor import ( + Context, + Portal, + RemoteActorError, +) +from tractor._exceptions import ( + get_err_type, + reg_err_types, +) + + +# -- custom app-level errors for testing -- +class CustomAppError(Exception): + ''' + A hypothetical user-app error that should be + boxed+relayed by `tractor` IPC when registered. + + ''' + + +class AnotherAppError(Exception): + ''' + A second custom error for multi-type registration. + + ''' + + +class UnregisteredAppError(Exception): + ''' + A custom error that is intentionally NEVER + registered via `reg_err_types()` so we can + verify the framework's failure indication. + + ''' + + +# -- remote-task endpoints -- +@tractor.context +async def raise_custom_err( + ctx: Context, +) -> None: + ''' + Remote ep that raises a `CustomAppError` + after sync-ing with the caller. + + ''' + await ctx.started() + raise CustomAppError( + 'the app exploded remotely' + ) + + +@tractor.context +async def raise_another_err( + ctx: Context, +) -> None: + ''' + Remote ep that raises `AnotherAppError`. + + ''' + await ctx.started() + raise AnotherAppError( + 'another app-level kaboom' + ) + + +@tractor.context +async def raise_unreg_err( + ctx: Context, +) -> None: + ''' + Remote ep that raises an `UnregisteredAppError` + which has NOT been `reg_err_types()`-registered. + + ''' + await ctx.started() + raise UnregisteredAppError( + 'this error type is unknown to tractor' + ) + + +# -- unit tests for the type-registry plumbing -- + +class TestRegErrTypesPlumbing: + ''' + Low-level checks on `reg_err_types()` and + `get_err_type()` without requiring IPC. + + ''' + + def test_unregistered_type_returns_none(self): + ''' + An unregistered custom error name should yield + `None` from `get_err_type()`. + + ''' + result = get_err_type('CustomAppError') + assert result is None + + def test_register_and_lookup(self): + ''' + After `reg_err_types()`, the custom type should + be discoverable via `get_err_type()`. + + ''' + reg_err_types([CustomAppError]) + result = get_err_type('CustomAppError') + assert result is CustomAppError + + def test_register_multiple_types(self): + ''' + Registering a list of types should make each + one individually resolvable. + + ''' + reg_err_types([ + CustomAppError, + AnotherAppError, + ]) + assert ( + get_err_type('CustomAppError') + is CustomAppError + ) + assert ( + get_err_type('AnotherAppError') + is AnotherAppError + ) + + def test_builtin_types_always_resolve(self): + ''' + Builtin error types like `RuntimeError` and + `ValueError` should always be found without + any prior registration. + + ''' + assert ( + get_err_type('RuntimeError') + is RuntimeError + ) + assert ( + get_err_type('ValueError') + is ValueError + ) + + def test_tractor_native_types_resolve(self): + ''' + `tractor`-internal exc types (e.g. + `ContextCancelled`) should always resolve. + + ''' + assert ( + get_err_type('ContextCancelled') + is tractor.ContextCancelled + ) + + +# -- IPC-level integration tests -- + +def test_registered_custom_err_relayed( + debug_mode: bool, + tpt_proto: str, +): + ''' + When a custom error type is registered via + `reg_err_types()` on BOTH sides of an IPC dialog, + the parent should receive a `RemoteActorError` + whose `.boxed_type` matches the original custom + error type. + + ''' + reg_err_types([CustomAppError]) + + async def main(): + async with tractor.open_nursery( + debug_mode=debug_mode, + enable_transports=[tpt_proto], + ) as an: + ptl: Portal = await an.start_actor( + 'custom-err-raiser', + enable_modules=[__name__], + ) + async with ptl.open_context( + raise_custom_err, + ) as (ctx, sent): + assert not sent + try: + await ctx.wait_for_result() + except RemoteActorError as rae: + assert rae.boxed_type is CustomAppError + assert rae.src_type is CustomAppError + assert 'the app exploded remotely' in str( + rae.tb_str + ) + raise + + await an.cancel() + + with pytest.raises(RemoteActorError) as excinfo: + trio.run(main) + + rae = excinfo.value + assert rae.boxed_type is CustomAppError + + +def test_registered_another_err_relayed( + debug_mode: bool, + tpt_proto: str, +): + ''' + Same as above but for a different custom error + type to verify multi-type registration works + end-to-end over IPC. + + ''' + reg_err_types([AnotherAppError]) + + async def main(): + async with tractor.open_nursery( + debug_mode=debug_mode, + enable_transports=[tpt_proto], + ) as an: + ptl: Portal = await an.start_actor( + 'another-err-raiser', + enable_modules=[__name__], + ) + async with ptl.open_context( + raise_another_err, + ) as (ctx, sent): + assert not sent + try: + await ctx.wait_for_result() + except RemoteActorError as rae: + assert ( + rae.boxed_type + is AnotherAppError + ) + raise + + await an.cancel() + + with pytest.raises(RemoteActorError) as excinfo: + trio.run(main) + + rae = excinfo.value + assert rae.boxed_type is AnotherAppError + + +@pytest.mark.xfail( + reason=( + 'Unregistered custom error types are not ' + 'resolvable by `get_err_type()` and thus ' + '`.boxed_type` will be `None`, indicating ' + 'the framework cannot reconstruct the ' + 'original remote error type - the user ' + 'must call `reg_err_types()` to fix this.' + ), +) +def test_unregistered_custom_err_fails_lookup( + debug_mode: bool, + tpt_proto: str, +): + ''' + When a custom error type is NOT registered the + received `RemoteActorError.boxed_type` should NOT + match the original error type. + + This test is `xfail` to document the expected + failure mode and to alert the user that + `reg_err_types()` must be called for custom + error types to relay correctly. + + ''' + # NOTE: intentionally do NOT call + # `reg_err_types([UnregisteredAppError])` + + async def main(): + async with tractor.open_nursery( + debug_mode=debug_mode, + enable_transports=[tpt_proto], + ) as an: + ptl: Portal = await an.start_actor( + 'unreg-err-raiser', + enable_modules=[__name__], + ) + async with ptl.open_context( + raise_unreg_err, + ) as (ctx, sent): + assert not sent + await ctx.wait_for_result() + + await an.cancel() + + with pytest.raises(RemoteActorError) as excinfo: + trio.run(main) + + rae = excinfo.value + + # XXX this SHOULD fail bc the type was never + # registered and thus `get_err_type()` returns + # `None` for the boxed type lookup. + assert rae.boxed_type is UnregisteredAppError From a41c6d5c709f1ff76116b0dcf32d9a3dac5c2457 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Mar 2026 15:15:28 -0400 Subject: [PATCH 2/6] Fix unregistered-remote-error-type relay crash Make `RemoteActorError` resilient to unresolved custom error types so that errors from remote actors always relay back to the caller - even when the user hasn't called `reg_err_types()` to register the exc type. Deats, - `.src_type`: log warning + return `None` instead of raising `TypeError` which was crashing the entire `_deliver_msg()` -> `pformat()` chain before the error could be relayed. - `.boxed_type_str`: fallback to `_ipc_msg.boxed_type_str` when the type obj can't be resolved so the type *name* is always available. - `unwrap_src_err()`: fallback to `RuntimeError` preserving original type name + traceback. - `unpack_error()`: log warning when `get_err_type()` returns `None` telling the user to call `reg_err_types()`. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/_exceptions.py | 93 ++++++++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/tractor/_exceptions.py b/tractor/_exceptions.py index 66aea7f10..3e1624e21 100644 --- a/tractor/_exceptions.py +++ b/tractor/_exceptions.py @@ -434,24 +434,36 @@ def src_type_str(self) -> str: return self._ipc_msg.src_type_str @property - def src_type(self) -> str: + def src_type(self) -> Type[BaseException]|None: ''' - Error type raised by original remote faulting actor. + Error type raised by original remote faulting + actor. - When the error has only been relayed a single actor-hop - this will be the same as the `.boxed_type`. + When the error has only been relayed a single + actor-hop this will be the same as + `.boxed_type`. + + If the type can not be resolved locally (i.e. + it was not registered via `reg_err_types()`) + a warning is logged and `None` is returned; + all string-level error info (`.src_type_str`, + `.tb_str`, etc.) remains available. ''' if self._src_type is None: self._src_type = get_err_type( self._ipc_msg.src_type_str ) - if not self._src_type: - raise TypeError( - f'Failed to lookup src error type with ' - f'`tractor._exceptions.get_err_type()` :\n' - f'{self.src_type_str}' + log.warning( + f'Failed to lookup src error type via\n' + f'`tractor._exceptions.get_err_type()`:\n' + f'\n' + f'`{self._ipc_msg.src_type_str}`' + f' is not registered!\n' + f'\n' + f'Call `reg_err_types()` to enable' + f' full type reconstruction.\n' ) return self._src_type @@ -459,16 +471,26 @@ def src_type(self) -> str: @property def boxed_type_str(self) -> str: ''' - String-name of the (last hop's) boxed error type. + String-name of the (last hop's) boxed error + type. + + Falls back to the IPC-msg-encoded type-name + str when the type can not be resolved locally + (e.g. unregistered custom errors). ''' # TODO, maybe support also serializing the - # `ExceptionGroup.exeptions: list[BaseException]` set under - # certain conditions? + # `ExceptionGroup.exeptions: list[BaseException]` + # set under certain conditions? bt: Type[BaseException] = self.boxed_type if bt: return str(bt.__name__) + # fallback to the str name from the IPC msg + # when the type obj can't be resolved. + if self._ipc_msg: + return self._ipc_msg.boxed_type_str + return '' @property @@ -701,10 +723,22 @@ def unwrap( failing actor's remote env. ''' - # TODO: better tb insertion and all the fancier dunder - # metadata stuff as per `.__context__` etc. and friends: + # TODO: better tb insertion and all the fancier + # dunder metadata stuff as per `.__context__` + # etc. and friends: # https://github.com/python-trio/trio/issues/611 - src_type_ref: Type[BaseException] = self.src_type + src_type_ref: Type[BaseException]|None = ( + self.src_type + ) + if src_type_ref is None: + # unresolvable type: fall back to + # a `RuntimeError` preserving original + # traceback + type name. + return RuntimeError( + f'{self.src_type_str}: ' + f'{self.tb_str}' + ) + return src_type_ref(self.tb_str) # TODO: local recontruction of nested inception for a given @@ -1233,14 +1267,31 @@ def unpack_error( if not isinstance(msg, Error): return None - # try to lookup a suitable error type from the local runtime - # env then use it to construct a local instance. - # boxed_type_str: str = error_dict['boxed_type_str'] + # try to lookup a suitable error type from the + # local runtime env then use it to construct a + # local instance. boxed_type_str: str = msg.boxed_type_str - boxed_type: Type[BaseException] = get_err_type(boxed_type_str) + boxed_type: Type[BaseException] = get_err_type( + boxed_type_str + ) - # retrieve the error's msg-encoded remotoe-env info - message: str = f'remote task raised a {msg.boxed_type_str!r}\n' + if boxed_type is None: + log.warning( + f'Failed to resolve remote error type\n' + f'`{boxed_type_str}` - boxing as\n' + f'`RemoteActorError` with original\n' + f'traceback preserved.\n' + f'\n' + f'Call `reg_err_types()` to enable\n' + f'full type reconstruction.\n' + ) + + # retrieve the error's msg-encoded remote-env + # info + message: str = ( + f'remote task raised a ' + f'{msg.boxed_type_str!r}\n' + ) # TODO: do we even really need these checks for RAEs? if boxed_type_str in [ From 80597b80bf6a328f6ecbb10472508e0d738ef27e Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Mar 2026 16:28:19 -0400 Subject: [PATCH 3/6] Add passing test for unregistered err relay Drop the `xfail` test and instead add a new one that ensures the `tractor._exceptions` fixes enable graceful relay of remote-but-unregistered error types via the unboxing of just the `rae.src_type_str/boxed_type_str` content. The test also ensures a warning is included with remote error content indicating the user should register their error type for effective cross-actor re-raising. Deats, - add `test_unregistered_err_still_relayed`: verify the `RemoteActorError` IS raised with `.boxed_type` as `None` but `.src_type_str`, `.boxed_type_str`, and `.tb_str` all preserved from the IPC msg. - drop `test_unregistered_boxed_type_resolution_xfail` since the new above case covers it and we don't need to have an effectively entirely repeated test just with an inverse assert as it's last line.. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_reg_err_types.py | 56 ++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/tests/test_reg_err_types.py b/tests/test_reg_err_types.py index e1f4f249c..1164d2461 100644 --- a/tests/test_reg_err_types.py +++ b/tests/test_reg_err_types.py @@ -257,29 +257,23 @@ async def main(): assert rae.boxed_type is AnotherAppError -@pytest.mark.xfail( - reason=( - 'Unregistered custom error types are not ' - 'resolvable by `get_err_type()` and thus ' - '`.boxed_type` will be `None`, indicating ' - 'the framework cannot reconstruct the ' - 'original remote error type - the user ' - 'must call `reg_err_types()` to fix this.' - ), -) -def test_unregistered_custom_err_fails_lookup( +def test_unregistered_err_still_relayed( debug_mode: bool, tpt_proto: str, ): ''' - When a custom error type is NOT registered the - received `RemoteActorError.boxed_type` should NOT - match the original error type. + Verify that even when a custom error type is NOT registered via + `reg_err_types()`, the remote error is still relayed as + a `RemoteActorError` with all string-level info preserved + (traceback, type name, source actor uid). + + The `.boxed_type` will be `None` (type obj can't be resolved) but + `.boxed_type_str` and `.src_type_str` still report the original + type name from the IPC msg. - This test is `xfail` to document the expected - failure mode and to alert the user that - `reg_err_types()` must be called for custom - error types to relay correctly. + This document the expected limitation: without `reg_err_types()` + the `.boxed_type` property can NOT resolve to the original Python + type. ''' # NOTE: intentionally do NOT call @@ -307,7 +301,25 @@ async def main(): rae = excinfo.value - # XXX this SHOULD fail bc the type was never - # registered and thus `get_err_type()` returns - # `None` for the boxed type lookup. - assert rae.boxed_type is UnregisteredAppError + # the error IS relayed even without + # registration; type obj is unresolvable but + # all string-level info is preserved. + assert rae.boxed_type is None # NOT `UnregisteredAppError` + assert rae.src_type is None + + # string names survive the IPC round-trip + # via the `Error` msg fields. + assert ( + rae.src_type_str + == + 'UnregisteredAppError' + ) + assert ( + rae.boxed_type_str + == + 'UnregisteredAppError' + ) + + # original traceback content is preserved + assert 'this error type is unknown' in rae.tb_str + assert 'UnregisteredAppError' in rae.tb_str From 5968a3c773140d71b2e006f3974dbb0671d7eca6 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 24 Mar 2026 16:45:24 -0400 Subject: [PATCH 4/6] Use `''` for unresolvable `.boxed_type_str` Add a teensie unit test to match. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_reg_err_types.py | 10 ++++++++++ tractor/_exceptions.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_reg_err_types.py b/tests/test_reg_err_types.py index 1164d2461..ab14ae5a6 100644 --- a/tests/test_reg_err_types.py +++ b/tests/test_reg_err_types.py @@ -165,6 +165,16 @@ def test_tractor_native_types_resolve(self): is tractor.ContextCancelled ) + def test_boxed_type_str_without_ipc_msg(self): + ''' + When a `RemoteActorError` is constructed + without an IPC msg (and no resolvable type), + `.boxed_type_str` should return `''`. + + ''' + rae = RemoteActorError('test') + assert rae.boxed_type_str == '' + # -- IPC-level integration tests -- diff --git a/tractor/_exceptions.py b/tractor/_exceptions.py index 3e1624e21..21d76d1c1 100644 --- a/tractor/_exceptions.py +++ b/tractor/_exceptions.py @@ -491,7 +491,7 @@ def boxed_type_str(self) -> str: if self._ipc_msg: return self._ipc_msg.boxed_type_str - return '' + return '' @property def boxed_type(self) -> Type[BaseException]: From 8f44efa327b38c602a2e8f45b23600d5453fbeda Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 31 Mar 2026 01:23:25 -0400 Subject: [PATCH 5/6] Drop stale `.cancel()`, fix docstring typo in tests - Remove leftover `await an.cancel()` in `test_registered_custom_err_relayed`; the nursery already cancels on scope exit. - Fix `This document` -> `This documents` typo in `test_unregistered_err_still_relayed` docstring. Review: PR #426 (Copilot) https://github.com/goodboy/tractor/pull/426 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_reg_err_types.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_reg_err_types.py b/tests/test_reg_err_types.py index ab14ae5a6..82de8d083 100644 --- a/tests/test_reg_err_types.py +++ b/tests/test_reg_err_types.py @@ -215,8 +215,6 @@ async def main(): ) raise - await an.cancel() - with pytest.raises(RemoteActorError) as excinfo: trio.run(main) @@ -281,7 +279,7 @@ def test_unregistered_err_still_relayed( `.boxed_type_str` and `.src_type_str` still report the original type name from the IPC msg. - This document the expected limitation: without `reg_err_types()` + This documents the expected limitation: without `reg_err_types()` the `.boxed_type` property can NOT resolve to the original Python type. From c3d1ec22ebdedb2f2022c42cb5fe30faa0c6cece Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 31 Mar 2026 01:24:48 -0400 Subject: [PATCH 6/6] Fix `Type[BaseException]` annots, guard `.src_type` resolve - Use `Type[BaseException]` (not bare `BaseException`) for all err-type references: `get_err_type()` return, `._src_type`, `boxed_type` in `unpack_error()`. - Add `|None` where types can be unresolvable (`get_err_type()`, `.boxed_type` property). - Add `._src_type_resolved` flag to prevent repeated lookups and guard against `._ipc_msg is None`. - Fix `recevier` and `exeptions` typos. Review: PR #426 (Copilot) https://github.com/goodboy/tractor/pull/426 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/_exceptions.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tractor/_exceptions.py b/tractor/_exceptions.py index 21d76d1c1..5ec9cbd5c 100644 --- a/tractor/_exceptions.py +++ b/tractor/_exceptions.py @@ -195,7 +195,7 @@ def reg_err_types( Such that error types can be registered by an external `tractor`-use-app code base which are expected to be raised - remotely; enables them being re-raised on the recevier side of + remotely; enables them being re-raised on the receiver side of some inter-actor IPC dialog. ''' @@ -211,7 +211,7 @@ def reg_err_types( ) -def get_err_type(type_name: str) -> BaseException|None: +def get_err_type(type_name: str) -> Type[BaseException]|None: ''' Look up an exception type by name from the set of locally known namespaces: @@ -325,7 +325,8 @@ def __init__( # also pertains to our long long oustanding issue XD # https://github.com/goodboy/tractor/issues/5 self._boxed_type: BaseException = boxed_type - self._src_type: BaseException|None = None + self._src_type: Type[BaseException]|None = None + self._src_type_resolved: bool = False self._ipc_msg: Error|None = ipc_msg self._extra_msgdata = extra_msgdata @@ -450,7 +451,12 @@ def src_type(self) -> Type[BaseException]|None: `.tb_str`, etc.) remains available. ''' - if self._src_type is None: + if not self._src_type_resolved: + self._src_type_resolved = True + + if self._ipc_msg is None: + return None + self._src_type = get_err_type( self._ipc_msg.src_type_str ) @@ -480,7 +486,7 @@ def boxed_type_str(self) -> str: ''' # TODO, maybe support also serializing the - # `ExceptionGroup.exeptions: list[BaseException]` + # `ExceptionGroup.exceptions: list[BaseException]` # set under certain conditions? bt: Type[BaseException] = self.boxed_type if bt: @@ -494,7 +500,7 @@ def boxed_type_str(self) -> str: return '' @property - def boxed_type(self) -> Type[BaseException]: + def boxed_type(self) -> Type[BaseException]|None: ''' Error type boxed by last actor IPC hop. @@ -1271,7 +1277,7 @@ def unpack_error( # local runtime env then use it to construct a # local instance. boxed_type_str: str = msg.boxed_type_str - boxed_type: Type[BaseException] = get_err_type( + boxed_type: Type[BaseException]|None = get_err_type( boxed_type_str )