From 6a30e11ee5a7f080b90a9f8134548b25b45dbaea Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Wed, 11 Mar 2026 17:33:54 +0800 Subject: [PATCH] feat: add x402 payment retry logic and extend retryable status codes Add retry loop (up to 3 attempts with exponential backoff) for 5xx server errors on payment-signed x402 requests, reusing the same payment signature to avoid double-charges. Also add 502/503/520/524 to the retryable error patterns in the MCP client. --- mcp/client.go | 4 +++ mcp/x402.go | 85 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 5b914965..72f01782 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -32,6 +32,10 @@ var ( "no such host", "stream error", // HTTP/2 stream error "INTERNAL_ERROR", // Server internal error + "status 502", // Bad Gateway + "status 503", // Service Unavailable + "status 520", // Cloudflare origin error + "status 524", // Cloudflare timeout } // TokenUsageCallback is called after each AI request with token usage info diff --git a/mcp/x402.go b/mcp/x402.go index 5debb8d1..8efdb6c0 100644 --- a/mcp/x402.go +++ b/mcp/x402.go @@ -8,10 +8,20 @@ import ( "fmt" "io" "net/http" + "time" "github.com/ethereum/go-ethereum/crypto" ) +const ( + // x402MaxPaymentRetries is the number of retries for 5xx errors on the + // payment-signed request. The same payment signature is reused (no double-charge). + x402MaxPaymentRetries = 3 + + // x402RetryBaseWait is the base wait between payment retry attempts. + x402RetryBaseWait = 3 * time.Second +) + // ── Shared x402 types ──────────────────────────────────────────────────────── // x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2). @@ -122,32 +132,63 @@ func doX402Request( return nil, fmt.Errorf("failed to sign x402 payment: %w", err) } - req2, err := buildReqFn() - if err != nil { - return nil, fmt.Errorf("failed to build retry request: %w", err) - } - req2.Header.Set("X-Payment", paymentSig) - req2.Header.Set("Payment-Signature", paymentSig) + // Retry loop for 5xx errors on the payment-signed request. + // Reuses the same payment signature — no double-charge. + var lastBody []byte + var lastStatus int + for attempt := 1; attempt <= x402MaxPaymentRetries; attempt++ { + req2, err := buildReqFn() + if err != nil { + return nil, fmt.Errorf("failed to build retry request: %w", err) + } + req2.Header.Set("X-Payment", paymentSig) + req2.Header.Set("Payment-Signature", paymentSig) - resp2, err := httpClient.Do(req2) - if err != nil { - return nil, fmt.Errorf("failed to send payment retry: %w", err) - } - defer resp2.Body.Close() + resp2, err := httpClient.Do(req2) + if err != nil { + if attempt < x402MaxPaymentRetries { + wait := x402RetryBaseWait * time.Duration(attempt) + logger.Warnf("⚠️ [%s] Payment request failed: %v, retrying in %v (%d/%d)...", + providerTag, err, wait, attempt+1, x402MaxPaymentRetries) + time.Sleep(wait) + continue + } + return nil, fmt.Errorf("failed to send payment retry: %w", err) + } - body2, err := io.ReadAll(resp2.Body) - if err != nil { - return nil, fmt.Errorf("failed to read payment retry response: %w", err) - } - if resp2.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2)) + body2, readErr := io.ReadAll(resp2.Body) + resp2.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("failed to read payment retry response: %w", readErr) + } + + if resp2.StatusCode == http.StatusOK { + if txHash := resp2.Header.Get("Payment-Response"); txHash != "" { + logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash) + } + if attempt > 1 { + logger.Infof("✅ [%s] Payment retry succeeded on attempt %d", providerTag, attempt) + } + return body2, nil + } + + lastBody = body2 + lastStatus = resp2.StatusCode + + // Retry on 5xx server errors (502, 503, 520, etc.) + if resp2.StatusCode >= 500 && attempt < x402MaxPaymentRetries { + wait := x402RetryBaseWait * time.Duration(attempt) + logger.Warnf("⚠️ [%s] Server error (status %d), retrying in %v (%d/%d)...", + providerTag, resp2.StatusCode, wait, attempt+1, x402MaxPaymentRetries) + time.Sleep(wait) + continue + } + + // Non-5xx error or final attempt — fail + break } - if txHash := resp2.Header.Get("Payment-Response"); txHash != "" { - logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash) - } - - return body2, nil + return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, lastStatus, string(lastBody)) } body, err := io.ReadAll(resp.Body)