Skip to content

Commit a5a95d2

Browse files
authored
fix: respect timeout when polling in ActorClient.call() and TaskClient.call() (#1184)
- Forward `call(timeout=...)` to `wait_for_finish` for both Actor and Task clients, sync and async, including Actor log redirection. The timeout remains per HTTP request; `wait_duration` still controls overall waiting. - Clarify the timeout docstrings and add six focused unit cases. Fixes #1171
1 parent cbed860 commit a5a95d2

3 files changed

Lines changed: 94 additions & 10 deletions

File tree

‎src/apify_client/_resource_clients/actor.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,9 @@ def call(
386386
default logger will be used. Setting `None` will disable any log propagation. Passing custom logger
387387
will redirect logs to the provided logger. The logger is also used to capture status and status message
388388
of the other Actor run.
389-
timeout: Timeout for the API HTTP request.
389+
timeout: Timeout for each API HTTP request, both the start and every poll. The server holds each poll for
390+
up to 60 seconds, so a shorter timeout cuts healthy polls off. Each retry doubles the timeout, and the
391+
call fails if the retries run out first.
390392
391393
Returns:
392394
The run object.
@@ -414,7 +416,7 @@ def call(
414416
)
415417

416418
if not logger:
417-
return run_client.wait_for_finish(wait_duration=wait_duration)
419+
return run_client.wait_for_finish(wait_duration=wait_duration, timeout=timeout)
418420

419421
if logger == 'default':
420422
logger = None
@@ -426,7 +428,7 @@ def call(
426428
streamed_log = run_client.get_streamed_log(to_logger=logger)
427429

428430
with status_redirector, streamed_log:
429-
return run_client.wait_for_finish(wait_duration=wait_duration)
431+
return run_client.wait_for_finish(wait_duration=wait_duration, timeout=timeout)
430432

431433
def build(
432434
self,
@@ -946,7 +948,9 @@ async def call(
946948
default logger will be used. Setting `None` will disable any log propagation. Passing custom logger
947949
will redirect logs to the provided logger. The logger is also used to capture status and status message
948950
of the other Actor run.
949-
timeout: Timeout for the API HTTP request.
951+
timeout: Timeout for each API HTTP request, both the start and every poll. The server holds each poll for
952+
up to 60 seconds, so a shorter timeout cuts healthy polls off. Each retry doubles the timeout, and the
953+
call fails if the retries run out first.
950954
951955
Returns:
952956
The run object.
@@ -975,7 +979,7 @@ async def call(
975979
)
976980

977981
if not logger:
978-
return await run_client.wait_for_finish(wait_duration=wait_duration)
982+
return await run_client.wait_for_finish(wait_duration=wait_duration, timeout=timeout)
979983

980984
if logger == 'default':
981985
logger = None
@@ -987,7 +991,7 @@ async def call(
987991
streamed_log = await run_client.get_streamed_log(to_logger=logger)
988992

989993
async with status_redirector, streamed_log:
990-
return await run_client.wait_for_finish(wait_duration=wait_duration)
994+
return await run_client.wait_for_finish(wait_duration=wait_duration, timeout=timeout)
991995

992996
async def build(
993997
self,

‎src/apify_client/_resource_clients/task.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,9 @@ def call(
347347
than the whole memory limit of the account is rejected with `actor-memory-limit-exceeded` as well and
348348
never starts, so `True` retries it forever. The time spent retrying doesn't count toward
349349
`wait_duration`.
350-
timeout: Timeout for the API HTTP request.
350+
timeout: Timeout for each API HTTP request, both the start and every poll. The server holds each poll for
351+
up to 60 seconds, so a shorter timeout cuts healthy polls off. Each retry doubles the timeout, and the
352+
call fails if the retries run out first.
351353
352354
Returns:
353355
The run object.
@@ -372,7 +374,7 @@ def call(
372374
http_client=self._http_client,
373375
client_registry=self._client_registry,
374376
)
375-
return run_client.wait_for_finish(wait_duration=wait_duration)
377+
return run_client.wait_for_finish(wait_duration=wait_duration, timeout=timeout)
376378

377379
def get_input(self, *, timeout: Timeout = 'short') -> dict:
378380
"""Retrieve the default input for this task.
@@ -761,7 +763,9 @@ async def call(
761763
than the whole memory limit of the account is rejected with `actor-memory-limit-exceeded` as well and
762764
never starts, so `True` retries it forever. The time spent retrying doesn't count toward
763765
`wait_duration`.
764-
timeout: Timeout for the API HTTP request.
766+
timeout: Timeout for each API HTTP request, both the start and every poll. The server holds each poll for
767+
up to 60 seconds, so a shorter timeout cuts healthy polls off. Each retry doubles the timeout, and the
768+
call fails if the retries run out first.
765769
766770
Returns:
767771
The run object.
@@ -785,7 +789,7 @@ async def call(
785789
http_client=self._http_client,
786790
client_registry=self._client_registry,
787791
)
788-
return await run_client.wait_for_finish(wait_duration=wait_duration)
792+
return await run_client.wait_for_finish(wait_duration=wait_duration, timeout=timeout)
789793

790794
async def get_input(self, *, timeout: Timeout = 'short') -> dict:
791795
"""Retrieve the default input for this task.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
from datetime import timedelta
4+
from unittest.mock import AsyncMock, MagicMock, Mock
5+
6+
import pytest
7+
8+
from apify_client import ApifyClient, ApifyClientAsync
9+
10+
11+
@pytest.mark.parametrize('resource', [pytest.param('actor', id='actor'), pytest.param('task', id='task')])
12+
def test_call_passes_timeout_to_poll_sync(monkeypatch: pytest.MonkeyPatch, resource: str) -> None:
13+
"""Apply the per-request timeout to both the start and poll requests."""
14+
client = ApifyClient(token='test_token')
15+
sub_client = getattr(client, resource)('resource-id')
16+
start = Mock(return_value=Mock(id='run-id'))
17+
poll = Mock(return_value=Mock(id='run-id'))
18+
monkeypatch.setattr(sub_client, 'start', start)
19+
monkeypatch.setattr(sub_client._client_registry, 'run_client', Mock(return_value=Mock(wait_for_finish=poll)))
20+
21+
timeout = timedelta(seconds=90)
22+
kwargs = {'logger': None} if resource == 'actor' else {}
23+
sub_client.call(timeout=timeout, wait_duration=timedelta(seconds=120), **kwargs)
24+
25+
assert start.call_args.kwargs['timeout'] == timeout
26+
poll.assert_called_once_with(wait_duration=timedelta(seconds=120), timeout=timeout)
27+
28+
29+
@pytest.mark.parametrize('resource', [pytest.param('actor', id='actor'), pytest.param('task', id='task')])
30+
async def test_call_passes_timeout_to_poll_async(monkeypatch: pytest.MonkeyPatch, resource: str) -> None:
31+
"""Apply the per-request timeout to both async start and poll requests."""
32+
client = ApifyClientAsync(token='test_token')
33+
sub_client = getattr(client, resource)('resource-id')
34+
start = AsyncMock(return_value=Mock(id='run-id'))
35+
poll = AsyncMock(return_value=Mock(id='run-id'))
36+
monkeypatch.setattr(sub_client, 'start', start)
37+
monkeypatch.setattr(sub_client._client_registry, 'run_client', Mock(return_value=Mock(wait_for_finish=poll)))
38+
39+
timeout = timedelta(seconds=90)
40+
kwargs = {'logger': None} if resource == 'actor' else {}
41+
await sub_client.call(timeout=timeout, wait_duration=timedelta(seconds=120), **kwargs)
42+
43+
assert start.call_args.kwargs['timeout'] == timeout
44+
poll.assert_awaited_once_with(wait_duration=timedelta(seconds=120), timeout=timeout)
45+
46+
47+
def test_actor_call_passes_timeout_to_poll_with_log_redirect(monkeypatch: pytest.MonkeyPatch) -> None:
48+
"""Keep the poll timeout when the default log redirect is active."""
49+
actor = ApifyClient(token='test_token').actor('actor-id')
50+
monkeypatch.setattr(actor, 'start', Mock(return_value=Mock(id='run-id')))
51+
poll = Mock()
52+
run_client = Mock(wait_for_finish=poll)
53+
run_client.get_status_message_watcher.return_value = MagicMock()
54+
run_client.get_streamed_log.return_value = MagicMock()
55+
monkeypatch.setattr(actor._client_registry, 'run_client', Mock(return_value=run_client))
56+
57+
timeout = timedelta(seconds=90)
58+
actor.call(timeout=timeout)
59+
60+
poll.assert_called_once_with(wait_duration=None, timeout=timeout)
61+
62+
63+
async def test_actor_call_passes_timeout_to_poll_with_log_redirect_async(monkeypatch: pytest.MonkeyPatch) -> None:
64+
"""Keep the async poll timeout when the default log redirect is active."""
65+
actor = ApifyClientAsync(token='test_token').actor('actor-id')
66+
monkeypatch.setattr(actor, 'start', AsyncMock(return_value=Mock(id='run-id')))
67+
poll = AsyncMock()
68+
run_client = Mock(wait_for_finish=poll)
69+
run_client.get_status_message_watcher = AsyncMock(return_value=MagicMock())
70+
run_client.get_streamed_log = AsyncMock(return_value=MagicMock())
71+
monkeypatch.setattr(actor._client_registry, 'run_client', Mock(return_value=run_client))
72+
73+
timeout = timedelta(seconds=90)
74+
await actor.call(timeout=timeout)
75+
76+
poll.assert_awaited_once_with(wait_duration=None, timeout=timeout)

0 commit comments

Comments
 (0)