Skip to content

_pay_and_retry exhausts its backoff and returns a bare 402 after ProcessPayment has already run — the caller cannot tell that from 'not paid' #45

Description

@aurumflux20

Reading this one with a single question — what does a buyer see when the payment lands and the answer does not come back? — and the first thing to say is that the retry loop already gets right what almost nobody does.

retry_headers["X-PAYMENT"] = encoded_sig        # signed ONCE, outside the loop
backoffs = [2, 3, 5, 8, 10]
for attempt in range(max_attempts):
    resp = _send(retry_headers)                  # the SAME header, re-presented
    if resp.status_code != 402: break
    _time.sleep(backoffs[attempt])

payment-agent/tools.py, _pay_and_retry (:285)

The signature is built once and re-presented rather than re-minted, and the comment names exactly why — "the previous on-chain tx may still be settling and the facilitator rejects until the nonce clears." Most x402 clients I have read call their payment builder again on each 402, mint a fresh nonce, and pay a second time. This one does not, and the reasoning is written down. Worth saying before the report.

The report: what the function returns when the loop gives up

After five attempts and ~28 seconds, if the seller is still answering 402:

    return resp     # a 402

ProcessPayment has already run. Money has, by the function's own comment, quite possibly moved. But the value handed back is a bare 402 — structurally identical to the 402 that means "you have not paid", and it carries nothing that says a payment was attempted: no transaction hash, no payment id, no flag.

The single call site (:574) passes it straight through as ("response", resp), and the consumer above that is a language model whose entire documented contract for a 402 is pay. The README describes the plugin path as intercepting each HTTP 402 and running ProcessPayment automatically. So the exhausted-retry return value is a 402 arriving at a component that answers 402 by paying — and the second _pay_and_retry invocation builds a new signature, because encoded_sig is local to the call.

The re-presentation discipline is correct within one invocation and absent across invocations, and the boundary between them is a 28-second timeout — which is exactly the window a slow settlement occupies.

Why the timeout does not bound it

The backoff totals 28s. An EIP-3009 authorization is presentable until validBefore; a Solana blockhash is good for roughly two minutes. So the first payment can still land after the loop has given up, while a second is being signed. Two valid authorizations, two distinct replay identities, one purchase — and every guard behaves correctly, because at the protocol boundary they genuinely are two payments.

What would close it

  1. Do not return a bare 402 after ProcessPayment has run. Return something the caller cannot confuse with "unpaid" — an exception, or a result carrying the payment id and an explicit unresolved status. The rule a production x402 operator arrived at independently last week states it well: "a receiver that cannot determine a settlement outcome has no business naming a price for it" (x402-foundation/x402#3438). The same applies to a client library reporting to its own caller.
  2. Carry the signature across invocations for one logical purchase, keyed on the request, so a retry at the agent level re-presents rather than re-signs. The in-loop discipline is already the right shape; it just needs to outlive the function.
  3. Say in the tool result that a payment was attempted, so the model has something truthful to tell the user other than "payment required".

On scope

Your disclaimer is explicit and I am not treating this as a production claim — it says plainly this is not production-ready, runs on test networks, and needs a reliability review before real use. This is filed because the retry shape is the part a reader is most likely to copy, and because the surrounding code shows this failure mode was already understood: the in-loop re-presentation and its comment are the evidence.

Related upstream: #3437 proposes the receiver-side obligation, and #3438 covers the client half — that a payer's intent to retry is not expressible in the bytes, so re-signing has to be made harmless rather than assumed away.

The battery I use is MIT and ships with a mutation control, so it can be watched failing on known-broken input before a pass is trusted: https://github.com/aurumflux20/hostile-facilitator/releases/tag/v0.1.2 — it counts distinct settlements at the facilitator rather than comparing response bodies. Nothing needed from me, and nothing to buy.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions