Skip to content

fix!: validate retry and budget config, case-insensitive retry_methods, Retry-After give-up event - #177

Merged
lesnik512 merged 4 commits into
mainfrom
fix/retry-config
Oct 17, 2026
Merged

lesnik512 merged 4 commits into
mainfrom
fix/retry-config

Conversation

@lesnik512

Copy link
Copy Markdown
Member

What changed

Four fixes to Retry, AsyncRetry, the shared _RetryPolicy and RetryBudget. Each one was written test first, and sync and async are covered alike.

1. Config is validated at construction (breaking)

Retry, AsyncRetry and RetryBudget now raise ValueError when constructed with a value that would crash or quietly misbehave later. Before this, base_delay=-1 constructed fine and the first sync retry crashed in time.sleep with a negative delay (async slept 0). A negative max_delay made every Retry-After look too long and produced negative backoff delays. Bad budget values either turned retries off without saying so or crashed inside try_withdraw.

The bounds, and the reason for each:

Parameter Accepted Rejected because
max_attempts >= 1 unchanged
base_delay >= 0, including inf negative values give negative sleeps (sync crash); NaN is ignored by min() and the backoff silently uses max_delay
max_delay finite and >= 0 negative values give negative sleeps and refuse every Retry-After; NaN breaks the comparisons; inf lets an unbounded Retry-After (see fix 4) reach time.sleep(inf), which raises OverflowError, or hang asyncio.sleep forever
RetryBudget.ttl finite and > 0 0 or negative purges every deposit and makes the floor <= 0, so retries are refused; NaN never purges (unbounded memory) and crashes in int(); inf never purges and crashes in int()
RetryBudget.min_retries_per_sec finite and >= 0 negative lowers the percent allowance below what was configured; NaN and inf crash in int()
RetryBudget.percent_can_retry finite and >= 0, values above 1.0 included negative eats into the floor; NaN and inf crash in math.ceil()

0 stays valid wherever it already worked: base_delay=0 and max_delay=0 mean no waiting, and min_retries_per_sec=0 or percent_can_retry=0 switch off that half of the budget formula.

The validation follows _validate_bulkhead_config: one module-level function per module and module-level message constants. _validate_retry_config lives in retry.py and _validate_budget_config in budget.py.

2. retry_methods ignores case

_RetryPolicy upper-cases retry_methods once at construction, so retry_methods={"post"} now retries POST. httpx2.Request already upper-cases request.method, so the per-request .upper() call in decide is gone.

3. retry.giving_up fires when Retry-After exceeds max_delay

Before this, the give-up in that case only added the PEP 678 note and emitted nothing. It now emits retry.giving_up at WARNING with the same attributes as the attempts-exhausted emission (attempts, method, url, last_status, last_exception_type) plus reason="retry_after_exceeds_max_delay", retry_after and max_delay. The existing attempts-exhausted emission gains reason="max_attempts" so a consumer can tell the two apart with one field. That is an added attribute; nothing was renamed or removed.

4. An oversized integer Retry-After is an infinite wait

A Retry-After of 309 or more digits overflowed float(), the parser returned None, and the call was retried after the normal jittered backoff. A value past Python's 4300-digit int() limit took the same path through a ValueError. Both now parse as math.inf, which exceeds max_delay and goes down the give-up path from fix 3. A hugely negative integer now counts as 0, matching the documented rule for negative integers. Whitespace, past HTTP dates, small negative integers and non-integer values parse exactly as before.

Release notes (0.22.0)

Breaking: Retry, AsyncRetry and RetryBudget reject invalid configuration at construction with ValueError. Migration: if construction now fails, the old value was already broken at request time. Use base_delay >= 0 and a finite max_delay >= 0. Replace max_delay=float("inf") with a large finite number. For RetryBudget, use a finite ttl > 0 and finite, non-negative min_retries_per_sec and percent_can_retry. To turn the budget off in practice, pass a large min_retries_per_sec instead of inf.

Fixed: retry_methods matches regardless of case.

Fixed: giving up because Retry-After exceeded max_delay now emits retry.giving_up with reason="retry_after_exceeds_max_delay". Every retry.giving_up event now carries a reason attribute.

Fixed: a Retry-After integer too large for a float makes the retry give up instead of retrying immediately.

Docs

  • docs/resilience.md, AsyncRetry and RetryBudget sections: the parameter bounds, case-insensitive retry_methods, the event on the Retry-After give-up, and oversized integers.
  • docs/observability.md: the reason, retry_after and max_delay attributes of retry.giving_up.

Performance

Validation and method upper-casing run once, at construction. The per-request path is unchanged apart from dropping one str.upper() call in decide. Measured with timeit (best of 7 x 50,000, CPython, three alternating runs against origin/main):

Path origin/main this branch
Retry.__call__, success on first try 333 / 334 / 370 ns 334 / 340 / 369 ns
_RetryPolicy.decide, retryable 503 1793 / 1789 / 1854 ns 1764 / 1761 / 1888 ns

The differences are within run-to-run noise.

Tests

just lint, just lint-ci and just test-ci pass: 1229 tests, 100% coverage, no new pragma: no cover. The retry and budget test files also pass on Python 3.11, and just docs-build passes.

Two existing tests, test_retry_after_huge_digit_string_does_not_crash (sync and async), asserted the old retry-with-jitter behaviour for a 400-digit Retry-After. They are now test_retry_after_huge_digit_string_gives_up and assert the give-up.

@lesnik512
lesnik512 merged commit a45e0eb into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the fix/retry-config branch October 17, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant