diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 3c5845ee..f79ae56e 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -974,15 +974,6 @@ async def start( """ client = self.new_client(token=token) if token else self.apify_client - if timeout == 'inherit': - actor_start_timeout = self._get_remaining_time() - elif timeout is None: - actor_start_timeout = None - elif isinstance(timeout, timedelta): - actor_start_timeout = timeout - else: - raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.') - actor_client = client.actor(actor_id) return await actor_client.start( run_input=run_input, @@ -991,7 +982,7 @@ async def start( max_total_charge_usd=max_total_charge_usd, restart_on_error=restart_on_error, memory_mbytes=memory_mbytes, - run_timeout=actor_start_timeout, + run_timeout=self._resolve_run_timeout(timeout), force_permission_level=force_permission_level, webhooks=to_client_representations(webhooks), ) @@ -1033,6 +1024,55 @@ async def abort( return run + @_ensure_context + async def resurrect( + self, + run_id: str, + *, + token: str | None = None, + build: str | None = None, + memory_mbytes: int | None = None, + timeout: timedelta | Literal['inherit'] | None = None, + max_items: int | None = None, + max_total_charge_usd: Decimal | None = None, + restart_on_error: bool | None = None, + ) -> Run: + """Resurrect a finished Actor run on the Apify platform. + + Only finished runs, i.e. runs with status SUCCEEDED, FAILED, ABORTED and TIMED-OUT, can be resurrected. The run + status is set back to RUNNING and its container is restarted with the same default storages. + + Args: + run_id: The ID of the Actor run to be resurrected. + token: The Apify API token to use for this request (defaults to the `APIFY_TOKEN` environment variable). + build: Which Actor build the resurrected run should use. It can be either a build tag or build number. By + default, the resurrected run uses the same build as before. + memory_mbytes: New memory limit for the resurrected run, in megabytes. By default, the resurrected run uses + the same memory limit as before. + timeout: New timeout for the resurrected run. By default, the resurrected run uses the same timeout as + before. Using `inherit` will set timeout of the resurrected run to the time remaining from this Actor + timeout. + max_items: Maximum number of items that the resurrected pay-per-result run will return. By default, the + resurrected run uses the same limit as before. The limit can only be increased. + max_total_charge_usd: Maximum cost for the resurrected pay-per-event run in USD. By default, the resurrected + run uses the same limit as before. The limit can only be increased. + restart_on_error: If true, the resurrected run process will be restarted whenever it exits with a non-zero + status code. By default, the resurrected run uses the same setting as before. + + Returns: + Info about the resurrected Actor run. + """ + client = self.new_client(token=token) if token else self.apify_client + + return await client.run(run_id).resurrect( + build=build, + memory_mbytes=memory_mbytes, + run_timeout=self._resolve_run_timeout(timeout), + max_items=max_items, + max_total_charge_usd=max_total_charge_usd, + restart_on_error=restart_on_error, + ) + @_ensure_context async def call( self, @@ -1085,15 +1125,6 @@ async def call( """ client = self.new_client(token=token) if token else self.apify_client - if timeout == 'inherit': - actor_call_timeout = self._get_remaining_time() - elif timeout is None: - actor_call_timeout = None - elif isinstance(timeout, timedelta): - actor_call_timeout = timeout - else: - raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.') - actor_client = client.actor(actor_id) run = await actor_client.call( run_input=run_input, @@ -1102,7 +1133,7 @@ async def call( max_total_charge_usd=max_total_charge_usd, restart_on_error=restart_on_error, memory_mbytes=memory_mbytes, - run_timeout=actor_call_timeout, + run_timeout=self._resolve_run_timeout(timeout), force_permission_level=force_permission_level, webhooks=to_client_representations(webhooks), wait_duration=wait, @@ -1158,22 +1189,13 @@ async def call_task( """ client = self.new_client(token=token) if token else self.apify_client - if timeout == 'inherit': - task_call_timeout = self._get_remaining_time() - elif timeout is None: - task_call_timeout = None - elif isinstance(timeout, timedelta): - task_call_timeout = timeout - else: - raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.') - task_client = client.task(task_id) run = await task_client.call( task_input=task_input, build=build, restart_on_error=restart_on_error, memory_mbytes=memory_mbytes, - run_timeout=task_call_timeout, + run_timeout=self._resolve_run_timeout(timeout), webhooks=to_client_representations(webhooks), wait_duration=wait, ) @@ -1520,6 +1542,14 @@ async def _read_input_from_working_directory(input_key: str) -> Any: raise ValueError(f'The input file "{input_path}" is not valid JSON.') from exc return content + def _resolve_run_timeout(self, timeout: timedelta | Literal['inherit'] | None) -> timedelta | None: + """Resolve the `timeout` argument of the methods running other Actors into the run timeout for the API.""" + if timeout == 'inherit': + return self._get_remaining_time() + if timeout is None or isinstance(timeout, timedelta): + return timeout + raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.') + def _get_remaining_time(self) -> timedelta | None: """Get time remaining from the Actor timeout, rounded up to whole seconds with minimum value of 1 second. diff --git a/tests/unit/actor/test_actor_helpers.py b/tests/unit/actor/test_actor_helpers.py index 2644afa5..0b2b0bdf 100644 --- a/tests/unit/actor/test_actor_helpers.py +++ b/tests/unit/actor/test_actor_helpers.py @@ -3,6 +3,7 @@ import asyncio import logging from datetime import UTC, datetime, timedelta +from decimal import Decimal from typing import TYPE_CHECKING from unittest.mock import AsyncMock, Mock, patch @@ -128,6 +129,59 @@ async def test_abort_actor_run(apify_client_async_patcher: ApifyClientAsyncPatch assert apify_client_async_patcher.calls['run']['abort'][0][0][0].resource_id == run_id +async def test_resurrect_actor_run(apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: Run) -> None: + """`Actor.resurrect` forwards the run ID and all options to the run client's `resurrect`.""" + apify_client_async_patcher.patch('run', 'resurrect', return_value=fake_actor_run) + run_id = 'some-run-id' + + async with Actor: + run = await Actor.resurrect( + run_id, + build='beta', + memory_mbytes=1024, + timeout=timedelta(seconds=120), + max_items=100, + max_total_charge_usd=Decimal('2.5'), + restart_on_error=True, + ) + + assert run is fake_actor_run + calls = apify_client_async_patcher.calls['run']['resurrect'] + assert len(calls) == 1 + assert calls[0][0][0].resource_id == run_id + assert calls[0][1] == { + 'build': 'beta', + 'memory_mbytes': 1024, + 'run_timeout': timedelta(seconds=120), + 'max_items': 100, + 'max_total_charge_usd': Decimal('2.5'), + 'restart_on_error': True, + } + + +async def test_resurrect_with_inherited_timeout(apify_client_async_patcher: ApifyClientAsyncPatcher) -> None: + """`Actor.resurrect` with `timeout='inherit'` passes the remaining Actor time, clamped to 1 second when overdue.""" + apify_client_async_patcher.patch('run', 'resurrect', return_value=Mock()) + + async with Actor: + Actor.configuration.is_at_home = True + Actor.configuration.timeout_at = datetime.now(tz=UTC) - timedelta(minutes=5) + await Actor.resurrect('some-run-id', timeout='inherit') + + assert apify_client_async_patcher.calls['run']['resurrect'][0][1]['run_timeout'] == timedelta(seconds=1) + + +async def test_resurrect_with_invalid_timeout(apify_client_async_patcher: ApifyClientAsyncPatcher) -> None: + """`Actor.resurrect` raises `ValueError` for an invalid timeout without calling the API.""" + apify_client_async_patcher.patch('run', 'resurrect', return_value=Mock()) + + async with Actor: + with pytest.raises(ValueError, match='Invalid timeout'): + await Actor.resurrect('some-run-id', timeout='invalid') # ty: ignore[invalid-argument-type] + + assert 'resurrect' not in apify_client_async_patcher.calls['run'] + + # NOTE: The following methods are properly tested using integrations tests.