mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 01:06: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",
|
"no such host",
|
||||||
"stream error", // HTTP/2 stream error
|
"stream error", // HTTP/2 stream error
|
||||||
"INTERNAL_ERROR", // Server internal 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
|
// 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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/crypto"
|
"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 ────────────────────────────────────────────────────────
|
// ── Shared x402 types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
|
// 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)
|
return nil, fmt.Errorf("failed to sign x402 payment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req2, err := buildReqFn()
|
// Retry loop for 5xx errors on the payment-signed request.
|
||||||
if err != nil {
|
// Reuses the same payment signature — no double-charge.
|
||||||
return nil, fmt.Errorf("failed to build retry request: %w", err)
|
var lastBody []byte
|
||||||
}
|
var lastStatus int
|
||||||
req2.Header.Set("X-Payment", paymentSig)
|
for attempt := 1; attempt <= x402MaxPaymentRetries; attempt++ {
|
||||||
req2.Header.Set("Payment-Signature", paymentSig)
|
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)
|
resp2, err := httpClient.Do(req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to send payment retry: %w", err)
|
if attempt < x402MaxPaymentRetries {
|
||||||
}
|
wait := x402RetryBaseWait * time.Duration(attempt)
|
||||||
defer resp2.Body.Close()
|
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)
|
body2, readErr := io.ReadAll(resp2.Body)
|
||||||
if err != nil {
|
resp2.Body.Close()
|
||||||
return nil, fmt.Errorf("failed to read payment retry response: %w", err)
|
if readErr != nil {
|
||||||
}
|
return nil, fmt.Errorf("failed to read payment retry response: %w", readErr)
|
||||||
if resp2.StatusCode != http.StatusOK {
|
}
|
||||||
return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2))
|
|
||||||
|
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 != "" {
|
return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, lastStatus, string(lastBody))
|
||||||
logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
return body2, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
|||||||
Reference in New Issue
Block a user