A logical request's Retry-After waits are now bounded by
max_total_retry_duration, and a retry storm can no longer re-arm a
full ceiling per attempt:
- BudgetClock anchored per logical request by RetryGateMiddleware
(into the request Extensions, shared across retry attempts) and
read by the inner RetryAfterMiddleware every attempt; the monotonic
anchor projects the hard stop through wall-clock steps.
- maybe_sleep_for truncates the sleep to the remaining budget;
a spent budget skips the sleep entirely.
- record() keeps the EARLIEST deadline per URL (retry storms cannot
extend the first-seen deadline); a refresh that cannot make it
under the budget hard stop drops the entry so the next attempt
starts immediately instead of parking.
- Middleware without a budget anchor (budget = 0) keeps the prior
semantics; the shared client now wires
HttpClientConfig.max_total_retry_duration into the Retry-After
middleware.
- Wire tests (tests/retry_after_budget.rs): always-429 responder with
a 300 s Retry-After is bounded by budget + one attempt; separate
logical requests still honor the recorded throttle window.
- FWD-12 atomic reload pairing and the FWD-15 stream-client split
untouched (stack built in build_client_with_pems for both).
FWD-15: forward_stream now sends through SharedHttpClient::stream_client
— a client derived from the same config with the total request timeout
removed and connect + read timeouts retained. reqwest 0.13's per-request
override can lengthen a client-level total timeout but never clear it
(request-scoped None falls back to the client default), so the derived
client is the only correct mechanism. Both clients rebuild-and-swap
together atomically (FWD-12). A healthy >30s subscription survives; the
read timeout stays as the staleness guard, matching the gateway's
deadline: None dispatch contract (alkcall ADR-021).
FWD-14: the streaming branch enforces a total streamed-bytes cap per
subscription (HttpClientConfig::stream_total_byte_cap, default 1 GiB),
accumulated across every chunk fed to the SSE parser; exceeding it
terminates with a single terminal HTTP_413 error envelope. The SSE
line-cap check moved before extend_from_slice so the reassembly buffer
can never exceed the cap. Removing the total timeout without this cap
would open an unbounded-memory window, so both land together.
Wire tests: keepalive trickle past a scaled total-timeout deadline keeps
delivering; over-cap stream terminates with exactly one terminal error;
parser boundary tests for pre-extend cap checks.
Verified: cargo test (302+5), --all-features (373+41), clippy
--all-targets -D warnings (default + all-features), fmt --check,
doc --no-deps clean.