Skip to content

Commit ddfdb75

Browse files
committed
fix: treat a Retry-After integer too large for a float as an infinite wait
1 parent fa42583 commit ddfdb75

6 files changed

Lines changed: 43 additions & 18 deletions

File tree

‎docs/observability.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The logger and event names below are a stable public contract:
1313

1414
Each log record has an `event` field holding the event name, such as `event="circuit.opened"`, which you can filter on in your log aggregator. `circuit.rejected` is emitted at most once per second per circuit, so an open circuit under heavy traffic does not flood your logs. The first rejection of each outage is always emitted, `suppressed` counts the rejections left out since the previous one, and `circuit.closed` reports the outage's total in `rejected`. Events from `AsyncKeyedCircuitBreaker` and `KeyedCircuitBreaker` also carry `circuit_key`, the circuit they belong to. [Resilience](resilience.md) describes when each event fires.
1515

16-
`retry.giving_up` fires when a retryable failure is re-raised without another attempt, and its `reason` attribute says why. `max_attempts` means every attempt failed. `retry_after_exceeds_max_delay` means the server's `Retry-After` asked for a longer wait than `max_delay` allows; these events also carry `retry_after` and `max_delay` in seconds. Both kinds carry `attempts`, `method`, `url`, `last_status` and `last_exception_type`.
16+
`retry.giving_up` fires when a retryable failure is re-raised without another attempt, and its `reason` attribute says why. `max_attempts` means every attempt failed. `retry_after_exceeds_max_delay` means the server's `Retry-After` asked for a longer wait than `max_delay` allows; these events also carry `retry_after` and `max_delay` in seconds, and `retry_after` is `inf` when the header held an integer too large to fit in a float. Both kinds carry `attempts`, `method`, `url`, `last_status` and `last_exception_type`.
1717

1818
Events that carry a `url` attribute get it redacted first: httpware strips `user:pass@` userinfo and replaces every query-parameter value with `REDACTED`, keeping the keys. A URL logged as `https://api.test/items?page=REDACTED&sort=REDACTED` came from `https://api.test/items?page=2&sort=name`.
1919

