Summary
@elytro/cli 0.8.5 added retries for some transient x402 EIP-3009 facilitator failures, but retries do not trigger for a common real-world failure mode where the facilitator returns:
I was able to reproduce this consistently against twit.sh and locally patch the installed CLI to make the same flow succeed much more reliably.
Environment
- CLI version:
0.8.5
- Chain: Base (
8453)
- Payment method:
EIP-3009
- Account: deployed smart account
- Test endpoint:
https://x402.twit.sh/tweets/by/id?id=20
Reproduction
Dry-run succeeds:
elytro request --account fair-yarn --dry-run 'https://x402.twit.sh/tweets/by/id?id=20'
Real paid request often fails:
elytro request --account fair-yarn --verbose 'https://x402.twit.sh/tweets/by/id?id=20'
Observed failure:
{
"success": false,
"error": {
"code": -32005,
"message": "Payment rejected by facilitator",
"data": {
"method": "eip3009",
"initialStatus": 402,
"finalStatus": 402,
"facilitatorResponse": {}
}
}
}
Verbose logs show the final payment response is often just:
{
"status": 402,
"body": "{}"
}
Why 0.8.5 doesn't catch this
Current retry logic only retries 402 bodies containing certain strings:
facilitator returned 400 bad request with no error
facilitator validation failed
invalid payment
But the failing service above often returns an empty body ({}), so retries never trigger.
Local patch that improved the behavior
I locally patched the installed dist/index.js with this idea:
- Treat empty/generic
402 bodies like {} as transient.
- Retry more aggressively for idempotent requests (
GET, HEAD, OPTIONS).
- Log each payment response in verbose mode so it's obvious what the facilitator returned.
Suggested diff
@@
- const maxRetries = 3;
- const retryBaseDelayMs = 1e3;
+ const isIdempotentRequest = ["GET", "HEAD", "OPTIONS"].includes(options.method.toUpperCase());
+ const maxRetries = isIdempotentRequest ? 9 : 3;
+ const retryBaseDelayMs = 500;
+ const maxRetryDelayMs = 4e3;
@@
const finalBody = await finalResponse.text();
const settlementHeader = finalResponse.headers.get(X402_HEADERS.PAYMENT_RESPONSE);
const settlement = settlementHeader ? this.decodeSettlement(settlementHeader) : null;
+ if (options.verbose) {
+ this.logDebug("Payment response", {
+ attempt: attempt + 1,
+ status: finalResponse.status,
+ settlement,
+ body: finalBody.length > 500 ? `${finalBody.slice(0, 500)}…` : finalBody
+ });
+ }
@@
- const delayMs = retryBaseDelayMs * 2 ** attempt;
+ const delayMs = Math.min(retryBaseDelayMs * 2 ** attempt, maxRetryDelayMs);
if (options.verbose) {
this.logDebug("Retry", {
reason: "Transient facilitator rejection",
attempt: attempt + 1,
maxRetries,
- delayMs
+ delayMs,
+ idempotentRequest: isIdempotentRequest
});
}
@@
isTransientFacilitatorFailure(finalStatus, body) {
if (finalStatus >= 500) return true;
- if (finalStatus === 402) {
- const normalized = body.toLowerCase();
- return normalized.includes("facilitator returned 400 bad request with no error") || normalized.includes("facilitator validation failed") || normalized.includes("invalid payment");
- }
- return false;
+ if (finalStatus !== 402) return false;
+ const normalized = (body ?? "").trim().toLowerCase();
+ if (!normalized || normalized === "{}") return true;
+ return normalized.includes("facilitator returned 400 bad request with no error") || normalized.includes("facilitator validation failed") || normalized.includes("invalid payment") || normalized.includes("payment rejected by facilitator") || normalized.includes("payment required") || normalized.includes("bad request with no error");
}
End-to-end results
Before local patch
10 attempts with --verbose:
10/10 failed
0 retry logs observed
- no USDC spent in that batch
After local patch
Same endpoint, 10 attempts with --verbose:
9/10 succeeded
51 retry logs observed total
- successful calls returned the expected tweet payload (
"just setting up my twttr" for tweet id=20)
- the one remaining failure exhausted retries and still ended in
402
So this doesn't make the upstream service perfect, but it does turn a usually-failing GET x402 flow into a mostly-successful one.
Suggestion
At minimum, I think isTransientFacilitatorFailure() should consider empty/generic 402 bodies retryable.
Potentially also:
- allow more retries for idempotent methods
- keep current behavior for mutating requests conservative
- keep the extra verbose logging because it's very helpful for diagnosing facilitator behavior
If useful, I can turn this into a cleaner source-level PR instead of a local dist patch.
Summary
@elytro/cli0.8.5added retries for some transient x402 EIP-3009 facilitator failures, but retries do not trigger for a common real-world failure mode where the facilitator returns:402{}I was able to reproduce this consistently against
twit.shand locally patch the installed CLI to make the same flow succeed much more reliably.Environment
0.8.58453)EIP-3009https://x402.twit.sh/tweets/by/id?id=20Reproduction
Dry-run succeeds:
elytro request --account fair-yarn --dry-run 'https://x402.twit.sh/tweets/by/id?id=20'Real paid request often fails:
elytro request --account fair-yarn --verbose 'https://x402.twit.sh/tweets/by/id?id=20'Observed failure:
{ "success": false, "error": { "code": -32005, "message": "Payment rejected by facilitator", "data": { "method": "eip3009", "initialStatus": 402, "finalStatus": 402, "facilitatorResponse": {} } } }Verbose logs show the final payment response is often just:
{ "status": 402, "body": "{}" }Why
0.8.5doesn't catch thisCurrent retry logic only retries
402bodies containing certain strings:facilitator returned 400 bad request with no errorfacilitator validation failedinvalid paymentBut the failing service above often returns an empty body (
{}), so retries never trigger.Local patch that improved the behavior
I locally patched the installed
dist/index.jswith this idea:402bodies like{}as transient.GET,HEAD,OPTIONS).Suggested diff
End-to-end results
Before local patch
10 attempts with
--verbose:10/10failed0retry logs observedAfter local patch
Same endpoint, 10 attempts with
--verbose:9/10succeeded51retry logs observed total"just setting up my twttr"for tweetid=20)402So this doesn't make the upstream service perfect, but it does turn a usually-failing GET x402 flow into a mostly-successful one.
Suggestion
At minimum, I think
isTransientFacilitatorFailure()should consider empty/generic402bodies retryable.Potentially also:
If useful, I can turn this into a cleaner source-level PR instead of a local
distpatch.