Skip to content

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

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

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

Conversation

@ygd58

@ygd58 ygd58 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What

Transport#request 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 Ruby 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-063. I already fixed the identical pattern in tempoxyz/mpp-go (PR #118) — same root cause, same fix shape, different language.

Fix

Wraps the existing single-attempt body in a loop bounded by a new Transport::MAX_PAYMENT_RETRIES = 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. All event emissions (challenge.received, credential.created, payment.response, payment.failed) are preserved exactly as before, just re-emitted per iteration instead of once.

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, unparseable/expired challenge) still breaks out of the loop and returns immediately as before.

Testing

Added two regression tests in test/mpp/test_client.rb:

  • test_retries_across_multiple_402_challenges: a server that rejects the first credential with a second, different challenge succeeds on the third request (3 total HTTP requests, verified via assert_requested).
  • test_gives_up_after_max_payment_retries: a server that always returns 402 is called exactly 1+MAX_PAYMENT_RETRIES times, then the transport returns that last 402 rather than looping forever.

I could not run the test suite in my sandbox — no access to rubygems.org to install bundler/webmock/minitest — but ruby -c confirms both files parse cleanly, and I reviewed the diff carefully: outside the loop restructuring and a payment_response → response rename, the only semantic changes are return → break at the three early-exit points. Please run bundle exec rake test before merging, happy to fix anything that doesn't match.

Fixes tempoxyz/mpp-tools#174

Transport#request 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 Ruby 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 body in a loop bounded by a new
Transport::MAX_PAYMENT_RETRIES = 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.
All event emissions (challenge.received, credential.created,
payment.response, payment.failed) are preserved exactly as before,
just re-emitted per iteration instead of once. Rebased onto main's
custom-header support (challenge.credential_header, from a change
landed after this branch was first opened) -- each iteration re-reads
credential_header from its own iteration's challenge, so a header
choice that changes between challenges is respected on every retry.

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,
unparseable/expired challenge) still breaks out of the loop and
returns immediately as before.

Adds two regression tests in test/mpp/test_client.rb:
- test_retries_across_multiple_402_challenges: a server that rejects
  the first credential with a second, different challenge succeeds on
  the third request (3 total HTTP requests).
- test_gives_up_after_max_payment_retries: a server that always
  returns 402 is called exactly 1+MAX_PAYMENT_RETRIES times, then the
  transport returns that last 402 rather than looping forever.

I could not run the test suite in my sandbox -- no access to
rubygems.org to install bundler/webmock/minitest -- but `ruby -c`
confirms both files parse cleanly. Please run `bundle exec rake test`
before merging.

Fixes tempoxyz/mpp-tools#174
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-063: Client stops after the first payment retry

1 participant