Skip to content

Commit 89381f6

Browse files
committed
Fix Retry-After overflow handling
1 parent 0b86336 commit 89381f6

File tree

2 files changed

+112
-3
lines changed

2 files changed

+112
-3
lines changed

atlassian/rest_client.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import logging
44
import random
55
import time
6+
from datetime import datetime, timezone
7+
from email.utils import parsedate_to_datetime
68
from http.cookiejar import CookieJar
79
from json import dumps
810
from typing import (
@@ -295,6 +297,44 @@ def _calculate_backoff_value(self, retry_count):
295297
backoff_value += random.uniform(0, self.backoff_jitter) # nosec B311
296298
return float(max(0, min(self.max_backoff_seconds, backoff_value)))
297299

300+
def _parse_retry_after_header(self, header_value: Optional[str]) -> Optional[float]:
301+
"""
302+
Parse the Retry-After header and return a safe delay (seconds).
303+
The Retry-After header may contain either an integer (delta-seconds)
304+
or an HTTP-date. Values are clamped to ``self.max_backoff_seconds`` to
305+
avoid ``time.sleep`` overflow on some platforms.
306+
"""
307+
if not header_value:
308+
return None
309+
310+
delay_seconds: Optional[float]
311+
try:
312+
delay_seconds = float(header_value)
313+
except (TypeError, ValueError):
314+
try:
315+
retry_after_dt = parsedate_to_datetime(header_value)
316+
except (TypeError, ValueError):
317+
log.warning("Unable to parse Retry-After header value '%s'", header_value)
318+
return None
319+
320+
if retry_after_dt.tzinfo is None:
321+
retry_after_dt = retry_after_dt.replace(tzinfo=timezone.utc)
322+
delay_seconds = (retry_after_dt - datetime.now(timezone.utc)).total_seconds()
323+
324+
if delay_seconds is None:
325+
return None
326+
327+
delay_seconds = max(0.0, delay_seconds)
328+
if delay_seconds > self.max_backoff_seconds:
329+
log.debug(
330+
"Retry-After value %.2f exceeds max_backoff_seconds (%s); clamping",
331+
delay_seconds,
332+
self.max_backoff_seconds,
333+
)
334+
delay_seconds = float(self.max_backoff_seconds)
335+
336+
return delay_seconds
337+
298338
def _retry_handler(self):
299339
"""
300340
Creates and returns a retry handler function for managing HTTP request retries.
@@ -310,9 +350,11 @@ def _retry_handler(self):
310350
def _handle(response):
311351
nonlocal retries
312352

313-
if self.retry_with_header and "Retry-After" in response.headers and response.status_code == 429:
314-
time.sleep(int(response.headers["Retry-After"]))
315-
return True
353+
if self.retry_with_header and response.status_code == 429:
354+
delay = self._parse_retry_after_header(response.headers.get("Retry-After"))
355+
if delay is not None:
356+
time.sleep(delay)
357+
return True
316358

317359
if not self.backoff_and_retry or self.use_urllib3_retry:
318360
return False

tests/test_rest_client.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
Unit tests for atlassian.rest_client module
44
"""
55

6+
from datetime import datetime, timedelta
7+
from types import SimpleNamespace
8+
69
import pytest
10+
711
from .mockup import mockup_server
812
from atlassian.rest_client import AtlassianRestAPI
913

@@ -369,3 +373,66 @@ def test_cloud_vs_server_behavior(self):
369373
# Cloud flag should be different
370374
assert cloud_api.cloud is True
371375
assert server_api.cloud is False
376+
377+
def test_retry_handler_clamps_retry_after(self, monkeypatch):
378+
"""Ensure large Retry-After headers are clamped to max_backoff_seconds."""
379+
captured = {}
380+
381+
def fake_sleep(delay):
382+
captured["delay"] = delay
383+
384+
monkeypatch.setattr("atlassian.rest_client.time.sleep", fake_sleep)
385+
386+
api = AtlassianRestAPI(
387+
url=f"{mockup_server()}/test",
388+
retry_with_header=True,
389+
max_backoff_seconds=5,
390+
)
391+
392+
handler = api._retry_handler()
393+
response = SimpleNamespace(headers={"Retry-After": "99999999999"}, status_code=429)
394+
395+
assert handler(response) is True
396+
assert captured["delay"] == 5
397+
398+
def test_retry_handler_parses_http_date(self, monkeypatch):
399+
"""Ensure HTTP-date Retry-After headers are converted to delta seconds."""
400+
captured = {}
401+
402+
def fake_sleep(delay):
403+
captured["delay"] = delay
404+
405+
monkeypatch.setattr("atlassian.rest_client.time.sleep", fake_sleep)
406+
407+
api = AtlassianRestAPI(
408+
url=f"{mockup_server()}/test",
409+
retry_with_header=True,
410+
max_backoff_seconds=60,
411+
)
412+
413+
handler = api._retry_handler()
414+
future_delay = 10
415+
future_date = datetime.utcnow().replace(tzinfo=None) + timedelta(seconds=future_delay)
416+
retry_after_value = future_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
417+
response = SimpleNamespace(headers={"Retry-After": retry_after_value}, status_code=429)
418+
419+
assert handler(response) is True
420+
assert pytest.approx(captured["delay"], rel=0.1) == future_delay
421+
422+
def test_retry_handler_skips_invalid_header(self, monkeypatch):
423+
"""Ensure invalid Retry-After headers fall back to regular logic."""
424+
def fake_sleep(_):
425+
raise AssertionError("sleep should not be called for invalid header")
426+
427+
monkeypatch.setattr("atlassian.rest_client.time.sleep", fake_sleep)
428+
429+
api = AtlassianRestAPI(
430+
url=f"{mockup_server()}/test",
431+
retry_with_header=True,
432+
)
433+
434+
handler = api._retry_handler()
435+
response = SimpleNamespace(headers={"Retry-After": "invalid-value"}, status_code=429)
436+
437+
# Should return False so that other retry mechanisms can take over
438+
assert handler(response) is False

0 commit comments

Comments
 (0)