From 853e2c09e135603beaf5ee29bc86f1c378922521 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Wed, 21 Oct 2026 12:06:47 +0300 Subject: [PATCH 1/4] 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: From 2d214bedffc4e3960b83800f2beeee0785eed852 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 16:41:44 +0100 Subject: [PATCH 2/4] fix: drop the extra crawler status forwarding timeout --- src/apify/_actor.py | 8 ++------ src/apify/_consts.py | 3 --- tests/unit/actor/test_actor_lifecycle.py | 17 +++++++++-------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 00551decf..c934c4c34 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -40,7 +40,6 @@ from apify._child_runs import ChildRunRegistry from apify._configuration import Configuration from apify._consts import ( - CRAWLER_STATUS_FORWARDING_TIMEOUT, EVENT_LISTENERS_TIMEOUT, EXIT_CODE_ERROR_USER_FUNCTION_THREW, STATUS_MESSAGE_UPDATE_TIMEOUT, @@ -1909,16 +1908,13 @@ async def _forward_crawler_status(self, event_data: EventCrawlerStatusData) -> N Messages that arrive once the Actor is exiting are dropped, and the terminal message waits for an update that is already in flight, so no forwarded message can overwrite the terminal one. Each update is bounded by - `CRAWLER_STATUS_FORWARDING_TIMEOUT`, so a slow or unavailable API can't hold up the crawler or the Actor exit. + `STATUS_MESSAGE_UPDATE_TIMEOUT`, so a slow or unavailable API can't hold up the crawler or the Actor exit. """ try: async with self._status_message_lock: if self._is_exiting: return - await asyncio.wait_for( - self.set_status_message(event_data.message), - timeout=CRAWLER_STATUS_FORWARDING_TIMEOUT.total_seconds(), - ) + await self.set_status_message(event_data.message) except Exception: self.log.warning('Failed to forward the crawler status message to the Actor run', exc_info=True) diff --git a/src/apify/_consts.py b/src/apify/_consts.py index a316f86fe..e7c39deae 100644 --- a/src/apify/_consts.py +++ b/src/apify/_consts.py @@ -10,9 +10,6 @@ STATUS_MESSAGE_UPDATE_TIMEOUT = timedelta(seconds=1) """How long `Actor.set_status_message()` waits for the run update before giving up with a warning.""" -CRAWLER_STATUS_FORWARDING_TIMEOUT = timedelta(seconds=1) -"""Timeout for setting a forwarded crawler status message as the run status message.""" - BASE64_REGEXP = '[-A-Za-z0-9+/]*={0,3}' """Regex fragment matching a single base64-encoded component.""" diff --git a/tests/unit/actor/test_actor_lifecycle.py b/tests/unit/actor/test_actor_lifecycle.py index 86c026027..025747c14 100644 --- a/tests/unit/actor/test_actor_lifecycle.py +++ b/tests/unit/actor/test_actor_lifecycle.py @@ -27,6 +27,8 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable + from ..conftest import ApifyClientAsyncPatcher + @pytest.fixture( params=[ @@ -529,18 +531,18 @@ async def test_failed_crawler_status_forwarding_logs_warning( async def test_slow_crawler_status_forwarding_times_out( + apify_client_async_patcher: ApifyClientAsyncPatcher, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """A forwarded crawler status update that exceeds the timeout is abandoned with a warning.""" set_up_platform_env(monkeypatch) - monkeypatch.setattr('apify._actor.CRAWLER_STATUS_FORWARDING_TIMEOUT', timedelta(milliseconds=10)) + monkeypatch.setattr('apify._actor.STATUS_MESSAGE_UPDATE_TIMEOUT', timedelta(milliseconds=10)) - async def set_status_message(_self: Any, _status_message: str, *, is_terminal: bool | None = None) -> None: - if not is_terminal: - await asyncio.sleep(60) + async def hanging_update(*_args: object, **_kwargs: object) -> None: + await asyncio.sleep(60) - monkeypatch.setattr(_ActorType, 'set_status_message', set_status_message) + apify_client_async_patcher.patch('run', 'update', replacement_method=hanging_update) async with Actor() as actor: actor.event_manager.emit( @@ -549,10 +551,9 @@ async def set_status_message(_self: Any, _status_message: str, *, is_terminal: b ) await asyncio.wait_for(actor.event_manager.wait_for_all_listeners_to_complete(), timeout=5) - records = [r for r in caplog.records if r.msg == 'Failed to forward the crawler status message to the Actor run'] + records = [r for r in caplog.records if r.msg.startswith('Failed to set status message')] assert len(records) == 1 - assert isinstance(records[0].exc_info, tuple) - assert isinstance(records[0].exc_info[1], TimeoutError) + assert records[0].levelno == logging.WARNING async def test_crawler_status_does_not_follow_terminal_status_message(monkeypatch: pytest.MonkeyPatch) -> None: From 682f102f50ff13df8589b46d5ef13c14b9b8aaf8 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 16:41:45 +0100 Subject: [PATCH 3/4] docs: rewrap the set_status_message return docstring --- src/apify/_actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index c934c4c34..e3c0d3237 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -1788,8 +1788,8 @@ async def set_status_message( is_terminal: Set this flag to True if this is the final status message of the Actor run. Returns: - The updated Actor run object, or `None` when no run was updated: when running locally, or when the - update failed or timed out. + 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' From 8814b45168df040ab6f856f48fdb6372062c5a6b Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 29 Oct 2026 16:46:06 +0100 Subject: [PATCH 4/4] docs: drop the set_status_message section from the v4 upgrading guide --- docs/04_upgrading/upgrading_to_v4.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/04_upgrading/upgrading_to_v4.md b/docs/04_upgrading/upgrading_to_v4.md index 55d8bf462..f7c774885 100644 --- a/docs/04_upgrading/upgrading_to_v4.md +++ b/docs/04_upgrading/upgrading_to_v4.md @@ -285,16 +285,6 @@ 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.