aws-samples / aws-samples/sample-agentic-serverless-payments

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

Open
#45 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
9
Forks
4
PR merge metrics
No merged PRs in 30d

Description

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.

```python
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:

```python
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](https://github.com/x402-foundation/x402/issues/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](https://github.com/x402-foundation/x402/pull/3437) proposes the receiver-side obligation, and [#3438](https://github.com/x402-foundation/x402/issues/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.

Contributor guide

Open the contributing guide

Research direction

Start in payment-agent/tools.py at _pay_and_retry around line 285, then trace its sole call site around line 574 and the README contract for handling HTTP 402 responses. Define a result that distinguishes an unresolved payment from an unpaid response, preserves payment identity across one logical purchase, and add tests showing that an exhausted retry cannot trigger a second payment.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, payments
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.