diff --git a/src/apify/_actor.py b/src/apify/_actor.py index e1acf238..13dc3885 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 @@ -1467,9 +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. Returns `None` if not on an Apify platform.""" + """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. + """ + smallest_possible_api_timeout = 1 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) + 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 78c567c6..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,6 +339,38 @@ async def test_get_remaining_time_returns_positive_when_timeout_in_future() -> N assert result <= timedelta(minutes=5) +@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=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') + + 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=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') + + 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( apify_client_async_patcher: ApifyClientAsyncPatcher, caplog: pytest.LogCaptureFixture,