Skip to content

fix(client): retry across multiple 402 challenges up to a default limit - #118

Closed
ygd58 wants to merge 1 commit into
tempoxyz:mainfrom
ygd58:fix/client-retry-multiple-402s
Closed

ygd58 wants to merge 1 commit into
tempoxyz:mainfrom
ygd58:fix/client-retry-multiple-402s

Conversation

@ygd58

@ygd58 ygd58 commented Aug 13, 2026

Copy link
Copy Markdown

What

Transport.RoundTrip handled exactly one 402 response: it created a credential, retried once, and returned that retry's response directly regardless of what it was. Canonical mppx (src/client/internal/Fetch.ts) loops up to maxPaymentRetries (default 3) successive actionable challenges, since a server can legitimately respond to a submitted credential with a fresh 402 — a different challenge, not success or a final failure (e.g. the original challenge expired mid-flight, or the credential was rejected but the server is willing to reissue). Against such a server, the Go transport recovered from zero of those retries and returned the second 402 to the caller unpaid, even though canonical mppx would have paid successfully.

Flagged by the cross-SDK audit as AGR-2026-052.

Fix

Wraps the existing single-attempt logic in a loop bounded by a new defaultMaxPaymentRetries = 3 constant (matching mppx's default). Each iteration re-parses WWW-Authenticate challenges from the latest response, so a fresh challenge on retry N is picked up on iteration N. The retry request is always cloned from the original req (not the previous retry), so a GetBody-backed request body is re-read fresh each attempt instead of chaining clones of clones.

Behavior for a single 402 (the common case) is unchanged. A server that never accepts still gets bounded by the retry limit rather than looping forever, and a non-actionable 402 (no matching method) still returns immediately as before.

Testing

  • TestTransport_RoundTrip_RetriesAcrossMultiple402s: a server that rejects the first credential with a second, different challenge succeeds on the third request, and CreateCredential is called once per distinct challenge.
  • TestTransport_RoundTrip_GivesUpAfterMaxRetries: a server that always returns 402 is called exactly 1+defaultMaxPaymentRetries times, then the transport returns that last 402 rather than looping forever.

Ran go test ./pkg/client/... -v and go vet ./pkg/client/... locally — all pass, including the full existing suite (no regressions). Unlike the mpp-java PRs I opened today, pkg/client only depends on stdlib + pkg/mpp, so I could actually compile and run this one end-to-end.

Fixes tempoxyz/mpp-tools#148

Transport.RoundTrip handled exactly one 402 response: it created a
credential, retried once, and returned that retry's response directly
regardless of what it was. Canonical mppx (src/client/internal/Fetch.ts)
loops up to maxPaymentRetries (default 3) successive actionable
challenges, since a server can legitimately respond to a submitted
credential with a *fresh* 402 — a different challenge, not success or
a final failure (e.g. the original challenge expired mid-flight, or
the credential was rejected but the server is willing to reissue).
Against such a server, the Go transport recovered from zero of those
retries and returned the second 402 to the caller unpaid, even though
canonical mppx would have paid successfully.

Wraps the existing single-attempt logic in a loop bounded by a new
defaultMaxPaymentRetries = 3 constant (matching mppx's default). Each
iteration re-parses WWW-Authenticate challenges from the latest
response, so a fresh challenge on retry N is picked up on iteration N.
The retry request is always cloned from the original req (not the
previous retry), so a GetBody-backed request body is re-read fresh
each attempt instead of chaining clones of clones.

Behavior for a single 402 (the common case) is unchanged. A server
that never accepts still gets bounded by the retry limit rather than
looping forever, and a non-actionable 402 (no matching method) still
returns immediately as before.

Adds:
- TestTransport_RoundTrip_RetriesAcrossMultiple402s: a server that
  rejects the first credential with a second, different challenge
  succeeds on the third request, and CreateCredential is called once
  per distinct challenge (not reusing a stale one).
- TestTransport_RoundTrip_GivesUpAfterMaxRetries: a server that always
  returns 402 is called exactly 1+defaultMaxPaymentRetries times, then
  the transport returns that last 402 rather than looping forever.

Ran ./pkg/client/... locally (go test -v and go vet), all tests pass
including the existing suite — no regressions.

Fixes tempoxyz/mpp-tools#148 (AGR-2026-052)
@ygd58

ygd58 commented Sep 19, 2026

Copy link
Copy Markdown
Author

Closing this — #140 ("align payment challenge retry behavior", merged as 705a261) already implements the same fix, and goes further: it also adds credential caching keyed by challenge id + method/intent, so a repeated challenge across retries doesn't re-create a credential unnecessarily. defaultMaxPaymentRetries = 3 and the retry loop are both already on main. No need to carry a duplicate implementation forward — thanks for landing this.

@ygd58 ygd58 closed this Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Agricola] AGR-2026-052: Client stops after one payment attempt

1 participant