Skip to content

x402 EIP-3009 retries do not trigger on empty 402 facilitator responses (e.g. {}) #3

@zengjiajun0623

Description

@zengjiajun0623

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:

  • HTTP 402
  • body = {}

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:

  1. Treat empty/generic 402 bodies like {} as transient.
  2. Retry more aggressively for idempotent requests (GET, HEAD, OPTIONS).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions