From a992dcb748a906b88e97824060d14ffedec8c3e0 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 15 May 2025 13:40:34 +0200 Subject: [PATCH 1/6] draft --- src/apify/_actor.py | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 73e1be7c..04bc4e70 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -693,7 +693,7 @@ async def start( content_type: str | None = None, build: str | None = None, memory_mbytes: int | None = None, - timeout: timedelta | None = None, + timeout: timedelta | None | Literal['RemainingTime'] = None, wait_for_finish: int | None = None, webhooks: list[Webhook] | None = None, ) -> ActorRun: @@ -711,7 +711,8 @@ async def start( memory_mbytes: Memory limit for the run, in megabytes. By default, the run uses a memory limit specified in the default run configuration for the Actor. timeout: Optional timeout for the run, in seconds. By default, the run uses timeout specified in - the default run configuration for the Actor. + the default run configuration for the Actor. Using `RemainingTime` will set timeout of the other actor + to the time remaining from this actor timeout. wait_for_finish: The maximum number of seconds the server waits for the run to finish. By default, it is 0, the maximum value is 300. webhooks: Optional ad-hoc webhooks (https://docs.apify.com/webhooks/ad-hoc-webhooks) associated with @@ -732,18 +733,37 @@ async def start( else: serialized_webhooks = None + if timeout == 'RemainingTime': + actor_start_timeout = await self._get_remaining_time(client) + elif isinstance(timeout, str): + raise ValueError(f'`timeout` can be `None`, `RemainingTime` or `timedelta` instance, but is {timeout=}') + else: + actor_start_timeout = timeout + api_result = await client.actor(actor_id).start( run_input=run_input, content_type=content_type, build=build, memory_mbytes=memory_mbytes, - timeout_secs=int(timeout.total_seconds()) if timeout is not None else None, + timeout_secs=int(actor_start_timeout.total_seconds()) if actor_start_timeout is not None else None, wait_for_finish=wait_for_finish, webhooks=serialized_webhooks, ) return ActorRun.model_validate(api_result) + async def _get_remaining_time(self, client: ApifyClientAsync) -> timedelta | None: + """Get time remaining from the actor timeout. Returns `None` if not on Apify platform.""" + if self.is_at_home() and self.configuration.actor_run_id: + run_data = await client.run(self.configuration.actor_run_id).get() + if run_data is not None and (timeout := run_data.get('options', {}).get('timeoutSecs', None)): + runtime = timedelta(seconds=run_data.get('runTimeSecs', None)) + remaining_time = timeout - runtime + return timedelta(seconds=remaining_time) + + self.log.warning('Using `RemainingTime` argument for timeout outside of the Apify platform. Returning `None`') + return None + async def abort( self, run_id: str, @@ -787,7 +807,7 @@ async def call( content_type: str | None = None, build: str | None = None, memory_mbytes: int | None = None, - timeout: timedelta | None = None, + timeout: timedelta | None | Literal['RemainingTime'] = None, webhooks: list[Webhook] | None = None, wait: timedelta | None = None, ) -> ActorRun | None: @@ -805,7 +825,8 @@ async def call( memory_mbytes: Memory limit for the run, in megabytes. By default, the run uses a memory limit specified in the default run configuration for the Actor. timeout: Optional timeout for the run, in seconds. By default, the run uses timeout specified in - the default run configuration for the Actor. + the default run configuration for the Actor. Using `RemainingTime` will set timeout of the other actor + to the time remaining from this actor timeout. webhooks: Optional webhooks (https://docs.apify.com/webhooks) associated with the Actor run, which can be used to receive a notification, e.g. when the Actor finished or failed. If you already have a webhook set up for the Actor, you do not have to add it again here. @@ -826,12 +847,19 @@ async def call( else: serialized_webhooks = None + if timeout == 'RemainingTime': + actor_call_timeout = await self._get_remaining_time(client) + elif isinstance(timeout, str): + raise ValueError(f'`timeout` can be `None`, `RemainingTime` or `timedelta` instance, but is {timeout=}') + else: + actor_call_timeout = timeout + api_result = await client.actor(actor_id).call( run_input=run_input, content_type=content_type, build=build, memory_mbytes=memory_mbytes, - timeout_secs=int(timeout.total_seconds()) if timeout is not None else None, + timeout_secs=int(actor_call_timeout.total_seconds()) if actor_call_timeout is not None else None, webhooks=serialized_webhooks, wait_secs=int(wait.total_seconds()) if wait is not None else None, ) From b83291927ca89f082fe1e19b9160220203e1f7dd Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 15 May 2025 17:18:32 +0200 Subject: [PATCH 2/6] Draft of actor call with RemainingTime timeout --- tests/integration/conftest.py | 8 +++--- tests/integration/test_actor_call.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 tests/integration/test_actor_call.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1cd800f1..1667986d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -30,7 +30,7 @@ _TOKEN_ENV_VAR = 'APIFY_TEST_USER_API_TOKEN' _API_URL_ENV_VAR = 'APIFY_INTEGRATION_TESTS_API_URL' _SDK_ROOT_PATH = Path(__file__).parent.parent.parent.resolve() - +_DEFAULT_TEST_TIMEOUT = 600 @pytest.fixture def prepare_test_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Callable[[], None]: @@ -315,7 +315,7 @@ async def _make_actor( name=actor_name, default_run_build='latest', default_run_memory_mbytes=256, - default_run_timeout_secs=600, + default_run_timeout_secs=_DEFAULT_TEST_TIMEOUT, versions=[ { 'versionNumber': '0.0', @@ -331,7 +331,7 @@ async def _make_actor( print(f'Building Actor {actor_name}...') build_result = await actor_client.build(version_number='0.0') build_client = client.build(build_result['id']) - build_client_result = await build_client.wait_for_finish(wait_secs=600) + build_client_result = await build_client.wait_for_finish(wait_secs=_DEFAULT_TEST_TIMEOUT) assert build_client_result is not None assert build_client_result['status'] == ActorJobStatus.SUCCEEDED @@ -408,7 +408,7 @@ async def _run_actor( client = ApifyClientAsync(token=apify_token, api_url=os.getenv(_API_URL_ENV_VAR)) run_client = client.run(call_result['id']) - run_result = await run_client.wait_for_finish(wait_secs=600) + run_result = await run_client.wait_for_finish(wait_secs=_DEFAULT_TEST_TIMEOUT) return ActorRun.model_validate(run_result) diff --git a/tests/integration/test_actor_call.py b/tests/integration/test_actor_call.py new file mode 100644 index 00000000..5eeb4661 --- /dev/null +++ b/tests/integration/test_actor_call.py @@ -0,0 +1,42 @@ +import asyncio + +import pytest + +from apify import Actor +from tests.integration.conftest import MakeActorFunction, RunActorFunction + + +async def test_actor_start_remaining_timeout( + make_actor: MakeActorFunction, + run_actor: RunActorFunction, +) -> None: + async def main() -> None: + async with Actor: + actor_input = (await Actor.get_input()) or {} + if actor_input.get("called_from_another_actor",False) is True: + # Do nothing and only wait for + await asyncio.sleep(1000) + return + + + self_run_client = Actor.apify_client.run(Actor.configuration.actor_run_id) + self_run_data_1 = await self_run_client.get() + run_time_1 = self_run_data_1.get('runTimeSecs', 0) + self_timeout = self_run_data_1.get('options', {}).get('timeoutSecs', 0) + + # Start another run of this actor with timeout set to the time remaining in this actor + other_run_data = await Actor.start(actor_id=Actor.configuration.actor_id, + run_input={"called_from_another_actor": True}, + timeout="RemainingTime") + + self_run_data_2 = await self_run_client.get() + run_time_2 = self_run_data_2.get('runTimeSecs', 0) + other_actor_timeout = other_run_data.get('options', {}).get('timeoutSecs', 0) + + assert other_actor_timeout > self_timeout - run_time_1 + assert other_actor_timeout < self_timeout - run_time_2 + + actor = await make_actor(label='remaining timeout', main_func=main) + run_result = await run_actor(actor) + + assert run_result.status == 'SUCCEEDED' From 74de32440c8b397f27bebf0376856867120b9d29 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Fri, 16 May 2025 13:36:14 +0200 Subject: [PATCH 3/6] Draft --- src/apify/_actor.py | 26 ++--- tests/integration/conftest.py | 8 +- tests/integration/test_actor_call.py | 42 -------- tests/integration/test_actor_call_timeouts.py | 95 +++++++++++++++++++ 4 files changed, 112 insertions(+), 59 deletions(-) delete mode 100644 tests/integration/test_actor_call.py create mode 100644 tests/integration/test_actor_call_timeouts.py diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 04bc4e70..40815f2d 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -4,7 +4,7 @@ import os import sys from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, cast, overload from lazy_object_proxy import Proxy @@ -734,9 +734,11 @@ async def start( serialized_webhooks = None if timeout == 'RemainingTime': - actor_start_timeout = await self._get_remaining_time(client) + actor_start_timeout = await self._get_remaining_time() elif isinstance(timeout, str): - raise ValueError(f'`timeout` can be `None`, `RemainingTime` or `timedelta` instance, but is {timeout=}') + raise ValueError( + f'`timeout` can be `None`, `RemainingTime` literal or `timedelta` instance, but is {timeout=}' + ) else: actor_start_timeout = timeout @@ -752,14 +754,10 @@ async def start( return ActorRun.model_validate(api_result) - async def _get_remaining_time(self, client: ApifyClientAsync) -> timedelta | None: - """Get time remaining from the actor timeout. Returns `None` if not on Apify platform.""" - if self.is_at_home() and self.configuration.actor_run_id: - run_data = await client.run(self.configuration.actor_run_id).get() - if run_data is not None and (timeout := run_data.get('options', {}).get('timeoutSecs', None)): - runtime = timedelta(seconds=run_data.get('runTimeSecs', None)) - remaining_time = timeout - runtime - return timedelta(seconds=remaining_time) + async def _get_remaining_time(self) -> timedelta | None: + """Get time remaining from the actor timeout. Returns `None` if not on an Apify platform.""" + if self.is_at_home() and self.configuration.timeout_at: + return self.configuration.timeout_at - datetime.now(tz=timezone.utc) self.log.warning('Using `RemainingTime` argument for timeout outside of the Apify platform. Returning `None`') return None @@ -848,9 +846,11 @@ async def call( serialized_webhooks = None if timeout == 'RemainingTime': - actor_call_timeout = await self._get_remaining_time(client) + actor_call_timeout = await self._get_remaining_time() elif isinstance(timeout, str): - raise ValueError(f'`timeout` can be `None`, `RemainingTime` or `timedelta` instance, but is {timeout=}') + raise ValueError( + f'`timeout` can be `None`, `RemainingTime` literal or `timedelta` instance, but is {timeout=}' + ) else: actor_call_timeout = timeout diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1667986d..1cd800f1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -30,7 +30,7 @@ _TOKEN_ENV_VAR = 'APIFY_TEST_USER_API_TOKEN' _API_URL_ENV_VAR = 'APIFY_INTEGRATION_TESTS_API_URL' _SDK_ROOT_PATH = Path(__file__).parent.parent.parent.resolve() -_DEFAULT_TEST_TIMEOUT = 600 + @pytest.fixture def prepare_test_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Callable[[], None]: @@ -315,7 +315,7 @@ async def _make_actor( name=actor_name, default_run_build='latest', default_run_memory_mbytes=256, - default_run_timeout_secs=_DEFAULT_TEST_TIMEOUT, + default_run_timeout_secs=600, versions=[ { 'versionNumber': '0.0', @@ -331,7 +331,7 @@ async def _make_actor( print(f'Building Actor {actor_name}...') build_result = await actor_client.build(version_number='0.0') build_client = client.build(build_result['id']) - build_client_result = await build_client.wait_for_finish(wait_secs=_DEFAULT_TEST_TIMEOUT) + build_client_result = await build_client.wait_for_finish(wait_secs=600) assert build_client_result is not None assert build_client_result['status'] == ActorJobStatus.SUCCEEDED @@ -408,7 +408,7 @@ async def _run_actor( client = ApifyClientAsync(token=apify_token, api_url=os.getenv(_API_URL_ENV_VAR)) run_client = client.run(call_result['id']) - run_result = await run_client.wait_for_finish(wait_secs=_DEFAULT_TEST_TIMEOUT) + run_result = await run_client.wait_for_finish(wait_secs=600) return ActorRun.model_validate(run_result) diff --git a/tests/integration/test_actor_call.py b/tests/integration/test_actor_call.py deleted file mode 100644 index 5eeb4661..00000000 --- a/tests/integration/test_actor_call.py +++ /dev/null @@ -1,42 +0,0 @@ -import asyncio - -import pytest - -from apify import Actor -from tests.integration.conftest import MakeActorFunction, RunActorFunction - - -async def test_actor_start_remaining_timeout( - make_actor: MakeActorFunction, - run_actor: RunActorFunction, -) -> None: - async def main() -> None: - async with Actor: - actor_input = (await Actor.get_input()) or {} - if actor_input.get("called_from_another_actor",False) is True: - # Do nothing and only wait for - await asyncio.sleep(1000) - return - - - self_run_client = Actor.apify_client.run(Actor.configuration.actor_run_id) - self_run_data_1 = await self_run_client.get() - run_time_1 = self_run_data_1.get('runTimeSecs', 0) - self_timeout = self_run_data_1.get('options', {}).get('timeoutSecs', 0) - - # Start another run of this actor with timeout set to the time remaining in this actor - other_run_data = await Actor.start(actor_id=Actor.configuration.actor_id, - run_input={"called_from_another_actor": True}, - timeout="RemainingTime") - - self_run_data_2 = await self_run_client.get() - run_time_2 = self_run_data_2.get('runTimeSecs', 0) - other_actor_timeout = other_run_data.get('options', {}).get('timeoutSecs', 0) - - assert other_actor_timeout > self_timeout - run_time_1 - assert other_actor_timeout < self_timeout - run_time_2 - - actor = await make_actor(label='remaining timeout', main_func=main) - run_result = await run_actor(actor) - - assert run_result.status == 'SUCCEEDED' diff --git a/tests/integration/test_actor_call_timeouts.py b/tests/integration/test_actor_call_timeouts.py new file mode 100644 index 00000000..b0d064b3 --- /dev/null +++ b/tests/integration/test_actor_call_timeouts.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from apify import Actor + +if TYPE_CHECKING: + from .conftest import MakeActorFunction, RunActorFunction + + +async def test_actor_start_remaining_timeout( + make_actor: MakeActorFunction, + run_actor: RunActorFunction, +) -> None: + async def main() -> None: + from datetime import datetime, timezone + + async with Actor: + actor_input = (await Actor.get_input()) or {} + if actor_input.get('called_from_another_actor', False) is True: + return + + await asyncio.sleep(1) + # Start another run of this actor with timeout set to the time remaining in this actor run + other_run_data = await Actor.start( + actor_id=Actor.configuration.actor_id or '', + run_input={'called_from_another_actor': True}, + timeout='RemainingTime', + ) + + # To make sure that the actor is started + await asyncio.sleep(5) + + assert Actor.configuration.timeout_at is not None + assert Actor.configuration.started_at is not None + assert other_run_data is not None + assert other_run_data.options is not None + + remaining_time_after_actor_start = Actor.configuration.timeout_at - datetime.now(tz=timezone.utc) + + try: + assert other_run_data.options.timeout > remaining_time_after_actor_start + assert other_run_data.options.timeout < Actor.configuration.timeout_at - Actor.configuration.started_at + finally: + # Abort the other actor run after asserting the timeouts + await Actor.apify_client.run(other_run_data.id).abort() + + actor = await make_actor(label='remaining-timeout', main_func=main) + run_result = await run_actor(actor) + + assert run_result.status == 'SUCCEEDED' + + +async def test_actor_call_remaining_timeout( + make_actor: MakeActorFunction, + run_actor: RunActorFunction, +) -> None: + async def main() -> None: + from datetime import datetime, timezone + + async with Actor: + actor_input = (await Actor.get_input()) or {} + if actor_input.get('called_from_another_actor', False) is True: + return + + await asyncio.sleep(1) + # Start another run of this actor with timeout set to the time remaining in this actor run + other_run_data = await Actor.call( + actor_id=Actor.configuration.actor_id or '', + run_input={'called_from_another_actor': True}, + timeout='RemainingTime', + ) + + # To make sure that the actor is started + await asyncio.sleep(5) + + assert Actor.configuration.timeout_at is not None + assert Actor.configuration.started_at is not None + assert other_run_data is not None + assert other_run_data.options is not None + + remaining_time_after_actor_start = Actor.configuration.timeout_at - datetime.now(tz=timezone.utc) + + try: + assert other_run_data.options.timeout > remaining_time_after_actor_start + assert other_run_data.options.timeout < Actor.configuration.timeout_at - Actor.configuration.started_at + finally: + # Abort the other actor run after asserting the timeouts + await Actor.apify_client.run(other_run_data.id).abort() + + actor = await make_actor(label='remaining-timeout', main_func=main) + run_result = await run_actor(actor) + + assert run_result.status == 'SUCCEEDED' From b0cc5a7d9c375f0ff7575231fc760e0637edfc86 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Mon, 19 May 2025 08:13:11 +0200 Subject: [PATCH 4/6] Update tests --- src/apify/_actor.py | 6 +-- tests/integration/test_actor_call_timeouts.py | 40 +++++++++---------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 40815f2d..036d143a 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -734,7 +734,7 @@ async def start( serialized_webhooks = None if timeout == 'RemainingTime': - actor_start_timeout = await self._get_remaining_time() + actor_start_timeout = self._get_remaining_time() elif isinstance(timeout, str): raise ValueError( f'`timeout` can be `None`, `RemainingTime` literal or `timedelta` instance, but is {timeout=}' @@ -754,7 +754,7 @@ async def start( return ActorRun.model_validate(api_result) - async def _get_remaining_time(self) -> timedelta | None: + def _get_remaining_time(self) -> timedelta | None: """Get time remaining from the actor timeout. Returns `None` if not on an Apify platform.""" if self.is_at_home() and self.configuration.timeout_at: return self.configuration.timeout_at - datetime.now(tz=timezone.utc) @@ -846,7 +846,7 @@ async def call( serialized_webhooks = None if timeout == 'RemainingTime': - actor_call_timeout = await self._get_remaining_time() + actor_call_timeout = self._get_remaining_time() elif isinstance(timeout, str): raise ValueError( f'`timeout` can be `None`, `RemainingTime` literal or `timedelta` instance, but is {timeout=}' diff --git a/tests/integration/test_actor_call_timeouts.py b/tests/integration/test_actor_call_timeouts.py index b0d064b3..8c131686 100644 --- a/tests/integration/test_actor_call_timeouts.py +++ b/tests/integration/test_actor_call_timeouts.py @@ -21,29 +21,26 @@ async def main() -> None: if actor_input.get('called_from_another_actor', False) is True: return - await asyncio.sleep(1) # Start another run of this actor with timeout set to the time remaining in this actor run - other_run_data = await Actor.start( + other_run_data = await Actor.call( actor_id=Actor.configuration.actor_id or '', run_input={'called_from_another_actor': True}, timeout='RemainingTime', ) - - # To make sure that the actor is started - await asyncio.sleep(5) - - assert Actor.configuration.timeout_at is not None - assert Actor.configuration.started_at is not None assert other_run_data is not None - assert other_run_data.options is not None + try: + # To make sure that the actor is started + await asyncio.sleep(5) + assert other_run_data.options is not None + assert Actor.configuration.timeout_at is not None + assert Actor.configuration.started_at is not None - remaining_time_after_actor_start = Actor.configuration.timeout_at - datetime.now(tz=timezone.utc) + remaining_time_after_actor_start = Actor.configuration.timeout_at - datetime.now(tz=timezone.utc) - try: assert other_run_data.options.timeout > remaining_time_after_actor_start assert other_run_data.options.timeout < Actor.configuration.timeout_at - Actor.configuration.started_at finally: - # Abort the other actor run after asserting the timeouts + # Make sure the other actor run is aborted await Actor.apify_client.run(other_run_data.id).abort() actor = await make_actor(label='remaining-timeout', main_func=main) @@ -65,6 +62,7 @@ async def main() -> None: return await asyncio.sleep(1) + # Start another run of this actor with timeout set to the time remaining in this actor run other_run_data = await Actor.call( actor_id=Actor.configuration.actor_id or '', @@ -72,21 +70,21 @@ async def main() -> None: timeout='RemainingTime', ) - # To make sure that the actor is started - await asyncio.sleep(5) - - assert Actor.configuration.timeout_at is not None - assert Actor.configuration.started_at is not None assert other_run_data is not None - assert other_run_data.options is not None + try: + # To make sure that the actor is started + await asyncio.sleep(5) - remaining_time_after_actor_start = Actor.configuration.timeout_at - datetime.now(tz=timezone.utc) + assert other_run_data.options is not None + assert Actor.configuration.timeout_at is not None + assert Actor.configuration.started_at is not None + + remaining_time_after_actor_start = Actor.configuration.timeout_at - datetime.now(tz=timezone.utc) - try: assert other_run_data.options.timeout > remaining_time_after_actor_start assert other_run_data.options.timeout < Actor.configuration.timeout_at - Actor.configuration.started_at finally: - # Abort the other actor run after asserting the timeouts + # Make sure the other actor run is aborted await Actor.apify_client.run(other_run_data.id).abort() actor = await make_actor(label='remaining-timeout', main_func=main) From 7202c17956229f39e3bc8ba598bf8db4a1a137f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Tue, 20 May 2025 09:44:55 +0200 Subject: [PATCH 5/6] Update src/apify/_actor.py Co-authored-by: Vlada Dusek --- src/apify/_actor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 036d143a..614ec374 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -735,12 +735,14 @@ async def start( if timeout == 'RemainingTime': actor_start_timeout = self._get_remaining_time() - elif isinstance(timeout, str): + elif timeout is None: + actor_start_timeout = None + elif isinstance(timeout, timedelta): + actor_start_timeout = timeout + else: raise ValueError( - f'`timeout` can be `None`, `RemainingTime` literal or `timedelta` instance, but is {timeout=}' + f'Invalid timeout {timeout!r}: expected `None`, `"RemainingTime"`, or a `timedelta`.' ) - else: - actor_start_timeout = timeout api_result = await client.actor(actor_id).start( run_input=run_input, From 05eed5fb3fc59ca53d2878d4da0de385451ca66b Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Tue, 20 May 2025 10:03:53 +0200 Subject: [PATCH 6/6] Review comments --- src/apify/_actor.py | 28 ++++++++++--------- tests/integration/test_actor_call_timeouts.py | 16 +++++++++-- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 614ec374..b8fcdc05 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -711,8 +711,8 @@ async def start( memory_mbytes: Memory limit for the run, in megabytes. By default, the run uses a memory limit specified in the default run configuration for the Actor. timeout: Optional timeout for the run, in seconds. By default, the run uses timeout specified in - the default run configuration for the Actor. Using `RemainingTime` will set timeout of the other actor - to the time remaining from this actor timeout. + the default run configuration for the Actor. Using `RemainingTime` will set timeout of the other Actor + to the time remaining from this Actor timeout. wait_for_finish: The maximum number of seconds the server waits for the run to finish. By default, it is 0, the maximum value is 300. webhooks: Optional ad-hoc webhooks (https://docs.apify.com/webhooks/ad-hoc-webhooks) associated with @@ -740,9 +740,7 @@ async def start( elif isinstance(timeout, timedelta): actor_start_timeout = timeout else: - raise ValueError( - f'Invalid timeout {timeout!r}: expected `None`, `"RemainingTime"`, or a `timedelta`.' - ) + raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"RemainingTime"`, or a `timedelta`.') api_result = await client.actor(actor_id).start( run_input=run_input, @@ -761,7 +759,11 @@ def _get_remaining_time(self) -> timedelta | None: if self.is_at_home() and self.configuration.timeout_at: return self.configuration.timeout_at - datetime.now(tz=timezone.utc) - self.log.warning('Using `RemainingTime` argument for timeout outside of the Apify platform. Returning `None`') + self.log.warning( + 'Returning `None` instead of remaining time. Using `RemainingTime` argument is only possible when the Actor' + ' is running on the Apify platform and when the timeout for the Actor run is set. ' + f'{self.is_at_home()=}, {self.configuration.timeout_at=}' + ) return None async def abort( @@ -825,8 +827,8 @@ async def call( memory_mbytes: Memory limit for the run, in megabytes. By default, the run uses a memory limit specified in the default run configuration for the Actor. timeout: Optional timeout for the run, in seconds. By default, the run uses timeout specified in - the default run configuration for the Actor. Using `RemainingTime` will set timeout of the other actor - to the time remaining from this actor timeout. + the default run configuration for the Actor. Using `RemainingTime` will set timeout of the other Actor + to the time remaining from this Actor timeout. webhooks: Optional webhooks (https://docs.apify.com/webhooks) associated with the Actor run, which can be used to receive a notification, e.g. when the Actor finished or failed. If you already have a webhook set up for the Actor, you do not have to add it again here. @@ -849,12 +851,12 @@ async def call( if timeout == 'RemainingTime': actor_call_timeout = self._get_remaining_time() - elif isinstance(timeout, str): - raise ValueError( - f'`timeout` can be `None`, `RemainingTime` literal or `timedelta` instance, but is {timeout=}' - ) - else: + 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`, `"RemainingTime"`, or a `timedelta`.') api_result = await client.actor(actor_id).call( run_input=run_input, diff --git a/tests/integration/test_actor_call_timeouts.py b/tests/integration/test_actor_call_timeouts.py index 8c131686..c60ca93a 100644 --- a/tests/integration/test_actor_call_timeouts.py +++ b/tests/integration/test_actor_call_timeouts.py @@ -13,12 +13,19 @@ async def test_actor_start_remaining_timeout( make_actor: MakeActorFunction, run_actor: RunActorFunction, ) -> None: + """Test that correct timeout is set when using `RemainingTime` value for the `timeout` argument. + + In this test, one Actor starts itself again and checks that the timeout is correctly set on the second Actor run. + Timeout should be the remaining time of the first Actor run calculated at the moment of the other Actor start.""" + async def main() -> None: from datetime import datetime, timezone async with Actor: actor_input = (await Actor.get_input()) or {} if actor_input.get('called_from_another_actor', False) is True: + # If this Actor run was started with a specific argument (the second Actor run), return immediately. + # Asserts checking the timeout are in the first Actor run. return # Start another run of this actor with timeout set to the time remaining in this actor run @@ -53,16 +60,21 @@ async def test_actor_call_remaining_timeout( make_actor: MakeActorFunction, run_actor: RunActorFunction, ) -> None: + """Test that correct timeout is set when using `RemainingTime` value for the `timeout` argument. + + In this test, one Actor starts itself again and checks that the timeout is correctly set on the second Actor run. + Timeout should be the remaining time of the first Actor run calculated at the moment of the other Actor call.""" + async def main() -> None: from datetime import datetime, timezone async with Actor: actor_input = (await Actor.get_input()) or {} if actor_input.get('called_from_another_actor', False) is True: + # If this Actor run was started with a specific argument (the second Actor run), return immediately. + # Asserts checking the timeout are in the first Actor run. return - await asyncio.sleep(1) - # Start another run of this actor with timeout set to the time remaining in this actor run other_run_data = await Actor.call( actor_id=Actor.configuration.actor_id or '',