Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/04_upgrading/upgrading_to_v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 24 additions & 12 deletions src/apify/_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1705,34 +1711,40 @@ 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'
self.log.info(f'[{title}]: {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(
Expand Down
3 changes: 3 additions & 0 deletions src/apify/_consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
68 changes: 68 additions & 0 deletions tests/unit/actor/test_actor_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading