From 2ab8026342440807cc2a7af57f3fbc0946d1b1eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 15:07:02 +0000 Subject: [PATCH] Add opt-in exponential backoff to Job.join() polling 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 --- python_anticaptcha/base.py | 12 ++++++-- tests/test_base.py | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/python_anticaptcha/base.py b/python_anticaptcha/base.py index f620957..6838917 100644 --- a/python_anticaptcha/base.py +++ b/python_anticaptcha/base.py @@ -69,7 +69,7 @@ def __repr__(self) -> str: return f"" return f"" - def join(self, maximum_time: int | None = None, on_check=None) -> None: + def join(self, maximum_time: int | None = None, on_check=None, backoff: bool = False) -> None: """Poll for task completion, blocking until ready or timeout. :param maximum_time: Maximum seconds to wait (default: ``MAXIMUM_JOIN_TIME``). @@ -77,13 +77,19 @@ def join(self, maximum_time: int | None = None, on_check=None) -> None: ``(elapsed_time, status)`` where *elapsed_time* is the total seconds waited so far and *status* is the last task status string (e.g. ``"processing"``). + :param backoff: When ``True``, use exponential backoff for polling + intervals starting at 1 second and doubling up to a 10-second cap. + Default ``False`` preserves the fixed 3-second interval. :raises AnticaptchaException: If *maximum_time* is exceeded. """ elapsed_time = 0 maximum_time = maximum_time or MAXIMUM_JOIN_TIME + sleep_time = 1 if backoff else SLEEP_EVERY_CHECK_FINISHED while not self.check_is_ready(): - time.sleep(SLEEP_EVERY_CHECK_FINISHED) - elapsed_time += SLEEP_EVERY_CHECK_FINISHED + time.sleep(sleep_time) + elapsed_time += sleep_time + if backoff: + sleep_time = min(sleep_time * 2, 10) if on_check is not None: on_check(elapsed_time, self._last_result.get("status")) if elapsed_time > maximum_time: diff --git a/tests/test_base.py b/tests/test_base.py index 5c97223..c3a761b 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -202,6 +202,67 @@ def test_on_check_not_called_when_immediately_ready(self, mock_sleep): callback.assert_not_called() +class TestJobJoinBackoff: + @patch("python_anticaptcha.base.time.sleep") + def test_backoff_sleep_schedule(self, mock_sleep): + client = MagicMock() + # processing 6 times then ready — enough to hit the 10s cap + client.getTaskResult.side_effect = [ + {"status": "processing"}, + {"status": "processing"}, + {"status": "processing"}, + {"status": "processing"}, + {"status": "processing"}, + {"status": "processing"}, + {"status": "ready", "solution": {}}, + ] + job = Job(client, task_id=1) + job.join(backoff=True) + sleep_values = [call.args[0] for call in mock_sleep.call_args_list] + assert sleep_values == [1, 2, 4, 8, 10, 10] + + @patch("python_anticaptcha.base.time.sleep") + def test_backoff_false_uses_fixed_interval(self, mock_sleep): + client = MagicMock() + client.getTaskResult.side_effect = [ + {"status": "processing"}, + {"status": "processing"}, + {"status": "processing"}, + {"status": "ready", "solution": {}}, + ] + job = Job(client, task_id=1) + job.join(backoff=False) + sleep_values = [call.args[0] for call in mock_sleep.call_args_list] + assert sleep_values == [SLEEP_EVERY_CHECK_FINISHED] * 3 + + @patch("python_anticaptcha.base.time.sleep") + def test_backoff_timeout_still_works(self, mock_sleep): + client = MagicMock() + client.getTaskResult.return_value = {"status": "processing"} + job = Job(client, task_id=1) + with pytest.raises(AnticaptchaException) as exc_info: + job.join(maximum_time=5, backoff=True) + assert "exceeded" in str(exc_info.value).lower() + + @patch("python_anticaptcha.base.time.sleep") + def test_backoff_with_on_check(self, mock_sleep): + client = MagicMock() + client.getTaskResult.side_effect = [ + {"status": "processing"}, + {"status": "processing"}, + {"status": "processing"}, + {"status": "ready", "solution": {}}, + ] + job = Job(client, task_id=1) + callback = MagicMock() + job.join(backoff=True, on_check=callback) + assert callback.call_count == 3 + # Elapsed times: 1, 1+2=3, 3+4=7 + callback.assert_any_call(1, "processing") + callback.assert_any_call(3, "processing") + callback.assert_any_call(7, "processing") + + class TestContextManager: def test_enter_returns_self(self): client = AnticaptchaClient("key123")