diff --git a/docs/04_upgrading/upgrading_to_v4.md b/docs/04_upgrading/upgrading_to_v4.md index edb105a7..e318ab75 100644 --- a/docs/04_upgrading/upgrading_to_v4.md +++ b/docs/04_upgrading/upgrading_to_v4.md @@ -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. diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 23c70e9d..e1acf238 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -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. @@ -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}) @@ -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) diff --git a/tests/unit/actor/test_actor_lifecycle.py b/tests/unit/actor/test_actor_lifecycle.py index d1bcdcbf..7307fa23 100644 --- a/tests/unit/actor/test_actor_lifecycle.py +++ b/tests/unit/actor/test_actor_lifecycle.py @@ -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')