Skip to content

Commit ed13597

Browse files
vdusekclaude
andauthored
fix: Handle exceptions in pre-reboot event listeners via return_exceptions (#843)
- Add `return_exceptions=True` to `asyncio.gather` in `Actor.reboot()` so a single failing listener no longer prevents others from persisting state - Log any listener exceptions via `self.log.exception` --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4d56f7d commit ed13597

File tree

2 files changed

+61
-1
lines changed

2 files changed

+61
-1
lines changed

src/apify/_actor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,11 +1204,16 @@ async def reboot(
12041204
(self.event_manager._listeners_to_wrappers[Event.MIGRATING] or {}).values() # noqa: SLF001
12051205
)
12061206

1207-
await asyncio.gather(
1207+
results = await asyncio.gather(
12081208
*[listener(EventPersistStateData(is_migrating=True)) for listener in persist_state_listeners],
12091209
*[listener(EventMigratingData()) for listener in migrating_listeners],
1210+
return_exceptions=True,
12101211
)
12111212

1213+
for result in results:
1214+
if isinstance(result, Exception):
1215+
self.log.exception('A pre-reboot event listener failed', exc_info=result)
1216+
12121217
if not self.configuration.actor_run_id:
12131218
raise RuntimeError('actor_run_id cannot be None when running on the Apify platform.')
12141219

tests/unit/actor/test_actor_helpers.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import logging
45
import warnings
56
from datetime import datetime, timedelta, timezone
67
from typing import TYPE_CHECKING
@@ -344,3 +345,57 @@ async def test_get_remaining_time_returns_positive_when_timeout_in_future() -> N
344345
assert result is not None
345346
assert result > timedelta(0)
346347
assert result <= timedelta(minutes=5)
348+
349+
350+
async def test_reboot_runs_all_listeners_even_when_one_fails(
351+
apify_client_async_patcher: ApifyClientAsyncPatcher,
352+
caplog: pytest.LogCaptureFixture,
353+
) -> None:
354+
"""Test that a failing pre-reboot event listener does not prevent other listeners from running.
355+
356+
Directly injects raw async callables into the event manager's _listeners_to_wrappers
357+
to simulate exceptions escaping the wrapper layer (the scenario return_exceptions=True guards against).
358+
"""
359+
apify_client_async_patcher.patch('run', 'reboot', return_value=None)
360+
361+
persist_state_called = False
362+
migrating_called = False
363+
364+
async def failing_listener(*_args: object) -> None:
365+
raise RuntimeError('persist_state listener error')
366+
367+
async def successful_persist_state_listener(*_args: object) -> None:
368+
nonlocal persist_state_called
369+
persist_state_called = True
370+
371+
async def successful_migrating_listener(*_args: object) -> None:
372+
nonlocal migrating_called
373+
migrating_called = True
374+
375+
async with Actor:
376+
Actor.configuration.is_at_home = True
377+
Actor.configuration.actor_run_id = 'some-run-id'
378+
379+
# Inject raw listeners directly into the event manager's internal structure,
380+
# bypassing crawlee's wrapper that would catch exceptions on its own.
381+
listeners_map = Actor.event_manager._listeners_to_wrappers
382+
listeners_map[Event.PERSIST_STATE] = {
383+
failing_listener: [failing_listener],
384+
successful_persist_state_listener: [successful_persist_state_listener],
385+
}
386+
listeners_map[Event.MIGRATING] = {
387+
successful_migrating_listener: [successful_migrating_listener],
388+
}
389+
390+
with caplog.at_level(logging.ERROR):
391+
await Actor.reboot(custom_after_sleep=timedelta(milliseconds=1))
392+
393+
# All listeners ran despite the failure in one of them.
394+
assert persist_state_called
395+
assert migrating_called
396+
397+
# The exception was logged.
398+
assert any('A pre-reboot event listener failed' in r.message for r in caplog.records)
399+
400+
# The reboot API call was still made.
401+
assert len(apify_client_async_patcher.calls['run']['reboot']) == 1

0 commit comments

Comments
 (0)