Skip to content

Commit 20252da

Browse files
ad-mclaude
andauthored
Add opt-in exponential backoff to Job.join() polling (#129)
Add a `backoff` parameter to `Job.join()` that, when True, starts polling at 1s and doubles the interval up to a 10s cap. Default `backoff=False` preserves the existing fixed 3-second interval. https://claude.ai/code/session_01635dq7fRyq8VBZr1mgsU9C Co-authored-by: Claude <noreply@anthropic.com>
1 parent 85ae73f commit 20252da

2 files changed

Lines changed: 70 additions & 3 deletions

File tree

python_anticaptcha/base.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,27 @@ def __repr__(self) -> str:
6969
return f"<Job task_id={self.task_id} status={status!r}>"
7070
return f"<Job task_id={self.task_id}>"
7171

72-
def join(self, maximum_time: int | None = None, on_check=None) -> None:
72+
def join(self, maximum_time: int | None = None, on_check=None, backoff: bool = False) -> None:
7373
"""Poll for task completion, blocking until ready or timeout.
7474
7575
:param maximum_time: Maximum seconds to wait (default: ``MAXIMUM_JOIN_TIME``).
7676
:param on_check: Optional callback invoked after each poll with
7777
``(elapsed_time, status)`` where *elapsed_time* is the total seconds
7878
waited so far and *status* is the last task status string
7979
(e.g. ``"processing"``).
80+
:param backoff: When ``True``, use exponential backoff for polling
81+
intervals starting at 1 second and doubling up to a 10-second cap.
82+
Default ``False`` preserves the fixed 3-second interval.
8083
:raises AnticaptchaException: If *maximum_time* is exceeded.
8184
"""
8285
elapsed_time = 0
8386
maximum_time = maximum_time or MAXIMUM_JOIN_TIME
87+
sleep_time = 1 if backoff else SLEEP_EVERY_CHECK_FINISHED
8488
while not self.check_is_ready():
85-
time.sleep(SLEEP_EVERY_CHECK_FINISHED)
86-
elapsed_time += SLEEP_EVERY_CHECK_FINISHED
89+
time.sleep(sleep_time)
90+
elapsed_time += sleep_time
91+
if backoff:
92+
sleep_time = min(sleep_time * 2, 10)
8793
if on_check is not None:
8894
on_check(elapsed_time, self._last_result.get("status"))
8995
if elapsed_time > maximum_time:

tests/test_base.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,67 @@ def test_on_check_not_called_when_immediately_ready(self, mock_sleep):
202202
callback.assert_not_called()
203203

204204

205+
class TestJobJoinBackoff:
206+
@patch("python_anticaptcha.base.time.sleep")
207+
def test_backoff_sleep_schedule(self, mock_sleep):
208+
client = MagicMock()
209+
# processing 6 times then ready — enough to hit the 10s cap
210+
client.getTaskResult.side_effect = [
211+
{"status": "processing"},
212+
{"status": "processing"},
213+
{"status": "processing"},
214+
{"status": "processing"},
215+
{"status": "processing"},
216+
{"status": "processing"},
217+
{"status": "ready", "solution": {}},
218+
]
219+
job = Job(client, task_id=1)
220+
job.join(backoff=True)
221+
sleep_values = [call.args[0] for call in mock_sleep.call_args_list]
222+
assert sleep_values == [1, 2, 4, 8, 10, 10]
223+
224+
@patch("python_anticaptcha.base.time.sleep")
225+
def test_backoff_false_uses_fixed_interval(self, mock_sleep):
226+
client = MagicMock()
227+
client.getTaskResult.side_effect = [
228+
{"status": "processing"},
229+
{"status": "processing"},
230+
{"status": "processing"},
231+
{"status": "ready", "solution": {}},
232+
]
233+
job = Job(client, task_id=1)
234+
job.join(backoff=False)
235+
sleep_values = [call.args[0] for call in mock_sleep.call_args_list]
236+
assert sleep_values == [SLEEP_EVERY_CHECK_FINISHED] * 3
237+
238+
@patch("python_anticaptcha.base.time.sleep")
239+
def test_backoff_timeout_still_works(self, mock_sleep):
240+
client = MagicMock()
241+
client.getTaskResult.return_value = {"status": "processing"}
242+
job = Job(client, task_id=1)
243+
with pytest.raises(AnticaptchaException) as exc_info:
244+
job.join(maximum_time=5, backoff=True)
245+
assert "exceeded" in str(exc_info.value).lower()
246+
247+
@patch("python_anticaptcha.base.time.sleep")
248+
def test_backoff_with_on_check(self, mock_sleep):
249+
client = MagicMock()
250+
client.getTaskResult.side_effect = [
251+
{"status": "processing"},
252+
{"status": "processing"},
253+
{"status": "processing"},
254+
{"status": "ready", "solution": {}},
255+
]
256+
job = Job(client, task_id=1)
257+
callback = MagicMock()
258+
job.join(backoff=True, on_check=callback)
259+
assert callback.call_count == 3
260+
# Elapsed times: 1, 1+2=3, 3+4=7
261+
callback.assert_any_call(1, "processing")
262+
callback.assert_any_call(3, "processing")
263+
callback.assert_any_call(7, "processing")
264+
265+
205266
class TestContextManager:
206267
def test_enter_returns_self(self):
207268
client = AnticaptchaClient("key123")

0 commit comments

Comments
 (0)