Skip to content

Commit b31ba5a

Browse files
committed
fix(queue): honor Retry-After on transient errors (review feedback)
Use the Retry-After header (delta-seconds form, floored at 0.5s and capped at 60s) for the retry delay on 429/503-style responses, and use the stop-aware wait() instead of sleep() so a long delay can't block shutdown.
1 parent b2f2928 commit b31ba5a

2 files changed

Lines changed: 40 additions & 4 deletions

File tree

aw_client/client.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,21 @@ def _warn_queue_before_connect(self) -> None:
425425
QueuedRequest = namedtuple("QueuedRequest", ["endpoint", "data"])
426426
Bucket = namedtuple("Bucket", ["id", "type"])
427427

428+
# Bounds for the delay before retrying a queued request after a
429+
# transient server error (e.g. 429/503), honoring Retry-After if given.
430+
RETRY_DELAY_DEFAULT = 0.5
431+
RETRY_DELAY_MAX = 60.0
432+
433+
434+
def _retry_delay(response: req.Response) -> float:
435+
"""Delay before retrying, honoring the Retry-After header (delta-seconds
436+
form) if present and sane; the HTTP-date form falls back to the default."""
437+
try:
438+
delay = float(response.headers.get("Retry-After", RETRY_DELAY_DEFAULT))
439+
except ValueError:
440+
return RETRY_DELAY_DEFAULT
441+
return max(RETRY_DELAY_DEFAULT, min(delay, RETRY_DELAY_MAX))
442+
428443

429444
class RequestQueue(threading.Thread):
430445
"""Used to asynchronously send heartbeats.
@@ -539,17 +554,20 @@ def _dispatch_request(self) -> None:
539554
# NOTE: `e.response is not None` matters: Response.__bool__ is
540555
# False for any non-2xx status, so a plain `if e.response` never
541556
# matches an error response.
542-
status_code = e.response.status_code if e.response is not None else None
543-
if status_code in self.RETRY_STATUS_CODES:
557+
response = e.response
558+
status_code = response.status_code if response is not None else None
559+
if response is not None and status_code in self.RETRY_STATUS_CODES:
544560
# Transient server-side problem (busy, overloaded, restarting
545561
# or behind a flaky proxy) - the request itself is likely
546562
# fine, so keep it in the queue and retry. Heartbeats are safe
547563
# to replay: a duplicate of an already-processed heartbeat
548564
# merges into the last event as a no-op.
565+
delay = _retry_delay(response)
549566
logger.warning(
550-
f"Server error {status_code}, will retry: {request.endpoint}"
567+
f"Server error {status_code}, will retry in {delay}s: {request.endpoint}"
551568
)
552-
sleep(0.5)
569+
# stop-aware wait, so a long Retry-After can't block shutdown
570+
self.wait(delay)
553571
return
554572
else:
555573
# Client errors (e.g. HTTP 400 - bad request, see

tests/test_requestqueue.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,21 @@ def test_dispatch_keeps_queue_on_connection_error():
177177

178178
assert rq._get_next() is not None # still queued
179179
assert rq.connected is False
180+
181+
182+
def test_retry_delay_honors_retry_after():
183+
from aw_client.client import _retry_delay, RETRY_DELAY_DEFAULT, RETRY_DELAY_MAX
184+
185+
def resp(retry_after=None):
186+
r = requests.Response()
187+
r.status_code = 429
188+
if retry_after is not None:
189+
r.headers["Retry-After"] = retry_after
190+
return r
191+
192+
assert _retry_delay(resp()) == RETRY_DELAY_DEFAULT # absent
193+
assert _retry_delay(resp("2")) == 2.0 # delta-seconds
194+
assert _retry_delay(resp("9999")) == RETRY_DELAY_MAX # capped
195+
assert _retry_delay(resp("0")) == RETRY_DELAY_DEFAULT # floored
196+
# HTTP-date form is not parsed, falls back to default
197+
assert _retry_delay(resp("Wed, 21 Oct 2026 07:28:00 GMT")) == RETRY_DELAY_DEFAULT

0 commit comments

Comments
 (0)