From b0a39b61d064d2278f3983674c7c8025fa81e87f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 06:30:40 +0000 Subject: [PATCH 1/3] fix: skip starting another Actor when inherited timeout has no time remaining When `timeout='inherit'` was used and the current run was already past its own timeout, the remaining time was clamped to zero and sent to the API as `timeout=0`, which the platform treats as "no timeout" - the other Actor run would get unlimited runtime instead of inheriting the (exhausted) time budget. A positive sub-second remainder had the same problem, as the API client truncates the timeout to whole seconds. The remaining time is now rounded up to whole seconds, so it is never truncated to zero, and when no time remains, `Actor.start`, `Actor.call` and `Actor.call_task` skip the API call entirely, log a warning and return `None`. Overloads keep the `Run` return type for calls that do not use `timeout='inherit'`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E5mT2U8shnNpysaq8NdtZe --- src/apify/_actor.py | 152 +++++++++++++++++++++++-- tests/unit/actor/test_actor_helpers.py | 50 ++++++++ 2 files changed, 191 insertions(+), 11 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index e1acf238..fd01bb29 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import math import sys import warnings from contextlib import suppress @@ -896,6 +897,39 @@ def get_env(self) -> dict: env_vars = {env_var.value.lower(): env_var.name.lower() for env_var in [*ActorEnvVars, *ApifyEnvVars]} return {option_name: config[env_var] for env_var, option_name in env_vars.items() if env_var in config} + @overload + async def start( + self, + actor_id: str, + run_input: Any = None, + *, + token: str | None = None, + content_type: str | None = None, + build: str | None = None, + max_total_charge_usd: Decimal | None = None, + restart_on_error: bool | None = None, + memory_mbytes: int | None = None, + timeout: timedelta | None = None, + force_permission_level: ActorPermissionLevel | None = None, + webhooks: list[Webhook] | None = None, + ) -> Run: ... + @overload + async def start( + self, + actor_id: str, + run_input: Any = None, + *, + token: str | None = None, + content_type: str | None = None, + build: str | None = None, + max_total_charge_usd: Decimal | None = None, + restart_on_error: bool | None = None, + memory_mbytes: int | None = None, + timeout: Literal['inherit'], + force_permission_level: ActorPermissionLevel | None = None, + webhooks: list[Webhook] | None = None, + ) -> Run | None: ... + @_ensure_context async def start( self, @@ -911,7 +945,7 @@ async def start( timeout: timedelta | None | Literal['inherit'] = None, force_permission_level: ActorPermissionLevel | None = None, webhooks: list[Webhook] | None = None, - ) -> Run: + ) -> Run | None: """Run an Actor on the Apify platform. Unlike `Actor.call`, this method just starts the run without waiting for finish. To wait for the run to @@ -931,7 +965,8 @@ async def start( in the default run configuration for the Actor. timeout: Optional timeout for the run. By default, the run uses timeout specified in the default run configuration for the Actor. Using `inherit` will set timeout of the other Actor - to the time remaining from this Actor timeout. + to the time remaining from this Actor timeout. If there is no time remaining, the other Actor + is not started at all and `None` is returned. force_permission_level: Override the Actor's permissions for this run. If not set, the Actor will run with permissions configured in the Actor settings. webhooks: Optional ad-hoc webhooks (https://docs.apify.com/webhooks/ad-hoc-webhooks) associated with @@ -939,12 +974,19 @@ async def start( If you already have a webhook set up for the Actor or task, you do not have to add it again here. Returns: - Info about the started Actor run + Info about the started Actor run, or `None` if the start was skipped because `timeout='inherit'` + was used and this Actor run has no time remaining before its own timeout. """ client = self.new_client(token=token) if token else self.apify_client if timeout == 'inherit': actor_start_timeout = self._get_remaining_time() + if actor_start_timeout == timedelta(0): + self.log.warning( + "Actor.start() was skipped: `timeout='inherit'` was used, but the current run " + 'has no time remaining before its own timeout.' + ) + return None elif timeout is None: actor_start_timeout = None elif isinstance(timeout, timedelta): @@ -1002,6 +1044,43 @@ async def abort( return run + @overload + async def call( + self, + actor_id: str, + run_input: Any = None, + *, + token: str | None = None, + content_type: str | None = None, + build: str | None = None, + max_total_charge_usd: Decimal | None = None, + restart_on_error: bool | None = None, + memory_mbytes: int | None = None, + timeout: timedelta | None = None, + force_permission_level: ActorPermissionLevel | None = None, + webhooks: list[Webhook] | None = None, + wait: timedelta | None = None, + logger: logging.Logger | None | Literal['default'] = 'default', + ) -> Run: ... + @overload + async def call( + self, + actor_id: str, + run_input: Any = None, + *, + token: str | None = None, + content_type: str | None = None, + build: str | None = None, + max_total_charge_usd: Decimal | None = None, + restart_on_error: bool | None = None, + memory_mbytes: int | None = None, + timeout: Literal['inherit'], + force_permission_level: ActorPermissionLevel | None = None, + webhooks: list[Webhook] | None = None, + wait: timedelta | None = None, + logger: logging.Logger | None | Literal['default'] = 'default', + ) -> Run | None: ... + @_ensure_context async def call( self, @@ -1019,7 +1098,7 @@ async def call( webhooks: list[Webhook] | None = None, wait: timedelta | None = None, logger: logging.Logger | None | Literal['default'] = 'default', - ) -> Run: + ) -> Run | None: """Start an Actor on the Apify Platform and wait for it to finish before returning. It waits indefinitely, unless the wait argument is provided. @@ -1038,7 +1117,8 @@ async def call( in the default run configuration for the Actor. timeout: Optional timeout for the run. By default, the run uses timeout specified in the default run configuration for the Actor. Using `inherit` will set timeout of the other Actor - to the time remaining from this Actor timeout. + to the time remaining from this Actor timeout. If there is no time remaining, the other Actor + is not started at all and `None` is returned. force_permission_level: Override the Actor's permissions for this run. If not set, the Actor will run with permissions configured in the Actor settings. webhooks: Optional webhooks (https://docs.apify.com/webhooks) associated with the Actor run, which can @@ -1050,12 +1130,19 @@ async def call( will redirect logs to the provided logger. Returns: - Info about the started Actor run. + Info about the started Actor run, or `None` if the call was skipped because `timeout='inherit'` + was used and this Actor run has no time remaining before its own timeout. """ client = self.new_client(token=token) if token else self.apify_client if timeout == 'inherit': actor_call_timeout = self._get_remaining_time() + if actor_call_timeout == timedelta(0): + self.log.warning( + "Actor.call() was skipped: `timeout='inherit'` was used, but the current run " + 'has no time remaining before its own timeout.' + ) + return None elif timeout is None: actor_call_timeout = None elif isinstance(timeout, timedelta): @@ -1083,6 +1170,35 @@ async def call( return run + @overload + async def call_task( + self, + task_id: str, + task_input: dict | None = None, + *, + build: str | None = None, + restart_on_error: bool | None = None, + memory_mbytes: int | None = None, + timeout: timedelta | None = None, + webhooks: list[Webhook] | None = None, + wait: timedelta | None = None, + token: str | None = None, + ) -> Run: ... + @overload + async def call_task( + self, + task_id: str, + task_input: dict | None = None, + *, + build: str | None = None, + restart_on_error: bool | None = None, + memory_mbytes: int | None = None, + timeout: Literal['inherit'], + webhooks: list[Webhook] | None = None, + wait: timedelta | None = None, + token: str | None = None, + ) -> Run | None: ... + @_ensure_context async def call_task( self, @@ -1096,7 +1212,7 @@ async def call_task( webhooks: list[Webhook] | None = None, wait: timedelta | None = None, token: str | None = None, - ) -> Run: + ) -> Run | None: """Start an Actor task on the Apify Platform and wait for it to finish before returning. It waits indefinitely, unless the wait argument is provided. @@ -1116,19 +1232,27 @@ async def call_task( in the default run configuration for the Actor. timeout: Optional timeout for the run. By default, the run uses timeout specified in the default run configuration for the Actor. Using `inherit` will set timeout of the other Actor to the - time remaining from this Actor timeout. + time remaining from this Actor timeout. If there is no time remaining, the task is not started + at all and `None` is returned. 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. wait: The maximum time the server waits for the run to finish. If not provided, waits indefinitely. Returns: - Info about the started Actor run. + Info about the started Actor run, or `None` if the call was skipped because `timeout='inherit'` + was used and this Actor run has no time remaining before its own timeout. """ client = self.new_client(token=token) if token else self.apify_client if timeout == 'inherit': task_call_timeout = self._get_remaining_time() + if task_call_timeout == timedelta(0): + self.log.warning( + "Actor.call_task() was skipped: `timeout='inherit'` was used, but the current run " + 'has no time remaining before its own timeout.' + ) + return None elif timeout is None: task_call_timeout = None elif isinstance(timeout, timedelta): @@ -1467,9 +1591,15 @@ def _get_default_exit_process(self) -> bool: return True def _get_remaining_time(self) -> timedelta | None: - """Get time remaining from the Actor timeout. Returns `None` if not on an Apify platform.""" + """Get time remaining from the Actor timeout, rounded up to whole seconds. + + Returns `None` if not on an Apify platform. `timedelta(0)` means the run is already past its timeout. + """ if self.is_at_home() and self.configuration.timeout_at: - return max(self.configuration.timeout_at - datetime.now(tz=UTC), timedelta(0)) + remaining = self.configuration.timeout_at - datetime.now(tz=UTC) + # Rounded up so that a positive remainder is never truncated to zero seconds by the API client - + # callers treat zero remaining time as "no time remaining" and skip starting the other Actor run. + return timedelta(seconds=max(math.ceil(remaining.total_seconds()), 0)) self.log.warning( 'Using the `inherit` argument is only possible when the Actor is running on the Apify platform and ' diff --git a/tests/unit/actor/test_actor_helpers.py b/tests/unit/actor/test_actor_helpers.py index 78c567c6..988e2e2e 100644 --- a/tests/unit/actor/test_actor_helpers.py +++ b/tests/unit/actor/test_actor_helpers.py @@ -339,6 +339,56 @@ async def test_get_remaining_time_returns_positive_when_timeout_in_future() -> N assert result <= timedelta(minutes=5) +async def test_get_remaining_time_rounds_up_to_whole_seconds() -> None: + """Test that _get_remaining_time rounds a fractional remaining time up, so it is never truncated to zero.""" + async with Actor: + Actor.configuration.is_at_home = True + Actor.configuration.timeout_at = datetime.now(tz=UTC) + timedelta(seconds=30.5) + + result = Actor._get_remaining_time() + assert result == timedelta(seconds=31) + + +@pytest.mark.parametrize('method_name', ['start', 'call']) +async def test_actor_start_and_call_skipped_when_no_inherited_time_remains( + apify_client_async_patcher: ApifyClientAsyncPatcher, + caplog: pytest.LogCaptureFixture, + method_name: str, +) -> None: + """Test that Actor.start/Actor.call with `timeout='inherit'` is skipped when the run is past its timeout.""" + apify_client_async_patcher.patch('actor', method_name, return_value=None) + caplog.set_level('WARNING') + + async with Actor: + Actor.configuration.is_at_home = True + Actor.configuration.timeout_at = datetime.now(tz=UTC) - timedelta(minutes=5) + + run = await getattr(Actor, method_name)('some-actor-id', timeout='inherit') + assert run is None + + assert len(apify_client_async_patcher.calls['actor'][method_name]) == 0 + assert any('skipped' in msg for msg in caplog.messages) + + +async def test_actor_call_task_skipped_when_no_inherited_time_remains( + apify_client_async_patcher: ApifyClientAsyncPatcher, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that Actor.call_task with `timeout='inherit'` is skipped when the run is past its timeout.""" + apify_client_async_patcher.patch('task', 'call', return_value=None) + caplog.set_level('WARNING') + + async with Actor: + Actor.configuration.is_at_home = True + Actor.configuration.timeout_at = datetime.now(tz=UTC) - timedelta(minutes=5) + + run = await Actor.call_task('some-task-id', timeout='inherit') + assert run is None + + assert len(apify_client_async_patcher.calls['task']['call']) == 0 + assert any('skipped' in msg for msg in caplog.messages) + + async def test_reboot_runs_all_listeners_even_when_one_fails( apify_client_async_patcher: ApifyClientAsyncPatcher, caplog: pytest.LogCaptureFixture, From d91ed60b3165877066e58edc71fc364a4f5cea41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 06:40:15 +0000 Subject: [PATCH 2/3] test: drop log message asserts from inherit-timeout skip tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E5mT2U8shnNpysaq8NdtZe --- tests/unit/actor/test_actor_helpers.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/unit/actor/test_actor_helpers.py b/tests/unit/actor/test_actor_helpers.py index 988e2e2e..ec44ea93 100644 --- a/tests/unit/actor/test_actor_helpers.py +++ b/tests/unit/actor/test_actor_helpers.py @@ -352,12 +352,10 @@ async def test_get_remaining_time_rounds_up_to_whole_seconds() -> None: @pytest.mark.parametrize('method_name', ['start', 'call']) async def test_actor_start_and_call_skipped_when_no_inherited_time_remains( apify_client_async_patcher: ApifyClientAsyncPatcher, - caplog: pytest.LogCaptureFixture, method_name: str, ) -> None: """Test that Actor.start/Actor.call with `timeout='inherit'` is skipped when the run is past its timeout.""" apify_client_async_patcher.patch('actor', method_name, return_value=None) - caplog.set_level('WARNING') async with Actor: Actor.configuration.is_at_home = True @@ -367,16 +365,13 @@ async def test_actor_start_and_call_skipped_when_no_inherited_time_remains( assert run is None assert len(apify_client_async_patcher.calls['actor'][method_name]) == 0 - assert any('skipped' in msg for msg in caplog.messages) async def test_actor_call_task_skipped_when_no_inherited_time_remains( apify_client_async_patcher: ApifyClientAsyncPatcher, - caplog: pytest.LogCaptureFixture, ) -> None: """Test that Actor.call_task with `timeout='inherit'` is skipped when the run is past its timeout.""" apify_client_async_patcher.patch('task', 'call', return_value=None) - caplog.set_level('WARNING') async with Actor: Actor.configuration.is_at_home = True @@ -386,7 +381,6 @@ async def test_actor_call_task_skipped_when_no_inherited_time_remains( assert run is None assert len(apify_client_async_patcher.calls['task']['call']) == 0 - assert any('skipped' in msg for msg in caplog.messages) async def test_reboot_runs_all_listeners_even_when_one_fails( From d7a5ae6c2d346e5fd3245b1c2929d4db545df1f6 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 16 Jul 2026 11:04:04 +0200 Subject: [PATCH 3/3] Minimal timeout 1s --- src/apify/_actor.py | 152 +++---------------------- tests/unit/actor/test_actor_helpers.py | 36 ++---- 2 files changed, 27 insertions(+), 161 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index fd01bb29..13dc3885 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -897,39 +897,6 @@ def get_env(self) -> dict: env_vars = {env_var.value.lower(): env_var.name.lower() for env_var in [*ActorEnvVars, *ApifyEnvVars]} return {option_name: config[env_var] for env_var, option_name in env_vars.items() if env_var in config} - @overload - async def start( - self, - actor_id: str, - run_input: Any = None, - *, - token: str | None = None, - content_type: str | None = None, - build: str | None = None, - max_total_charge_usd: Decimal | None = None, - restart_on_error: bool | None = None, - memory_mbytes: int | None = None, - timeout: timedelta | None = None, - force_permission_level: ActorPermissionLevel | None = None, - webhooks: list[Webhook] | None = None, - ) -> Run: ... - @overload - async def start( - self, - actor_id: str, - run_input: Any = None, - *, - token: str | None = None, - content_type: str | None = None, - build: str | None = None, - max_total_charge_usd: Decimal | None = None, - restart_on_error: bool | None = None, - memory_mbytes: int | None = None, - timeout: Literal['inherit'], - force_permission_level: ActorPermissionLevel | None = None, - webhooks: list[Webhook] | None = None, - ) -> Run | None: ... - @_ensure_context async def start( self, @@ -945,7 +912,7 @@ async def start( timeout: timedelta | None | Literal['inherit'] = None, force_permission_level: ActorPermissionLevel | None = None, webhooks: list[Webhook] | None = None, - ) -> Run | None: + ) -> Run: """Run an Actor on the Apify platform. Unlike `Actor.call`, this method just starts the run without waiting for finish. To wait for the run to @@ -965,8 +932,7 @@ async def start( in the default run configuration for the Actor. timeout: Optional timeout for the run. By default, the run uses timeout specified in the default run configuration for the Actor. Using `inherit` will set timeout of the other Actor - to the time remaining from this Actor timeout. If there is no time remaining, the other Actor - is not started at all and `None` is returned. + to the time remaining from this Actor timeout. force_permission_level: Override the Actor's permissions for this run. If not set, the Actor will run with permissions configured in the Actor settings. webhooks: Optional ad-hoc webhooks (https://docs.apify.com/webhooks/ad-hoc-webhooks) associated with @@ -974,19 +940,12 @@ async def start( If you already have a webhook set up for the Actor or task, you do not have to add it again here. Returns: - Info about the started Actor run, or `None` if the start was skipped because `timeout='inherit'` - was used and this Actor run has no time remaining before its own timeout. + Info about the started Actor run """ client = self.new_client(token=token) if token else self.apify_client if timeout == 'inherit': actor_start_timeout = self._get_remaining_time() - if actor_start_timeout == timedelta(0): - self.log.warning( - "Actor.start() was skipped: `timeout='inherit'` was used, but the current run " - 'has no time remaining before its own timeout.' - ) - return None elif timeout is None: actor_start_timeout = None elif isinstance(timeout, timedelta): @@ -1044,43 +1003,6 @@ async def abort( return run - @overload - async def call( - self, - actor_id: str, - run_input: Any = None, - *, - token: str | None = None, - content_type: str | None = None, - build: str | None = None, - max_total_charge_usd: Decimal | None = None, - restart_on_error: bool | None = None, - memory_mbytes: int | None = None, - timeout: timedelta | None = None, - force_permission_level: ActorPermissionLevel | None = None, - webhooks: list[Webhook] | None = None, - wait: timedelta | None = None, - logger: logging.Logger | None | Literal['default'] = 'default', - ) -> Run: ... - @overload - async def call( - self, - actor_id: str, - run_input: Any = None, - *, - token: str | None = None, - content_type: str | None = None, - build: str | None = None, - max_total_charge_usd: Decimal | None = None, - restart_on_error: bool | None = None, - memory_mbytes: int | None = None, - timeout: Literal['inherit'], - force_permission_level: ActorPermissionLevel | None = None, - webhooks: list[Webhook] | None = None, - wait: timedelta | None = None, - logger: logging.Logger | None | Literal['default'] = 'default', - ) -> Run | None: ... - @_ensure_context async def call( self, @@ -1098,7 +1020,7 @@ async def call( webhooks: list[Webhook] | None = None, wait: timedelta | None = None, logger: logging.Logger | None | Literal['default'] = 'default', - ) -> Run | None: + ) -> Run: """Start an Actor on the Apify Platform and wait for it to finish before returning. It waits indefinitely, unless the wait argument is provided. @@ -1117,8 +1039,7 @@ async def call( in the default run configuration for the Actor. timeout: Optional timeout for the run. By default, the run uses timeout specified in the default run configuration for the Actor. Using `inherit` will set timeout of the other Actor - to the time remaining from this Actor timeout. If there is no time remaining, the other Actor - is not started at all and `None` is returned. + to the time remaining from this Actor timeout. force_permission_level: Override the Actor's permissions for this run. If not set, the Actor will run with permissions configured in the Actor settings. webhooks: Optional webhooks (https://docs.apify.com/webhooks) associated with the Actor run, which can @@ -1130,19 +1051,12 @@ async def call( will redirect logs to the provided logger. Returns: - Info about the started Actor run, or `None` if the call was skipped because `timeout='inherit'` - was used and this Actor run has no time remaining before its own timeout. + Info about the started Actor run. """ client = self.new_client(token=token) if token else self.apify_client if timeout == 'inherit': actor_call_timeout = self._get_remaining_time() - if actor_call_timeout == timedelta(0): - self.log.warning( - "Actor.call() was skipped: `timeout='inherit'` was used, but the current run " - 'has no time remaining before its own timeout.' - ) - return None elif timeout is None: actor_call_timeout = None elif isinstance(timeout, timedelta): @@ -1170,35 +1084,6 @@ async def call( return run - @overload - async def call_task( - self, - task_id: str, - task_input: dict | None = None, - *, - build: str | None = None, - restart_on_error: bool | None = None, - memory_mbytes: int | None = None, - timeout: timedelta | None = None, - webhooks: list[Webhook] | None = None, - wait: timedelta | None = None, - token: str | None = None, - ) -> Run: ... - @overload - async def call_task( - self, - task_id: str, - task_input: dict | None = None, - *, - build: str | None = None, - restart_on_error: bool | None = None, - memory_mbytes: int | None = None, - timeout: Literal['inherit'], - webhooks: list[Webhook] | None = None, - wait: timedelta | None = None, - token: str | None = None, - ) -> Run | None: ... - @_ensure_context async def call_task( self, @@ -1212,7 +1097,7 @@ async def call_task( webhooks: list[Webhook] | None = None, wait: timedelta | None = None, token: str | None = None, - ) -> Run | None: + ) -> Run: """Start an Actor task on the Apify Platform and wait for it to finish before returning. It waits indefinitely, unless the wait argument is provided. @@ -1232,27 +1117,19 @@ async def call_task( in the default run configuration for the Actor. timeout: Optional timeout for the run. By default, the run uses timeout specified in the default run configuration for the Actor. Using `inherit` will set timeout of the other Actor to the - time remaining from this Actor timeout. If there is no time remaining, the task is not started - at all and `None` is returned. + 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. wait: The maximum time the server waits for the run to finish. If not provided, waits indefinitely. Returns: - Info about the started Actor run, or `None` if the call was skipped because `timeout='inherit'` - was used and this Actor run has no time remaining before its own timeout. + Info about the started Actor run. """ client = self.new_client(token=token) if token else self.apify_client if timeout == 'inherit': task_call_timeout = self._get_remaining_time() - if task_call_timeout == timedelta(0): - self.log.warning( - "Actor.call_task() was skipped: `timeout='inherit'` was used, but the current run " - 'has no time remaining before its own timeout.' - ) - return None elif timeout is None: task_call_timeout = None elif isinstance(timeout, timedelta): @@ -1591,15 +1468,16 @@ def _get_default_exit_process(self) -> bool: return True def _get_remaining_time(self) -> timedelta | None: - """Get time remaining from the Actor timeout, rounded up to whole seconds. + """Get time remaining from the Actor timeout, rounded up to whole seconds with minimum value of 1 second. + + API treats 0 second timeout as no timeout, the minimum acceptable timeout is 1 second. - Returns `None` if not on an Apify platform. `timedelta(0)` means the run is already past its timeout. + Returns `None` if not on an Apify platform. """ + smallest_possible_api_timeout = 1 if self.is_at_home() and self.configuration.timeout_at: remaining = self.configuration.timeout_at - datetime.now(tz=UTC) - # Rounded up so that a positive remainder is never truncated to zero seconds by the API client - - # callers treat zero remaining time as "no time remaining" and skip starting the other Actor run. - return timedelta(seconds=max(math.ceil(remaining.total_seconds()), 0)) + return timedelta(seconds=max(math.ceil(remaining.total_seconds()), smallest_possible_api_timeout)) self.log.warning( 'Using the `inherit` argument is only possible when the Actor is running on the Apify platform and ' diff --git a/tests/unit/actor/test_actor_helpers.py b/tests/unit/actor/test_actor_helpers.py index ec44ea93..2078b7f3 100644 --- a/tests/unit/actor/test_actor_helpers.py +++ b/tests/unit/actor/test_actor_helpers.py @@ -4,7 +4,7 @@ import logging from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -316,15 +316,15 @@ async def test_get_remaining_time_warns_when_not_at_home(caplog: pytest.LogCaptu assert any('inherit' in msg for msg in caplog.messages) -async def test_get_remaining_time_clamps_negative_to_zero() -> None: - """Test that _get_remaining_time returns timedelta(0) instead of a negative value when timeout is in the past.""" +async def test_get_remaining_time_clamps_negative_to_one() -> None: + """Test that _get_remaining_time returns 1 second instead of a negative value when timeout is in the past.""" async with Actor: Actor.configuration.is_at_home = True Actor.configuration.timeout_at = datetime.now(tz=UTC) - timedelta(minutes=5) result = Actor._get_remaining_time() assert result is not None - assert result == timedelta(0) + assert result == timedelta(seconds=1) async def test_get_remaining_time_returns_positive_when_timeout_in_future() -> None: @@ -339,48 +339,36 @@ async def test_get_remaining_time_returns_positive_when_timeout_in_future() -> N assert result <= timedelta(minutes=5) -async def test_get_remaining_time_rounds_up_to_whole_seconds() -> None: - """Test that _get_remaining_time rounds a fractional remaining time up, so it is never truncated to zero.""" - async with Actor: - Actor.configuration.is_at_home = True - Actor.configuration.timeout_at = datetime.now(tz=UTC) + timedelta(seconds=30.5) - - result = Actor._get_remaining_time() - assert result == timedelta(seconds=31) - - @pytest.mark.parametrize('method_name', ['start', 'call']) async def test_actor_start_and_call_skipped_when_no_inherited_time_remains( apify_client_async_patcher: ApifyClientAsyncPatcher, method_name: str, ) -> None: """Test that Actor.start/Actor.call with `timeout='inherit'` is skipped when the run is past its timeout.""" - apify_client_async_patcher.patch('actor', method_name, return_value=None) + apify_client_async_patcher.patch('actor', method_name, return_value=Mock()) async with Actor: Actor.configuration.is_at_home = True Actor.configuration.timeout_at = datetime.now(tz=UTC) - timedelta(minutes=5) + await getattr(Actor, method_name)('some-actor-id', timeout='inherit') - run = await getattr(Actor, method_name)('some-actor-id', timeout='inherit') - assert run is None - - assert len(apify_client_async_patcher.calls['actor'][method_name]) == 0 + assert len(apify_client_async_patcher.calls['actor'][method_name]) == 1 + assert apify_client_async_patcher.calls['actor'][method_name][0][1]['run_timeout'] == timedelta(seconds=1) async def test_actor_call_task_skipped_when_no_inherited_time_remains( apify_client_async_patcher: ApifyClientAsyncPatcher, ) -> None: """Test that Actor.call_task with `timeout='inherit'` is skipped when the run is past its timeout.""" - apify_client_async_patcher.patch('task', 'call', return_value=None) + apify_client_async_patcher.patch('task', 'call', 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.call_task('some-task-id', timeout='inherit') - run = await Actor.call_task('some-task-id', timeout='inherit') - assert run is None - - assert len(apify_client_async_patcher.calls['task']['call']) == 0 + assert len(apify_client_async_patcher.calls['task']['call']) == 1 + assert apify_client_async_patcher.calls['task']['call'][0][1]['run_timeout'] == timedelta(seconds=1) async def test_reboot_runs_all_listeners_even_when_one_fails(