‎docs/resilience.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ To bound the total time across all attempts, put [`AsyncTimeout`](#asynctimeout)
4040
- integer seconds: `Retry-After: 30` waits 30 seconds.
4141
- an HTTP date (RFC 5322): `Retry-After: Wed, 21 Oct 2026 07:28:00 GMT` waits until that time, or not at all if it has passed.
4242

43-
Either form over `max_delay` makes `AsyncRetry` give up as described above. Negative integers count as 0. Malformed values are ignored and the jittered backoff is used instead.
43+
Either form over `max_delay` makes `AsyncRetry` give up as described above. An integer too large to fit in a float counts as an infinite wait, so it always exceeds `max_delay`. Negative integers count as 0. Malformed values are ignored and the jittered backoff is used instead.
4444

4545
### Streaming-body refusal
4646

‎src/httpware/middleware/resilience/retry.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,17 @@ def _validate_retry_config(*, max_attempts: int, base_delay: float, max_delay: f
7979

8080

8181
def _parse_retry_after(value: str) -> float | None:
82-
"""Parse a Retry-After header value. Returns None on malformed input."""
82+
"""Parse a Retry-After header value. Returns None on malformed input, inf for integers too large for a float."""
8383
try:
84-
return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
85-
except (ValueError, OverflowError):
86-
pass
84+
seconds = int(value)
85+
except ValueError:
86+
if value.strip().isdigit() and value.isascii():
87+
return math.inf # past int()'s digit limit
88+
else:
89+
try:
90+
return max(0.0, float(seconds)) # clamp: negative integers are malformed servers
91+
except OverflowError:
92+
return math.inf if seconds > 0 else 0.0
8793
try:
8894
parsed = email.utils.parsedate_to_datetime(value)
8995
except (TypeError, ValueError, OverflowError):

‎tests/test_retry.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,7 @@ async def test_retry_after_exceeds_max_delay_does_not_consume_budget_token() ->
779779
assert len(budget._withdrawn) == 0 # noqa: SLF001 — implementation-detail access for invariant
780780

781781

782-
async def test_retry_after_huge_digit_string_does_not_crash() -> None:
783-
"""A very large Retry-After integer string must fall back to jitter, not crash with OverflowError."""
782+
async def test_retry_after_huge_digit_string_gives_up() -> None:
784783
sleeper = _SleepRecorder()
785784
handler = _ResponseSequenceWithHeaders(
786785
[
@@ -789,10 +788,11 @@ async def test_retry_after_huge_digit_string_does_not_crash() -> None:
789788
]
790789
)
791790
client = _client(handler, retry=AsyncRetry(_sleep=sleeper, base_delay=0.01, max_delay=0.05))
792-
response = await client.get("https://example.test/x")
793-
assert response.status_code == HTTPStatus.OK
794-
assert len(sleeper.calls) == 1
795-
assert 0.0 <= sleeper.calls[0] <= 0.05 # noqa: PLR2004 — 0.05 matches max_delay literal above
791+
with pytest.raises(ServiceUnavailableError) as exc_info:
792+
await client.get("https://example.test/x")
793+
assert any("exceeded max_delay" in n for n in getattr(exc_info.value, "__notes__", []))
794+
assert sleeper.calls == []
795+
assert handler.calls == 1
796796

797797

798798
async def test_method_ineligible_with_streaming_body_does_not_attach_streaming_note() -> None:

‎tests/test_retry_policy.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,25 @@ def test_respect_retry_after_false_ignores_header() -> None:
167167
assert delay <= _BASE_DELAY # jitter, not the 2.0 header value
168168

169169

170+
@pytest.mark.parametrize(
171+
"header",
172+
["9" * 309, "9" * 400, "9" * 5000, " " + "9" * 5000 + " "],
173+
ids=["309-digits", "400-digits", "5000-digits", "5000-digits-padded"],
174+
)
175+
def test_retry_after_too_large_for_a_float_gives_up(header: str) -> None:
176+
request = _request("PUT")
177+
exc = _status_exc(503, request, retry_after=header)
178+
with pytest.raises(ServiceUnavailableError) as ei:
179+
_policy().decide(attempt=0, request=request, exc=exc)
180+
assert any("Retry-After (infs) exceeded max_delay" in note for note in _notes(ei.value))
181+
182+
183+
def test_retry_after_hugely_negative_counts_as_zero() -> None:
184+
request = _request("PUT")
185+
exc = _status_exc(503, request, retry_after="-" + "9" * 400)
186+
assert _policy().decide(attempt=0, request=request, exc=exc) == 0.0
187+
188+
170189
@pytest.mark.parametrize("zone", ["-0000", "", "XYZ"])
171190
def test_retry_after_date_without_a_usable_zone_is_read_as_gmt(zone: str) -> None:
172191
future = dt.datetime.now(dt.UTC) + dt.timedelta(seconds=_DATE_AHEAD_SECONDS)

‎tests/test_retry_sync.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -628,8 +628,7 @@ def test_retry_after_exceeds_max_delay_does_not_consume_budget_token_sync() -> N
628628
assert len(budget._withdrawn) == 0 # noqa: SLF001 — implementation-detail access for invariant
629629

630630

631-
def test_retry_after_huge_digit_string_does_not_crash_sync() -> None:
632-
"""A very large Retry-After integer string must fall back to jitter, not crash (sync)."""
631+
def test_retry_after_huge_digit_string_gives_up_sync() -> None:
633632
sleeper = _SleepRecorder()
634633
handler = _ResponseSequenceWithHeaders(
635634
[
@@ -638,10 +637,11 @@ def test_retry_after_huge_digit_string_does_not_crash_sync() -> None:
638637
]
639638
)
640639
client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=0.05))
641-
response = client.get("https://example.test/x")
642-
assert response.status_code == HTTPStatus.OK
643-
assert len(sleeper.calls) == 1
644-
assert 0.0 <= sleeper.calls[0] <= 0.05 # noqa: PLR2004 — 0.05 matches max_delay literal above
640+
with pytest.raises(ServiceUnavailableError) as exc_info:
641+
client.get("https://example.test/x")
642+
assert any("exceeded max_delay" in n for n in getattr(exc_info.value, "__notes__", []))
643+
assert sleeper.calls == []
644+
assert handler.calls == 1
645645

646646

647647
def test_method_ineligible_with_streaming_body_does_not_attach_streaming_note() -> None:

0 commit comments

Comments
 (0)