From 853e2c09e135603beaf5ee29bc86f1c378922521 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Wed, 21 Oct 2026 12:06:47 +0300 Subject: [PATCH] fix!: swallow and warn when setting the status message fails `Actor.set_status_message()` no longer raises when the run update fails. It logs a warning and returns `None`, the same value it returns locally, and abandons an update that takes longer than one second. This matches the JavaScript SDK (apify/apify-sdk-js#807). BREAKING CHANGE: `Actor.set_status_message()` returns `None` instead of raising when the run update fails or times out, and logs a new warning `Failed to set status message for Actor run with ID "..."`. Closes #1199 --- docs/04_upgrading/upgrading_to_v4.md | 10 ++++ src/apify/_actor.py | 36 +++++++++----- src/apify/_consts.py | 3 ++ tests/unit/actor/test_actor_helpers.py | 68 ++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/docs/04_upgrading/upgrading_to_v4.md b/docs/04_upgrading/upgrading_to_v4.md index f7c774885..55d8bf462 100644 --- a/docs/04_upgrading/upgrading_to_v4.md +++ b/docs/04_upgrading/upgrading_to_v4.md @@ -285,6 +285,16 @@ The typed public API is unchanged, so your code keeps working without edits. Onl - The local no-op messages from `Actor.metamorph()`, `Actor.reboot()`, and `Actor.add_webhook()`, logged when the Actor runs outside the Apify platform, are now `warning` instead of `error`. The Scrapy scheduler message for a request that can't be converted to an Apify request changed the same way. - The "unknown platform event" message is now `debug` instead of `info`. +### Actor.set_status_message no longer raises on a failed update + +`Actor.set_status_message()` used to raise when the run update failed. It now logs a warning and returns `None`, the same value it returns when running locally, and gives up on an update that takes longer than one second. This matches the JavaScript SDK, where a failed status message update has always been a warning. Check the return value if your code needs to know whether the run was updated: + +```python +run = await Actor.set_status_message('Crawled 10/100 pages') +if run: + Actor.log.info(run.status_message) +``` + ### Module-qualified logger names The charging, proxy, and events modules now log under their own child loggers (`apify._charging`, `apify._proxy_configuration`, and `apify.events._apify_event_manager`) instead of the generic `apify` logger. These child loggers still propagate to `apify`, so a handler attached to `apify` keeps receiving their records. Update only configuration that targets the exact `apify` logger name for these messages. diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 6026d7898..fe3a67c39 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -37,7 +37,13 @@ ) from apify._child_runs import ChildRunRegistry from apify._configuration import Configuration -from apify._consts import EVENT_LISTENERS_TIMEOUT, EXIT_CODE_ERROR_USER_FUNCTION_THREW, ActorEnvVars, ApifyEnvVars +from apify._consts import ( + EVENT_LISTENERS_TIMEOUT, + EXIT_CODE_ERROR_USER_FUNCTION_THREW, + STATUS_MESSAGE_UPDATE_TIMEOUT, + ActorEnvVars, + ApifyEnvVars, +) from apify._crypto import decrypt_input_secrets, load_private_key from apify._proxy_configuration import ProxyConfiguration from apify._utils import docs_group, docs_name, ensure_context, get_system_info, is_running_in_ipython @@ -1705,12 +1711,15 @@ async def set_status_message( ) -> Run | None: """Set the status message for the current Actor run. + The status message is cosmetic, so a failed or timed-out update is logged as a warning and never raises. + Args: status_message: The status message to set to the run. is_terminal: Set this flag to True if this is the final status message of the Actor run. Returns: - The updated Actor run object. + The updated Actor run object, or `None` when no run was updated: when running locally, or when the + update failed or timed out. """ if not self.is_at_home(): title = 'Terminal status message' if is_terminal else 'Status message' @@ -1718,21 +1727,24 @@ async def set_status_message( return None # If is_at_home() is True, config.actor_run_id is always set - if not self.configuration.actor_run_id: + run_id = self.configuration.actor_run_id + if not run_id: raise RuntimeError('actor_run_id cannot be None when running on the Apify platform.') - run_client = self.apify_client.run(self.configuration.actor_run_id) - run = await run_client.update( - status_message=status_message, - is_status_message_terminal=is_terminal, - ) + run_client = self.apify_client.run(run_id) + update = run_client.update(status_message=status_message, is_status_message_terminal=is_terminal) - if run is None: - raise RuntimeError( - f'Failed to set status message for Actor run with ID "{self.configuration.actor_run_id}".' + try: + return await asyncio.wait_for(update, STATUS_MESSAGE_UPDATE_TIMEOUT.total_seconds()) + except TimeoutError: + self.log.warning( + f'Failed to set status message for Actor run with ID "{run_id}": ' + f'the update timed out after {STATUS_MESSAGE_UPDATE_TIMEOUT.total_seconds():g}s.' ) + except Exception as exc: + self.log.warning(f'Failed to set status message for Actor run with ID "{run_id}": {exc}') - return run + return None @_ensure_context async def create_proxy_configuration( diff --git a/src/apify/_consts.py b/src/apify/_consts.py index 7251a2a24..e7c39deae 100644 --- a/src/apify/_consts.py +++ b/src/apify/_consts.py @@ -7,6 +7,9 @@ EVENT_LISTENERS_TIMEOUT = timedelta(seconds=5) """Timeout for waiting on event listeners to finish during Actor exit.""" +STATUS_MESSAGE_UPDATE_TIMEOUT = timedelta(seconds=1) +"""How long `Actor.set_status_message()` waits for the run update before giving up with a warning.""" + BASE64_REGEXP = '[-A-Za-z0-9+/]*={0,3}' """Regex fragment matching a single base64-encoded component.""" diff --git a/tests/unit/actor/test_actor_helpers.py b/tests/unit/actor/test_actor_helpers.py index 5ac9777a3..e8b0d8f95 100644 --- a/tests/unit/actor/test_actor_helpers.py +++ b/tests/unit/actor/test_actor_helpers.py @@ -357,6 +357,74 @@ async def test_set_status_message_locally(caplog: pytest.LogCaptureFixture) -> N assert '[Status message]: test-status-message' in matching_records[0].message +async def test_set_status_message_returns_updated_run( + apify_client_async_patcher: ApifyClientAsyncPatcher, + fake_actor_run: Run, +) -> None: + """`Actor.set_status_message` on the platform updates the run and returns it.""" + apify_client_async_patcher.patch('run', 'update', return_value=fake_actor_run) + + async with Actor: + Actor.configuration.is_at_home = True + Actor.configuration.actor_run_id = 'some-run-id' + run = await Actor.set_status_message('almost there', is_terminal=True) + + assert run is fake_actor_run + update_kwargs = apify_client_async_patcher.calls['run']['update'][0][1] + assert update_kwargs == {'status_message': 'almost there', 'is_status_message_terminal': True} + + +async def test_set_status_message_warns_when_update_fails( + apify_client_async_patcher: ApifyClientAsyncPatcher, + caplog: pytest.LogCaptureFixture, +) -> None: + """`Actor.set_status_message` logs a warning and returns `None` when the run update raises.""" + + def failing_update(*_args: object, **_kwargs: object) -> Run: + raise RuntimeError('Boom') + + apify_client_async_patcher.patch('run', 'update', replacement_method=failing_update) + + async with Actor: + Actor.configuration.is_at_home = True + Actor.configuration.actor_run_id = 'some-run-id' + run = await Actor.set_status_message('almost there') + + assert run is None + matching_records = [record for record in caplog.records if 'Failed to set status message' in record.message] + assert len(matching_records) == 1 + assert matching_records[0].levelname == 'WARNING' + assert matching_records[0].message == 'Failed to set status message for Actor run with ID "some-run-id": Boom' + + +async def test_set_status_message_warns_when_update_times_out( + apify_client_async_patcher: ApifyClientAsyncPatcher, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`Actor.set_status_message` logs a warning and returns `None` when the run update does not finish in time.""" + + async def hanging_update(*_args: object, **_kwargs: object) -> Run: + await asyncio.Event().wait() + raise AssertionError('unreachable') + + apify_client_async_patcher.patch('run', 'update', replacement_method=hanging_update) + monkeypatch.setattr('apify._actor.STATUS_MESSAGE_UPDATE_TIMEOUT', timedelta(milliseconds=10)) + + async with Actor: + Actor.configuration.is_at_home = True + Actor.configuration.actor_run_id = 'some-run-id' + run = await Actor.set_status_message('almost there') + + assert run is None + matching_records = [record for record in caplog.records if 'Failed to set status message' in record.message] + assert len(matching_records) == 1 + assert matching_records[0].levelname == 'WARNING' + assert matching_records[0].message == ( + 'Failed to set status message for Actor run with ID "some-run-id": the update timed out after 0.01s.' + ) + + async def test_set_terminal_status_message_locally(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level('INFO') async with Actor: