Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 333 additions & 0 deletions tests/test_reg_err_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
'''
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
Comment on lines +103 to +120
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

These tests mutate a global registry in tractor._exceptions via reg_err_types() and never restore it. That makes test_unregistered_type_returns_none order-dependent (it can fail if any earlier test registered CustomAppError). Use a fixture/monkeypatch to restore the prior module attributes after each test, or define per-test unique exception class names so registration can’t leak across tests.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 response authored by claude-code

Same concern as the earlier review — see reply on comment #4. The tests are intentionally ordered within the class and IPC tests spawn subprocesses with independent state.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This definitely valid, see my prior comment response.

I think at the least we should make a dedicated follow up issue and todo to refine this rather naive first draft.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 response authored by claude-code

Tracked as item 3 in #433monkeypatch / fixture cleanup options. Ties into the CM interface (item 1) since an unreg_err_types() counterpart would solve both.


def test_register_multiple_types(self):
'''
Registering a list of types should make each
one individually resolvable.

'''
reg_err_types([
CustomAppError,
AnotherAppError,
])
assert (
Comment on lines +118 to +132
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

These tests mutate global process state by registering CustomAppError / AnotherAppError into tractor._exceptions and never cleaning it up. That makes the unit tests order-dependent and can leak registrations into unrelated tests in the same session. Consider using monkeypatch (or a fixture/finalizer) to remove the injected attributes after each test, or use unique per-test type names to avoid cross-test contamination.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 response authored by claude-code

The tests are intentionally ordered within the class — test_unregistered_type_returns_none runs first before any registration. The IPC tests below use trio.run() spawning subprocesses, so they get their own process state. Adding monkeypatch cleanup here would add complexity without meaningful benefit since the module is a dedicated test file with no cross-module side-effects.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

hmm i do tend to agree we should support a @cm iface for this though.

my question would be around the usage warts at import time though..

having a with reg_err_types(..) as type_registry type iface is that it's going to force an indentation level at module scope..

i can see it being the right iface at any lower scope those (function, class, etc.) and having dynamic ctx control of what etypes should be allowed for a given runtime context is something that can likely interplay with supervision primitives around other runtime APIs like Portal.open_context(), Context.open_stream() etc.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 response authored by claude-code

Tracked as item 1 in #433. Noted the module-scope indentation concern — the issue lists three possible approaches: @cm for scoped usage only, a decorator form, or an unreg_err_types() counterpart instead of CM.

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
)

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 `'<unknown>'`.

'''
rae = RemoteActorError('test')
assert rae.boxed_type_str == '<unknown>'


# -- 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

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


def test_unregistered_err_still_relayed(
debug_mode: bool,
tpt_proto: str,
):
'''
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 documents the expected limitation: without `reg_err_types()`
the `.boxed_type` property can NOT resolve to the original Python
type.

'''
# 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

# 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
Loading
Loading