Refresh a redirected write's hop 401 without making the write again

Merged
JE
4d ago
+388-50

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.

SDKwhere
KotlinHeyClient.attempt: the resend after a refresh also asks idempotent || !redirected
SwiftHeyClient.attempt: the same
RustClient::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, generatedthe 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.Post and the generated Contacts.Create both succeeded on the resend.

Verified

  • Each new test passes, along with each SDK's refresh and redirect suites: Kotlin CredentialRefreshTest/RedirectTest, Swift CredentialRefreshTests/RedirectTests (run on Linux, Swift 6.3), Rust's 110 lib tests, and Go's pkg/hey.
  • make kt-check kt-check-drift, golangci-lint, cargo fmt --check. Conformance: Go, Rust, Kotlin and Swift 219/219, TypeScript 215/215.
  • make check otherwise fails on things main already fails on: cargo deny's advisory for webpki-root-certs 1.0.9 (main's Rust Tests is red for it). Locally only, Swift 6.3 fails warnings-as-errors in StalledSuccessTransportTests.swift (CI's Swift 6.0 and macOS are green), and a 5 s timeout in version-scripts.test.ts failed 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.