Refresh a redirected write's hop 401 without making the write again
A write HEY answers with a 302 or 303 has been made; following the redirect only reads the answer. When that hop was answered 401, Kotlin, Swift, Rust and Go refreshed the credentials and started the whole operation over, POST included. A contact got created twice, and a message got sent twice.
What changes
The refresh still runs, so later requests carry the renewed credentials. But a non-idempotent operation that came back through a redirect is not sent again, and the hop's 401 goes to the caller. An idempotent operation is resent as before.
| SDK | where |
|---|---|
| Kotlin | HeyClient.attempt: the resend after a refresh also asks idempotent || !redirected |
| Swift | HeyClient.attempt: the same |
| Rust | Client::execute retry loop: the same |
Go, hand-written (Client.Post/Patch) | singleRequest: a POST or PATCH whose redirect state says a hop was followed returns the 401, not the retryable "Token refreshed" |
| Go, generated | the refresher hook (resendAfter401) refreshes, then answers "no resend" when a hop was followed and generated.IsIdempotent says the operation is not. That table and doWithRetry's isIdempotent both come from x-go-idempotent. |
Not affected: the form paths capture redirects rather than follow them, and TypeScript sends with redirect: "manual" and never follows a hop.
A 307/308 repeats the write at the hop, so a 401 there refused it. The client doesn't tell the two kinds of redirect apart and holds both back, which is safe for either. The comments and the Go, Rust, Kotlin and Swift READMEs say a redirected write may already be made.
Red first
The first commit adds a test per SDK (POST → 303 → GET answered 401 until the fresh token; assert one POST, one GET, one refresh, and a 401 to the caller). On main:
- Kotlin: the create succeeded on the resend.
- Swift: requests were
POST, GET, POST. - Rust: the POST answered 200 from the second round.
- Go:
Client.Postand the generatedContacts.Createboth succeeded on the resend.
Verified
- Each new test passes, along with each SDK's refresh and redirect suites: Kotlin
CredentialRefreshTest/RedirectTest, SwiftCredentialRefreshTests/RedirectTests(run on Linux, Swift 6.3), Rust's 110 lib tests, and Go'spkg/hey. make kt-check kt-check-drift,golangci-lint,cargo fmt --check. Conformance: Go, Rust, Kotlin and Swift 219/219, TypeScript 215/215.make checkotherwise fails on things main already fails on: cargo deny's advisory forwebpki-root-certs1.0.9 (main'sRust Testsis red for it). Locally only, Swift 6.3 fails warnings-as-errors inStalledSuccessTransportTests.swift(CI's Swift 6.0 and macOS are green), and a 5 s timeout inversion-scripts.test.tsfailed once under load and passed on rerun.
Merges cleanly with #268 in either order.
Summary by cubic
A write HEY answers with a 302 or 303 may already be made; a 401 on the redirect's hop previously made Kotlin, Swift, Rust, and Go refresh credentials and replay the whole operation, POST included, creating a contact or sending a message twice. Now the refresh still runs, but a non-idempotent operation that came back through a redirect is not sent again — the hop's 401 goes to the caller. Idempotent operations are resent as before. TypeScript sends with redirect: "manual" and never follows a hop, so it's unaffected. The client doesn't tell 302/303 (write may already have taken effect) from 307/308 (write repeated at the hop) apart and holds both back, which is safe for either.
A conformance case in credential-refresh.json holds all four SDKs to this behavior under the 302 HEY sends, asserting the single refresh and the two requests; the TypeScript runner lists it as not applicable.