Skip to content
Merged
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
90 changes: 60 additions & 30 deletions src/apify/_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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.

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

Expand Down Expand Up @@ -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.


Expand Down
Loading