Skip to content

0.8.3 — RetryBudget cluster + retry/client robustness

Choose a tag to compare

@lesnik512 lesnik512 released this 08 Jun 09:00
171d893

httpware 0.8.3 — RetryBudget cluster + retry/client robustness

Patch release with three behavioral changes you should know about. All driven by the deep audit; collectively close 7 audit findings (3 RetryBudget, 2 retry-surface nits, 2 chunk-3 test rewrites).

TL;DR

  • RetryBudget deposits once per request, not once per attempt. Tighter retry pacing under load — matches the documented Finagle contract.
  • RetryBudget ceiling uses math.ceil, not int(...) truncation. No more silent off-by-one against the configured percent_can_retry.
  • Retry-After > max_delay now raises the underlying StatusError with a PEP 678 note rather than silently capping the sleep at max_delay (and retrying into the same error).
  • RuntimeError → TransportError mapping now keys on httpx2.Client.is_closed, not substring-matching "closed" in the exception message.
  • Streaming-body refusal note is now scoped to where streaming is actually the blocker (not attached to method-ineligible refusals).

The behavioral changes

RetryBudget.deposit() per request, not per attempt

The Finagle retry-budget contract is withdrawals / deposits <= percent_can_retry where the denominator counts original requests. AsyncRetry and Retry previously deposited a token inside the per-attempt loop, so a request that retried twice contributed three deposits and two withdrawals — inflating the ratio by (attempts-1)/attempts and letting through more retries than percent_can_retry allowed.

Now deposit() is hoisted above the attempt loop and runs exactly once per __call__. Users with active retry traffic will see the budget refuse retries earlier than before. This is the documented contract; the previous behavior was the bug.

If you were tuning percent_can_retry against the pre-0.8.3 behavior, re-validate your target retry rate.

RetryBudget ceiling: math.ceil instead of int(...)

try_withdraw's ceiling computed int(deposits * percent) + floor, truncating fractional values. For deposits=4 and percent_can_retry=0.2, the term was int(0.8) = 0 — with a floor=0, no retries were permitted even though the configured percentage says the first retry should be allowed at 5 deposits.

math.ceil makes the threshold honor the configured percentage at the first deposit-count where it is mathematically expressible. The previous behavior was strictly under-permissive; users with min_retries_per_sec > 0 were insulated by the floor, but min_retries_per_sec=0.0 configurations saw the off-by-one.

Retry-After > max_delay raises instead of silently capping

Previously when a server sent Retry-After: 120 and the client had max_delay=5.0, AsyncRetry/Retry clamped to 5s and retried — almost certainly hitting the same 503 or 429 and burning an attempt while violating the server's hint.

Now: when the parsed Retry-After exceeds max_delay, AsyncRetry/Retry re-raises the underlying StatusError (e.g. ServiceUnavailableError) with a PEP 678 note:

httpware: Retry-After (120s) exceeded max_delay (5.0s); giving up

If you want to keep retrying despite the gap, raise max_delay to accommodate the server's hint, or set respect_retry_after=False to drop back to jittered backoff.

RuntimeError → TransportError via is_closed

Both AsyncClient._terminal and Client._terminal mapped RuntimeError to TransportError by substring-matching "closed" in str(exc). Two failure modes: any unrelated RuntimeError whose message happened to contain "closed" was mis-classified as TransportError; conversely, an httpx2 wording change (e.g. "shut down") would silently break the mapping.

Now the check is self._httpx2_client.is_closed — message-independent. Same attribute already used elsewhere in client.py for borrowed-client teardown guards.

Streaming-body refusal note scoped correctly

The early-out branch for method ineligibility OR non-retryable status also attached the streaming-body refusal note whenever retryable_status and STREAMING_BODY_MARKER — misleadingly suggesting the stream was the blocker when the actual reason was method exclusion (e.g. POST not in retry_methods).

The note now fires only at the dedicated streaming-refusal site, where streaming IS the blocker. The diagnostic is precise instead of misleading.

Fixes that aren't user-visible

  • The RetryBudget Hypothesis property test (tests/test_budget_props.py) used to compute its expected ceiling with the same int(...) formula as production, so it couldn't detect the off-by-one. Now uses math.ceil and asserts equality.
  • A new property test on RetryBudget (tests/test_retry_props.py::test_budget_exhaustion_is_reachable_and_deterministic) exercises the budget-exhaustion path that the existing retry property tests left uncovered.

Audit findings closed

7 of the 35 audit findings from planning/audit/2026-06-07-deep-audit.md — the entire RetryBudget cross-cutting cluster plus 2 adjacent retry-surface nits.

Upgrade

uv add httpware==0.8.3
# or
pip install -U 'httpware==0.8.3'

No import changes; no API surface changes; constructor signatures unchanged.