Honor each route's retry policy in the Rust client
Card: hey-sdk Rust: honor per-operation retry policy
What changes
The generator has always written every route's policy into the route table (routes::LIST_BOXES.retry = Retry { max: 3, base_delay_ms: 1000, retry_on: &[429, 503] }, from behavior-model.json), and the client never read it: attempt() ran on the client-wide max_retries, gated only by idempotency, and resent on a fixed [429, 500, 502, 503, 504]. Go has honoured the policy since #94, with a Go-only fixture.
Operationcarries its&'static Route;Pagecarries it to every page read after the first, sonext_page/each_pageresend under the first page's policy rather than as anonymous requests.attempt()spends what the policy allows.maxis Smithy'smaxAttempts— the sends in all — somax: 3is three requests,max: 2(DeleteBoxDesignationand 61 others) is two.- The client's settings only make that gentler:
max_retriesis a ceiling on the sends (max_retries(1)on amax: 3route → 2 requests;max_retries(10)→ still 3),base_delaya floor under the first wait,max_delaya ceiling over every wait.base_delaybecomesOptioninternally so a client that never set one starts from the route'sbase_delay_ms; the public builder is unchanged. retry_onreplaces the fixed status list for modelled routes: a 500 onListBoxesis the answer, not a retry.RequestResult::retryable(what hooks see) now says whether the policy would resend the status.- A route the model gives no policy (
retry: { max: 0 }— the four non-idempotent POSTs) is sent once, whatever the client allows. A path the caller wrote (client.get("/custom"), form requests) has no policy and runs on the client's settings as before, including the old status list. Retry-Afteron a 429 is honoured as given (seconds or HTTP-date, rounded up per #151) when the policy names 429; a 429 on a route whose policy does not name it is not waited out.conformance/tests/go/retry-policy.jsonmoves toconformance/tests/retry-policy.jsonand thego/directory is gone; the Go runner loses its private glob. The Rust runner already had theDeleteBoxDesignationarm and theretryableassertion; at the generated layer it now carries arepeatOperationcase past a run that failed with an HTTP status (the Go runner's generated client counts such a run as answered, not failed), which is what the case's first 500 needs. The HEY layer stops at every failure, as Go's does.max_delayis now the ceiling it is documented as: it is no longer lifted to a largerbase_delayat build time, and the jitter goes under it too. The wait aRetry-Afternames is still honoured as given.
Second commit is a one-line rewrite of the cache-key check that clippy 1.97 flags (nonminimal_bool); it was blocking make rs-lint locally and is unrelated.
Go
Go's hey.Client passes WithRetryConfig, which replaces every operation's attempt count with MaxRetries + 1 and never reads baseDelayMs; the shared fixture passes there only because the runner drives the raw generated client. This PR is Rust-only. A separate PR is making the Go client honour the per-operation policy with the client-wide config as the same ceiling, and will split the shared fixture into status-list and attempt-count cases; the fixture's move out of conformance/tests/go/ lands here first.
Gate
On the branch as pushed (rebased onto main after #153):
make rs-check(fmt-check, clippy--all-targets -D warnings,cargo test --workspace, doc-D warnings): greenmake rs-check-drift: greenmake conformance-rs: 191/191make conformance-go: 191/191 (the moved fixture runs from the shared directory)
New tests: rust/hey-sdk/tests/retry_policy.rs (15 cases from literal wiremock bodies — policy count, ceiling, per-route count, status outside the policy, raw path on client defaults, 429 not named by the policy, no-policy route, non-idempotent, policy delay with client floor, max_delay over the policy's wait and over the client's own floor and jitter, Retry-After kept above the ceiling, Retry-After HTTP-date on a modelled route, later pages under the first page's policy with a route budget told from a raw one, route-table pin), plus client_pipeline::resending_stops_at_the_retry_limit corrected from 4 to the model's 3.
Adversarial review
Besides Copilot and the Codex GitHub reviewer, the diff was reviewed by Codex CLI (gpt-6-astra, reasoning xhigh). Findings and what was done:
max_delaylifted tobase_delayat build time, jitter added above the cap — fixed (also Copilot's).- Runner continuation applied to HEY-layer cases too — fixed, scoped to the generated layer (also Copilot's).
max_retries + 1overflows foru32::MAXon the path that used to skip it — fixed withsaturating_add.- The pagination test passed with route propagation removed (3 sends fit both budgets) — fixed: page two now answers an undeclared 500 under
max_retries(10), which a raw budget would resend. - Timing tests with tight upper bounds and an HTTP-date computed before mock setup — fixed: the policy delay is 1.5 s so the bounds are wide, the date is computed after the mocks are mounted.
- README overstated "sends in all": the ordinary budget is
min(max, max_retries + 1), the refresh resend is granted past a spent budget,Retry-After: 0falls back to backoff — fixed in the README. follow_paginationbuilds later pages withOperation::atand so loses the policy — declined: aResponsenames no route, giving it one means a new field on a public all-pubstruct, and no service usesfollow_pagination(onlyget_all-style raw walks do). Documented on the method and in the README;next_page/each_pagecarry the policy.- The shared fixture cannot tell the correct policy from
retry_on: [500](both give three requests and a final 503) — declined here: per-invocation assertions need a fixture-format change, and the fixture split is being done with the Go change.