mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 12:00:59 +08:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
85
mcp/x402.go
85
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)
|
||||
|
||||
Reference in New Issue
Block a user