75 lines
3.4 KiB
Markdown
75 lines
3.4 KiB
Markdown
---
|
|
id: review-002-cli01-retry-after-budget
|
|
name: Budget-aware Retry-After sleep — stop re-arming a full ceiling per attempt (CLI-01)
|
|
status: completed
|
|
depends_on: []
|
|
scope: narrow
|
|
risk: medium
|
|
impact: component
|
|
level: implementation
|
|
tags: [client, review-002]
|
|
---
|
|
|
|
## Description
|
|
|
|
Review 002 CLI-01 [major]. The `Retry-After` middleware sleeps outside
|
|
the retry budget and re-arms a full ceiling on every throttled retry —
|
|
middleware order is `RetryGateMiddleware` **outside**
|
|
`RetryAfterMiddleware` (`http_client.rs:511-520`), so every retry
|
|
attempt re-enters `RetryAfterMiddleware::handle`:
|
|
|
|
1. attempt 1 → `429` + `Retry-After: 300` → deadline recorded `t₀+300`
|
|
2. reqwest-retry sleeps ≤ 2 s, retries
|
|
3. attempt 2 re-sleeps until `t₀+300` (inside maybe_sleep_for), gets a
|
|
fresh `429` → deadline **re-armed to `t₀+302`** (a full new 300 s)
|
|
4. … for up to `max_retries` more → ~15 min wall time inside one
|
|
`forward()` call
|
|
|
|
`TotalRetryBudget` gates only its own backoff sleeps; the 30 s request
|
|
timeout never covers this window (the sleep happens before the reqwest
|
|
request is constructed, so the timer never starts).
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] `maybe_sleep_for` is budget-aware: caps the sleep by the
|
|
remaining `max_total_retry_duration` (check inside the sleep —
|
|
the pending sleeps, clamped, or checks-and-skips when the budget
|
|
is spent; implementer's choice, but a deadline that would extend
|
|
past the budget must be truncated to it)
|
|
- [ ] Re-arm clamp: `record_if_throttled` keeps the *earliest* deadline
|
|
(or otherwise does not extend past the first-seen deadline for
|
|
the same URL within one logical request) — a retry storm cannot
|
|
push the wall clock out ceiling-per-attempt
|
|
- [ ] Wire test: counting responder that always answers
|
|
`429`/`Retry-After: N` with the test's ceiling set small → the
|
|
caller's total wall time is bounded by max_total_retry_duration
|
|
+ one attempt's request time (assert the bound, not the exact
|
|
count)
|
|
- [ ] The per-URL deadline map semantics for *separate* logical
|
|
requests are unchanged (a fresh request still honors the
|
|
recorded throttle window — that feature stays)
|
|
- [ ] Fix the misspelled test name `malware_records_under_the_effective_url` while touching the file
|
|
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
|
`cargo fmt --check` pass
|
|
|
|
## References
|
|
|
|
- docs/reviews/002-post-remediation-review.md (Part D', CLI-01)
|
|
- src/client/http_client.rs:511-520 (stack order), 370-395 (TotalRetryBudget), src/client/retry_after.rs:44-62 (ceiling), :127-211 (record/maybe_sleep)
|
|
- tasks/client/review-001-client-timeout-retry.md (the Retry-After machinery)
|
|
- tasks/client/review-002-client-policy-wire-tests.md (the counting-responder seam this test family shares)
|
|
|
|
## Notes
|
|
|
|
Keep the semantics decision narrow: the goal is "one logical request's
|
|
Retry-After waits are bounded by the retry budget," NOT changing the
|
|
cross-request throttle map (FWD-11's eviction/jitter behavior is
|
|
already fixed and correct). The natural seam is threading the budget
|
|
handle into the middleware order so RetryAfter sees it — small,
|
|
testable, no public API change (the budget is already a
|
|
`HttpClientConfig` field).
|
|
|
|
## Summary
|
|
|
|
BudgetClock extension anchored per logical request; sleeps truncated to min(deadline, budget); record() keeps earliest deadline, refreshes clamped to hard stop (drop-on-exhaustion). No public API change; tests/retry_after_budget.rs wire tests added.
|