From cb230f12c6c43c8c23b9358d2f315d3f109a1d9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:51:33 +0000 Subject: [PATCH 1/4] Initial plan From 0271ff136f1204552208f4f78be5ec3edbf8482d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:58:45 +0000 Subject: [PATCH 2/4] Initial plan Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- uv.lock | 3 --- 1 file changed, 3 deletions(-) diff --git a/uv.lock b/uv.lock index 4b9c404d..53e9b387 100644 --- a/uv.lock +++ b/uv.lock @@ -59,9 +59,6 @@ requires-dist = [ ] provides-extras = ["dev"] -[package.metadata.requires-dev] -dev = [] - [[package]] name = "execnet" version = "2.1.2" From 8fa87b316aeaed82a032ef930b0f836a9e000c76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:02:18 +0000 Subject: [PATCH 3/4] Add retry_with_jitter helper with full jitter and max_delay cap Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- main.py | 35 +++++++++++++++++++++++++++-------- tests/test_rate_limit.py | 6 +++--- tests/test_retry_jitter.py | 23 +++++++++++------------ 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/main.py b/main.py index da9c0d2c..02950c18 100644 --- a/main.py +++ b/main.py @@ -622,6 +622,7 @@ def get_password( BATCH_KEYS = [f"hostnames[{i}]" for i in range(BATCH_SIZE)] MAX_RETRIES = 10 RETRY_DELAY = 1 +MAX_RETRY_DELAY = 60.0 # Maximum retry delay in seconds (caps exponential growth) FOLDER_CREATION_DELAY = 5 # <--- CHANGED: Increased from 2 to 5 for patience MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB limit @@ -1151,9 +1152,31 @@ def _api_post_form(client: httpx.Client, url: str, data: Dict) -> httpx.Response ) +def retry_with_jitter(attempt: int, base_delay: float = 1.0, max_delay: float = MAX_RETRY_DELAY) -> float: + """Calculate retry delay with exponential backoff and full jitter. + + Full jitter draws uniformly from [0, min(base_delay * 2^attempt, max_delay)] + to spread retries evenly across the full window and prevent thundering herd. + + Args: + attempt: Retry attempt number (0-indexed) + base_delay: Base delay in seconds (default: 1.0) + max_delay: Maximum delay cap in seconds (default: MAX_RETRY_DELAY) + + Returns: + Delay in seconds with full jitter applied + """ + exponential_delay = min(base_delay * (2 ** attempt), max_delay) + return exponential_delay * random.random() + + def _retry_request(request_func, max_retries=MAX_RETRIES, delay=RETRY_DELAY): """ - Retry request with exponential backoff. + Retry request with exponential backoff and full jitter. + + RETRY STRATEGY: + - Uses retry_with_jitter() for full jitter: delay drawn from [0, min(delay*2^attempt, MAX_RETRY_DELAY)] + - Full jitter prevents thundering herd when multiple clients fail simultaneously RATE LIMIT HANDLING: - Parses X-RateLimit-* headers from all API responses @@ -1219,13 +1242,9 @@ def _retry_request(request_func, max_retries=MAX_RETRIES, delay=RETRY_DELAY): log.debug(f"Response content: {sanitize_for_log(e.response.text)}") raise - # Exponential backoff with jitter to prevent thundering herd - # Base delay: delay * (2^attempt) gives exponential growth - # Jitter: multiply by random factor in range [0.5, 1.5] to spread retries - # This prevents multiple failed requests from retrying simultaneously - base_wait = delay * (2**attempt) - jitter_factor = 0.5 + random.random() # Random value between 0.5 and 1.5 - wait_time = base_wait * jitter_factor + # Full jitter exponential backoff: delay drawn from [0, min(delay * 2^attempt, MAX_RETRY_DELAY)] + # Spreads retries evenly across the full window to prevent thundering herd + wait_time = retry_with_jitter(attempt, base_delay=delay) log.warning( f"Request failed (attempt {attempt + 1}/{max_retries}): " diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index bbf8cec4..bbd5985a 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -238,7 +238,7 @@ def test_failed_request_parses_headers(self): with main._rate_limit_lock: assert main._rate_limit_info["remaining"] == 50 - @patch('random.random', return_value=0.5) + @patch('random.random', return_value=1.0) def test_429_without_retry_after_uses_exponential_backoff(self, mock_random): """Test that 429 without Retry-After falls back to exponential backoff.""" mock_request = MagicMock() @@ -260,8 +260,8 @@ def test_429_without_retry_after_uses_exponential_backoff(self, mock_random): request_func = MagicMock(side_effect=[error, error, success_response]) - # With delay=1, backoff should be: 1s, 2s - # Total wait should be >= 3 seconds (assuming jitter factor 1.0) + # With delay=1 and random.random()=1.0 (full jitter), backoff is: 1s, 2s + # Total wait should be >= 3 seconds start_time = time.time() result = main._retry_request(request_func, max_retries=3, delay=1) elapsed = time.time() - start_time diff --git a/tests/test_retry_jitter.py b/tests/test_retry_jitter.py index 414f901b..47bcfb9b 100644 --- a/tests/test_retry_jitter.py +++ b/tests/test_retry_jitter.py @@ -55,7 +55,7 @@ def test_jitter_adds_randomness_to_retry_delays(self): "Jitter should produce different wait times across runs" def test_jitter_stays_within_bounds(self): - """Verify jitter keeps delays within expected range (0.5x to 1.5x base).""" + """Verify jitter keeps delays within expected range (0 to 1x base, full jitter).""" request_func = Mock(side_effect=httpx.TimeoutException("Connection timeout")) with patch('time.sleep') as mock_sleep: @@ -66,11 +66,11 @@ def test_jitter_stays_within_bounds(self): wait_times = [call.args[0] for call in mock_sleep.call_args_list] - # Verify each wait time is within jitter bounds + # Verify each wait time is within full-jitter bounds [0, min(base, MAX_RETRY_DELAY)] for attempt, wait_time in enumerate(wait_times): base_delay = 1 * (2 ** attempt) # Exponential backoff formula - min_expected = base_delay * 0.5 - max_expected = base_delay * 1.5 + min_expected = 0.0 # Full jitter can produce 0 + max_expected = min(base_delay, main.MAX_RETRY_DELAY) assert min_expected <= wait_time <= max_expected, \ f"Attempt {attempt}: wait time {wait_time:.2f}s outside jitter bounds " \ @@ -80,13 +80,12 @@ def test_exponential_backoff_still_increases(self): """Verify that despite jitter, the exponential base scaling is correct. We fix random.random() to a constant so that jitter becomes deterministic, - and then assert that each delay matches delay * 2**attempt * jitter_factor. + and then assert that each delay matches delay * 2**attempt * random_factor. """ request_func = Mock(side_effect=httpx.TimeoutException("Connection timeout")) - # Use a fixed random.random() so jitter multiplier is stable across attempts. - # Assuming jitter is implemented as: base_delay * (0.5 + random.random()), - # a fixed return_value of 0.5 yields a jitter_factor of 1.0. + # Full jitter is implemented as: min(base_delay * 2**attempt, MAX_RETRY_DELAY) * random.random() + # With random.random() fixed at 0.5, each delay = exponential_delay * 0.5. with patch('time.sleep') as mock_sleep, patch('random.random', return_value=0.5): try: main._retry_request(request_func, max_retries=5, delay=1) @@ -95,10 +94,10 @@ def test_exponential_backoff_still_increases(self): wait_times = [call.args[0] for call in mock_sleep.call_args_list] - jitter_factor = 0.5 + 0.5 # Matches the patched random.random() above for attempt, wait_time in enumerate(wait_times): base_delay = 1 * (2 ** attempt) - expected_delay = base_delay * jitter_factor + exponential_delay = min(base_delay, main.MAX_RETRY_DELAY) + expected_delay = exponential_delay * 0.5 # random.random() fixed at 0.5 # Use approx to avoid brittle float equality while still being strict. assert wait_time == pytest.approx(expected_delay), ( f"Attempt {attempt}: expected {expected_delay:.2f}s, " @@ -143,8 +142,8 @@ def test_429_rate_limit_retries_with_jitter(self): wait_times = [call.args[0] for call in mock_sleep.call_args_list] assert len(wait_times) == 2 - # First retry: base=1, range=[0.5, 1.5] - assert 0.5 <= wait_times[0] <= 1.5 + # First retry: full jitter, base=1, range=[0, 1.0) since random.random() < 1.0 + assert 0.0 <= wait_times[0] < 1.0 def test_successful_retry_after_transient_failure(self): """Verify successful request after transient failures works correctly.""" From 98236e147c879d23a4934b4e1211eaef1727ac4c Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Thu, 19 Feb 2026 18:19:19 -0600 Subject: [PATCH 4/4] Update main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 02950c18..3fc8a975 100644 --- a/main.py +++ b/main.py @@ -1155,7 +1155,7 @@ def _api_post_form(client: httpx.Client, url: str, data: Dict) -> httpx.Response def retry_with_jitter(attempt: int, base_delay: float = 1.0, max_delay: float = MAX_RETRY_DELAY) -> float: """Calculate retry delay with exponential backoff and full jitter. - Full jitter draws uniformly from [0, min(base_delay * 2^attempt, max_delay)] + Full jitter draws uniformly from [0, min(base_delay * 2^attempt, max_delay)) to spread retries evenly across the full window and prevent thundering herd. Args: