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
18 changes: 18 additions & 0 deletions docs/04_upgrading/upgrading_to_v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ run = await Actor.start('my-actor-id', wait_for_finish=60)
run = await Actor.call('my-actor-id', wait=timedelta(seconds=60))
```

## Actor exit codes and control-flow exceptions

Exiting the Actor context (`async with Actor:`, `Actor.exit()`, or `Actor.fail()`) no longer maps every exception to a generic failure. In v3, any exception leaving the Actor context was logged as `Actor failed with an exception` and forced the exit code to `91`, even when you'd requested a different one. In v4 the exit code follows the exception:

- A regular `Exception` still exits with `91` (`EXIT_CODE_ERROR_USER_FUNCTION_THREW`), but only when you haven't chosen an exit code yourself. `Actor.fail(exit_code=..., exception=...)` now honors the code you pass.
- `SystemExit` keeps its own code, so `sys.exit(n)` inside the Actor block exits with `n` instead of `91`.
- `KeyboardInterrupt` (Ctrl+C) and `asyncio.CancelledError` are re-raised after cleanup instead of being swallowed. Interrupt and cancellation semantics are preserved, and neither is logged as `Actor failed with an exception`.

If you relied on `Actor.fail(exception=...)` always producing exit code `91`, pass it explicitly with `Actor.fail(exit_code=91, exception=...)`. Mapping specific errors to specific exit codes now works as documented:

```python
try:
await do_work()
except ValueError as exc:
# v3 exited with 91; v4 exits with the requested 10.
await Actor.fail(exit_code=10, exception=exc)
```

## Built on apify-client v3

The SDK is now built on [`apify-client`](https://docs.apify.com/api/client/python) v3 and no longer depends on `apify-shared`. The sections below cover the user-visible consequences; see the client's [Upgrading to v3](https://docs.apify.com/api/client/python/docs/upgrading/upgrading-to-v3) guide for the full list of changes in the client itself.
Expand Down
28 changes: 22 additions & 6 deletions src/apify/_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,11 @@ async def __aexit__(
) -> None:
"""Exit the Actor context.

If the block exits with an exception, the Actor fails with a non-zero exit code.
Otherwise, it exits cleanly. In both cases the Actor:
If the block raises a regular exception, the Actor fails with `EXIT_CODE_ERROR_USER_FUNCTION_THREW`,
unless an exit code was already set explicitly (e.g. via `fail(exit_code=...)`). A `SystemExit` keeps
its own exit code, while `KeyboardInterrupt` and `asyncio.CancelledError` are re-raised after cleanup
so interrupt and cancellation semantics are preserved. Otherwise, the Actor exits cleanly. In every
case the Actor:

- Cancels periodic `PERSIST_STATE` events.
- Sends a final `PERSIST_STATE` event.
Expand All @@ -236,11 +239,20 @@ async def __aexit__(
if not self._active:
raise RuntimeError('The _ActorType is not active. Use it within the async context.')

if exc_value and not is_running_in_ipython():
# In IPython, we don't run `sys.exit()` during Actor exits,
# so the exception traceback will be printed on its own
# Only a regular `Exception` (or `SystemExit`, below) is a failure. Any other `BaseException`
# (Ctrl+C, cancellation, ...) is a control-flow signal: clean up, then let it propagate.
reraise_control_flow = exc_value is not None and not isinstance(exc_value, (Exception, SystemExit))

if isinstance(exc_value, SystemExit):
# Keep the code from `sys.exit()` (no argument means a clean exit).
code = exc_value.code
self.exit_code = code if isinstance(code, int) else 0 if code is None else 1
elif isinstance(exc_value, Exception) and not is_running_in_ipython():
# In IPython we don't call `sys.exit()`, so the traceback prints on its own.
self.log.exception('Actor failed with an exception', exc_info=exc_value)
self.exit_code = EXIT_CODE_ERROR_USER_FUNCTION_THREW
# Fall back to the error code only if the caller hasn't chosen one (e.g. via `fail(exit_code=...)`).
if self.exit_code == 0:
self.exit_code = EXIT_CODE_ERROR_USER_FUNCTION_THREW

self._is_exiting = True
self.log.info('Exiting Actor', extra={'exit_code': self.exit_code})
Expand Down Expand Up @@ -278,6 +290,10 @@ async def finalize() -> None:
finally:
self._active = False

if reraise_control_flow:
# Return without `sys.exit()` so the original exception re-raises.
return

if self._exit_process:
sys.exit(self.exit_code)

Expand Down
45 changes: 45 additions & 0 deletions tests/unit/actor/test_actor_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,51 @@ async def test_unhandled_exception_sets_error_exit_code() -> None:
assert actor.exit_code == EXIT_CODE_ERROR_USER_FUNCTION_THREW


async def test_fail_respects_explicit_exit_code_with_exception(actor: _ActorType) -> None:
"""fail(exit_code=X, exception=e) must honor the requested exit code, not override it with the error code."""
await actor.fail(exit_code=42, exception=ValueError('boom'))
assert actor.exit_code == 42


@pytest.mark.parametrize(
('system_exit', 'expected_code'),
[
pytest.param(SystemExit(5), 5, id='nonzero'),
pytest.param(SystemExit(), 0, id='no-code'),
],
)
async def test_system_exit_in_block_preserves_exit_code(system_exit: SystemExit, expected_code: int) -> None:
"""sys.exit(n) inside the Actor block must propagate its own code, not the user-function-threw code."""
actor = Actor(exit_process=False)
with pytest.raises(SystemExit):
async with actor:
raise system_exit

assert actor.exit_code == expected_code


async def test_keyboard_interrupt_propagates_unchanged(caplog: pytest.LogCaptureFixture) -> None:
"""Ctrl+C must propagate as KeyboardInterrupt, not become a logged failure with the error exit code."""
actor = Actor(exit_process=True)
with pytest.raises(KeyboardInterrupt):
async with actor:
raise KeyboardInterrupt

assert actor.exit_code != EXIT_CODE_ERROR_USER_FUNCTION_THREW
assert not [r for r in caplog.records if r.msg == 'Actor failed with an exception']


async def test_cancelled_error_propagates_unchanged(caplog: pytest.LogCaptureFixture) -> None:
"""asyncio.CancelledError must propagate (cancellation contract), not be swallowed as an Actor failure."""
actor = Actor(exit_process=True)
with pytest.raises(asyncio.CancelledError):
async with actor:
raise asyncio.CancelledError

assert actor.exit_code != EXIT_CODE_ERROR_USER_FUNCTION_THREW
assert not [r for r in caplog.records if r.msg == 'Actor failed with an exception']


async def test_actor_stops_periodic_events_after_exit(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that periodic events (PERSIST_STATE and SYSTEM_INFO) stop emitting after Actor exits."""
monkeypatch.setenv(ApifyEnvVars.SYSTEM_INFO_INTERVAL_MILLIS, '100')
Expand Down