From c5a2c57df4a1f91084eb4c1d20490ee7a157149d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Thu, 2 Jul 2026 02:44:24 +0100 Subject: [PATCH] feat(x402): tolerant chat-path gateway, structured payment errors, funnel metrics - HandleProxy rewrites bare POST /services/ (and /chat/completions, /v1) to /v1/chat/completions for agent/inference offers, so the most common wrong-path mistake from external buyers succeeds instead of paying into a 404; the 402 page's agent copy taught exactly that bare form until this change - terminal payment failures return structured JSON {error, reason, hint, retriable}; the facilitator's invalidReason (previously discarded) now rides the re-issued 402 challenge in error + extensions.paymentFailure, and signature rejections state the expected EIP-712 domain - legacy error phrases kept verbatim (flows/lib.sh greps for them) - new funnel metrics: payment_failure_reasons_total{reason} (bounded 6-value set) and upstream_failed_after_verify_total, so first-try buyer success is measurable per stage; docs/observability.md updated Co-Authored-By: Claude Fable 5 --- docs/observability.md | 12 +- internal/x402/forwardauth.go | 176 ++++++++++++++++++++++++++-- internal/x402/forwardauth_test.go | 183 ++++++++++++++++++++++++++++++ internal/x402/metrics.go | 33 ++++++ internal/x402/verifier.go | 52 +++++++++ internal/x402/verifier_test.go | 89 ++++++++++++++- 6 files changed, 535 insertions(+), 10 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index a3728aec..e606a941 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -386,7 +386,17 @@ contributors: if you write a guarded division, the epsilon is `1e-9`. - `internal/x402/metrics.go` — verifier metric definitions (`obol_x402_verifier_requests_total`, `_payment_required_total`, - `_payment_verified_total`, `_payment_failed_total`, `_charged_requests_total`). + `_payment_verified_total`, `_payment_failed_total`, `_charged_requests_total`, + `_payment_failure_reasons_total`, `_upstream_failed_after_verify_total`). + `_payment_failure_reasons_total` facets failures by a bounded `reason` + label (`invalid_payment_header`, `no_matching_requirement`, + `facilitator_unreachable`, `payment_invalid`, `settlement_failed`, + `settlement_rejected` — the set enumerated in + `internal/x402/forwardauth.go`), turning "the buy funnel leaks" into + "this stage eats the buyers". `_upstream_failed_after_verify_total` + counts paid requests bounced by the seller's own upstream after the + payment verified (never settled) — a seller-side problem, not a + payment-flow one. - `internal/x402/verifier.go` — `prometheusLabels()` controls the verifier label set; this is the canonical place to add a new bounded label. - `internal/x402/buyer/metrics.go` — buyer-side counters diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go index e2ec9170..a7e97abb 100644 --- a/internal/x402/forwardauth.go +++ b/internal/x402/forwardauth.go @@ -11,6 +11,7 @@ import ( "log" "net" "net/http" + "strings" "time" x402types "github.com/x402-foundation/x402/go/v2/types" @@ -55,6 +56,12 @@ type ForwardAuthConfig struct { // the offer advertises a single option. OnPaymentMatched func(x402types.PaymentRequirements) + // OnPaymentFailure, if non-nil, is invoked once per payment-flow failure + // with the machine-readable reason (the same string written into the + // response body / extensions.paymentFailure). Lets the caller attribute + // funnel-leak metrics per failure stage. + OnPaymentFailure func(reason string) + // SettlesInProcess marks the in-process seller-gateway path (HandleProxy / // obol sell inference) where VerifyOnly=false is correct BY DESIGN — the // middleware proxies to the real upstream and settles only after a <400 @@ -99,6 +106,79 @@ var ( facilitatorSettleTimeout = 60 * time.Second ) +// paymentErrorBody is the structured JSON body written on terminal +// payment-flow failures (malformed header, facilitator unreachable, +// settlement error). Buying agents retry blind when a failure is an opaque +// plain-text line; giving them a machine-readable reason plus a +// next-action hint converts a dead retry loop into a self-correcting one. +// The `error` field keeps the exact legacy phrases ("Invalid payment +// header", "Payment verification failed", "Payment settlement failed") so +// existing greps and log matchers keep working. +type paymentErrorBody struct { + Error string `json:"error"` + Reason string `json:"reason"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` + Retriable bool `json:"retriable"` +} + +// writePaymentError emits a structured JSON error. Headers already set on w +// (e.g. X-PAYMENT-RESPONSE with a settle tx hash) are preserved. +func writePaymentError(w http.ResponseWriter, status int, body paymentErrorBody) { + payload, err := json.Marshal(body) + if err != nil { + http.Error(w, body.Error, status) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(payload) + _, _ = w.Write([]byte("\n")) +} + +// paymentFailure carries the facilitator's rejection detail from the +// middleware to the 402 renderer. The x402 contract on an invalid payment is +// to re-issue the full PaymentRequired challenge (so the buyer can re-probe +// and re-sign); without this the facilitator's invalidReason was logged +// server-side and the buyer saw only the generic challenge — no way to tell +// a wrong-domain signature from an expired auth. +type paymentFailure struct { + Reason string // machine-readable, e.g. "payment_invalid", "settlement_rejected" + Detail string // facilitator invalidReason/invalidMessage or errorReason + Hint string // buyer's next action +} + +type paymentFailureCtxKey struct{} + +func withPaymentFailure(r *http.Request, f paymentFailure) *http.Request { + return r.WithContext(context.WithValue(r.Context(), paymentFailureCtxKey{}, f)) +} + +func paymentFailureFrom(r *http.Request) (paymentFailure, bool) { + f, ok := r.Context().Value(paymentFailureCtxKey{}).(paymentFailure) + return f, ok +} + +// signatureFailureHint returns a targeted hint when the facilitator rejection +// looks like a signature problem. The #1 silent killer for external buyers is +// signing the wrong EIP-712 domain for the asset; the seller is the only +// party that knows the right answer, so say it in the response instead of +// making the buyer guess. +func signatureFailureHint(detail string, req x402types.PaymentRequirements) string { + if !strings.Contains(strings.ToLower(detail), "signature") { + return "" + } + name, _ := req.Extra["name"].(string) + version, _ := req.Extra["version"].(string) + if name == "" && version == "" { + return "signature rejected — re-sign using the EIP-712 domain advertised in accepts[].extra for this asset" + } + return fmt.Sprintf( + "signature rejected — sign the EIP-712 domain advertised in accepts[].extra (name=%q version=%q) for asset %s on %s", + name, version, req.Asset, req.Network, + ) +} + // NewForwardAuthMiddleware creates an x402 payment-gating middleware compatible // with the v1 wire format. It checks the X-PAYMENT header, verifies the payment // with the facilitator, and optionally settles after a successful downstream @@ -125,6 +205,10 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa if send == nil { send = sendPaymentRequiredJSON } + reportFailure := cfg.OnPaymentFailure + if reportFailure == nil { + reportFailure = func(string) {} + } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -138,7 +222,12 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa payloadBytes, err := base64.StdEncoding.DecodeString(paymentHeader) if err != nil { log.Printf("x402: invalid X-PAYMENT base64: %v", err) - http.Error(w, "Invalid payment header", http.StatusBadRequest) + reportFailure("invalid_payment_header") + writePaymentError(w, http.StatusBadRequest, paymentErrorBody{ + Error: "Invalid payment header", + Reason: "invalid_payment_header", + Hint: "X-PAYMENT must be the base64-encoded x402 PaymentPayload JSON — re-encode and retry the identical request", + }) return } @@ -146,13 +235,23 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa var payload x402types.PaymentPayload if err := json.Unmarshal(payloadBytes, &payload); err != nil { log.Printf("x402: invalid payment JSON: %v", err) - http.Error(w, "Invalid payment header", http.StatusBadRequest) + reportFailure("invalid_payment_header") + writePaymentError(w, http.StatusBadRequest, paymentErrorBody{ + Error: "Invalid payment header", + Reason: "invalid_payment_header", + Hint: "X-PAYMENT decoded but is not valid PaymentPayload JSON — re-fetch the 402 requirements and re-sign", + }) return } matchedReq, found := findMatchingRequirementV1(payload, requirements) if !found { - send(w, r, requirements, cfg.Extensions) + reportFailure("no_matching_requirement") + send(w, withPaymentFailure(r, paymentFailure{ + Reason: "no_matching_requirement", + Detail: fmt.Sprintf("payment offered scheme=%q network=%q, which matches none of the accepts[] entries", payload.Accepted.Scheme, payload.Accepted.Network), + Hint: "sign against one accepts[] entry verbatim — scheme and network must match exactly", + }), requirements, cfg.Extensions) return } if cfg.OnPaymentMatched != nil { @@ -163,13 +262,26 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa verifyResp, err := facilitatorVerify(r.Context(), verifyClient, cfg.FacilitatorURL, payloadBytes, matchedReq) if err != nil { log.Printf("x402: facilitator verify error: %v", err) - http.Error(w, "Payment verification failed", http.StatusServiceUnavailable) + reportFailure("facilitator_unreachable") + writePaymentError(w, http.StatusServiceUnavailable, paymentErrorBody{ + Error: "Payment verification failed", + Reason: "facilitator_unreachable", + Hint: "transient facilitator error — retry the identical request in a few seconds; the payment authorization was not consumed", + Retriable: true, + }) return } if !verifyResp.IsValid { log.Printf("x402: payment invalid: %s", verifyResp.InvalidReason) - send(w, r, requirements, cfg.Extensions) + detail := strings.TrimSpace(strings.TrimSpace(verifyResp.InvalidReason) + " " + strings.TrimSpace(verifyResp.InvalidMessage)) + hint := signatureFailureHint(detail, matchedReq) + reportFailure("payment_invalid") + send(w, withPaymentFailure(r, paymentFailure{ + Reason: "payment_invalid", + Detail: detail, + Hint: hint, + }), requirements, cfg.Extensions) return } @@ -193,19 +305,37 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa // before erroring so the buyer (or operator) can // reconcile against the chain. The header has to land // before http.Error commits the status code. + settledOnChain := false if settleResp != nil && settleResp.Transaction != "" { + settledOnChain = true settleJSON, _ := json.Marshal(settleResp) w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON)) log.Printf("x402: facilitator returned tx %s with the error — verify on-chain (network=%s payer=%s)", settleResp.Transaction, settleResp.Network, settleResp.Payer) } - http.Error(w, "Payment settlement failed", http.StatusServiceUnavailable) + reportFailure("settlement_failed") + hint := "transient facilitator error — retry the same request in a few seconds" + if settledOnChain { + hint = "the settle tx in X-PAYMENT-RESPONSE may have landed on-chain — verify against the chain before retrying, or you may pay twice" + } + writePaymentError(w, http.StatusServiceUnavailable, paymentErrorBody{ + Error: "Payment settlement failed", + Reason: "settlement_failed", + Hint: hint, + Retriable: !settledOnChain, + }) return false } if !settleResp.Success { log.Printf("x402: settlement unsuccessful: %s", settleResp.ErrorReason) - send(w, r, requirements, cfg.Extensions) + reportFailure("settlement_rejected") + detail := strings.TrimSpace(strings.TrimSpace(settleResp.ErrorReason) + " " + strings.TrimSpace(settleResp.ErrorMessage)) + send(w, withPaymentFailure(r, paymentFailure{ + Reason: "settlement_rejected", + Detail: detail, + Hint: signatureFailureHint(detail, matchedReq), + }), requirements, cfg.Extensions) return false } @@ -248,9 +378,39 @@ func sendPaymentRequiredJSON(w http.ResponseWriter, r *http.Request, requirement // block (serviceName/iconUrl — see specs/extensions/bazaar.md, soft-drop // rules apply facilitator-side). func buildPaymentRequired(r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) x402types.PaymentRequired { + errMsg := "Payment required for this resource" + + // When the middleware rejected an attempted payment, say WHY in the + // re-issued challenge. The buyer already holds these requirements; the + // only new information that helps them succeed on the retry is the + // rejection reason and the corrective hint. A machine-readable copy + // rides in extensions.paymentFailure for agents. + if failure, ok := paymentFailureFrom(r); ok { + errMsg = "Payment invalid" + if failure.Detail != "" { + errMsg += ": " + failure.Detail + } + if failure.Hint != "" { + errMsg += " — " + failure.Hint + } + failureExt := map[string]any{"reason": failure.Reason} + if failure.Detail != "" { + failureExt["detail"] = failure.Detail + } + if failure.Hint != "" { + failureExt["hint"] = failure.Hint + } + merged := make(map[string]any, len(extensions)+1) + for k, v := range extensions { + merged[k] = v + } + merged["paymentFailure"] = failureExt + extensions = merged + } + return x402types.PaymentRequired{ X402Version: 2, - Error: "Payment required for this resource", + Error: errMsg, Resource: &x402types.ResourceInfo{ URL: buildResourceURL(r), Description: "Payment required for " + r.URL.Path, diff --git a/internal/x402/forwardauth_test.go b/internal/x402/forwardauth_test.go index ae366935..3a52c620 100644 --- a/internal/x402/forwardauth_test.go +++ b/internal/x402/forwardauth_test.go @@ -186,6 +186,189 @@ func TestForwardAuth_InvalidPayment_Returns402(t *testing.T) { } } +// TestForwardAuth_MalformedPaymentHeader_StructuredJSON pins the structured +// error contract on the 400 path: an agent that mangles the base64 must get a +// machine-readable reason and a corrective hint, not an opaque text line. +func TestForwardAuth_MalformedPaymentHeader_StructuredJSON(t *testing.T) { + var verifyCalled, settleCalled atomic.Int32 + fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) + defer fac.Close() + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, testRequirements()) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called with a malformed header") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", "%%%not-base64%%%") + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + var body paymentErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body is not JSON: %v (body %q)", err, rec.Body.String()) + } + if body.Error != "Invalid payment header" { + t.Errorf("error = %q, want the stable legacy phrase", body.Error) + } + if body.Reason != "invalid_payment_header" { + t.Errorf("reason = %q, want invalid_payment_header", body.Reason) + } + if body.Hint == "" { + t.Error("hint must tell the buyer what to do next") + } + if body.Retriable { + t.Error("a malformed header is not retriable as-is") + } +} + +// TestForwardAuth_InvalidPayment_402CarriesFailureDetail pins the enriched +// re-issued challenge: when the facilitator rejects a payment, the 402 body +// must say why (error field) and carry a machine-readable copy in +// extensions.paymentFailure — the buyer already has the requirements; the +// rejection reason is the only new information that makes the retry succeed. +func TestForwardAuth_InvalidPayment_402CarriesFailureDetail(t *testing.T) { + var verifyCalled, settleCalled atomic.Int32 + fac := mockFacilitatorV1(false, true, &verifyCalled, &settleCalled) + defer fac.Close() + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, testRequirements()) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called for invalid payment") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", validPaymentHeader()) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", rec.Code) + } + var parsed x402types.PaymentRequired + if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil { + t.Fatalf("402 body is not PaymentRequired JSON: %v", err) + } + if !strings.Contains(parsed.Error, "test_invalid") { + t.Errorf("402 error = %q, must include the facilitator's invalidReason", parsed.Error) + } + failure, ok := parsed.Extensions["paymentFailure"].(map[string]any) + if !ok { + t.Fatalf("extensions.paymentFailure missing: %#v", parsed.Extensions) + } + if failure["reason"] != "payment_invalid" { + t.Errorf("paymentFailure.reason = %v, want payment_invalid", failure["reason"]) + } + if len(parsed.Accepts) == 0 { + t.Error("the re-issued challenge must still carry accepts[] so the buyer can re-sign") + } +} + +// TestForwardAuth_SignatureRejection_HintsEIP712Domain pins the targeted +// signature hint: when the facilitator rejection mentions "signature", the +// seller must state the EIP-712 domain the buyer should have signed — +// wrong-domain signing is the top silent killer for external buyers and the +// seller is the only party that knows the right answer. +func TestForwardAuth_SignatureRejection_HintsEIP712Domain(t *testing.T) { + fac := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(facilitatorVerifyResponse{ + IsValid: false, + InvalidReason: "invalid_exact_evm_payload_signature", + InvalidMessage: "FiatTokenV2: invalid signature", + }) + })) + defer fac.Close() + + reqs := testRequirements() + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, reqs) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", validPaymentHeader()) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", rec.Code) + } + var parsed x402types.PaymentRequired + if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil { + t.Fatalf("402 body is not PaymentRequired JSON: %v", err) + } + failure, ok := parsed.Extensions["paymentFailure"].(map[string]any) + if !ok { + t.Fatalf("extensions.paymentFailure missing: %#v", parsed.Extensions) + } + hint, _ := failure["hint"].(string) + if !strings.Contains(hint, "EIP-712") { + t.Errorf("hint = %q, must name the EIP-712 domain to sign", hint) + } + if wantName, _ := reqs[0].Extra["name"].(string); wantName != "" && !strings.Contains(hint, wantName) { + t.Errorf("hint = %q, must include the domain name %q", hint, wantName) + } +} + +// TestForwardAuth_FacilitatorDown_StructuredRetriable503 pins the transient +// path: facilitator unreachable must produce a retriable JSON 503 so buying +// agents retry the identical request instead of re-signing (the auth was not +// consumed). +func TestForwardAuth_FacilitatorDown_StructuredRetriable503(t *testing.T) { + fac := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + fac.Close() // deliberately down + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, testRequirements()) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", validPaymentHeader()) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } + var body paymentErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body is not JSON: %v (body %q)", err, rec.Body.String()) + } + if body.Error != "Payment verification failed" { + t.Errorf("error = %q, want the stable legacy phrase (flows/lib.sh greps for it)", body.Error) + } + if body.Reason != "facilitator_unreachable" { + t.Errorf("reason = %q, want facilitator_unreachable", body.Reason) + } + if !body.Retriable { + t.Error("facilitator-down must be marked retriable") + } +} + func TestForwardAuth_SettleOnSuccess(t *testing.T) { var verifyCalled, settleCalled atomic.Int32 fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) diff --git a/internal/x402/metrics.go b/internal/x402/metrics.go index 2779d148..69c70c9f 100644 --- a/internal/x402/metrics.go +++ b/internal/x402/metrics.go @@ -16,6 +16,19 @@ type verifierMetrics struct { paymentFailed *prometheus.CounterVec chargedRequests *prometheus.CounterVec lastPaymentSuccess *prometheus.GaugeVec + + // paymentFailureReasons splits paymentFailed by WHY (payment_invalid, + // facilitator_unreachable, settlement_failed, ...). paymentFailed alone + // says the funnel leaks; the reason label says where to fix it — the + // difference between "first-try success is 20%" and knowing which stage + // eats the other 80%. + paymentFailureReasons *prometheus.CounterVec + + // upstreamFailedAfterVerify counts paid requests whose payment verified + // but whose upstream then returned an error (no settlement happens on + // this path). High values mean buyers are being bounced by the seller's + // own service, not by payments. + upstreamFailedAfterVerify *prometheus.CounterVec } func newVerifierMetrics() *verifierMetrics { @@ -63,6 +76,20 @@ func newVerifierMetrics() *verifierMetrics { }, []string{"offer_namespace", "offer_name", "chain", "asset_symbol"}, ), + paymentFailureReasons: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "obol_x402_verifier_payment_failure_reasons_total", + Help: "Payment-flow failures split by machine-readable reason (payment_invalid, facilitator_unreachable, settlement_failed, ...).", + }, + []string{"offer_namespace", "offer_name", "chain", "asset_symbol", "reason"}, + ), + upstreamFailedAfterVerify: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "obol_x402_verifier_upstream_failed_after_verify_total", + Help: "Paid requests whose x402 payment verified but whose upstream returned an error (not settled).", + }, + []string{"offer_namespace", "offer_name", "chain", "asset_symbol"}, + ), } m.registry.MustRegister( @@ -72,6 +99,8 @@ func newVerifierMetrics() *verifierMetrics { m.paymentFailed, m.chargedRequests, m.lastPaymentSuccess, + m.paymentFailureReasons, + m.upstreamFailedAfterVerify, ) return m @@ -104,6 +133,10 @@ func (m *verifierMetrics) pruneSeriesNotIn(keep map[string]struct{}) { m.paymentFailed, m.chargedRequests, m.lastPaymentSuccess, + // Partial match on the four shared labels also prunes the + // reason-labelled series. + m.paymentFailureReasons, + m.upstreamFailedAfterVerify, } gathered, err := m.registry.Gather() diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 258e6524..b4da4958 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -203,6 +203,9 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { Extensions: mr.extensions, SendPaymentRequired: NewHTMLAwarePaymentRequired(display), OnPaymentMatched: func(req x402types.PaymentRequirements) { matchedLabels = mr.labelsForMatched(req) }, + OnPaymentFailure: func(reason string) { + v.metrics.paymentFailureReasons.With(withReason(matchedLabels, reason)).Inc() + }, }, mr.requirements) upstreamAuth := mr.rule.UpstreamAuth @@ -255,6 +258,7 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { display := buildPaymentDisplay(mr.rule, mr.chain, mr.asset, primary.PayTo, primary.Amount) matchedLabels := primaryLabels + paymentFailed := false middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ FacilitatorURL: cfg.FacilitatorURL, // HandleProxy is the in-process seller gateway: it proxies to the real @@ -266,6 +270,10 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { Extensions: mr.extensions, SendPaymentRequired: NewHTMLAwarePaymentRequired(display), OnPaymentMatched: func(req x402types.PaymentRequirements) { matchedLabels = mr.labelsForMatched(req) }, + OnPaymentFailure: func(reason string) { + paymentFailed = true + v.metrics.paymentFailureReasons.With(withReason(matchedLabels, reason)).Inc() + }, }, mr.requirements) hadPayment := r.Header.Get("X-PAYMENT") != "" @@ -283,6 +291,10 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { v.metrics.chargedRequests.With(matchedLabels).Inc() v.metrics.lastPaymentSuccess.With(matchedLabels).SetToCurrentTime() } + case tracker.status >= http.StatusBadRequest && hadPayment && !paymentFailed: + // Payment verified, upstream errored, no settlement — the buyer was + // bounced by the seller's own service, not by the payment flow. + v.metrics.upstreamFailedAfterVerify.With(matchedLabels).Inc() } } @@ -332,6 +344,17 @@ type matchedRoute struct { labels prometheus.Labels } +// withReason copies a route's metric labels and adds the failure-stage +// reason label for the paymentFailureReasons counter. +func withReason(labels prometheus.Labels, reason string) prometheus.Labels { + out := make(prometheus.Labels, len(labels)+1) + for k, v := range labels { + out[k] = v + } + out["reason"] = reason + return out +} + // labelsForMatched returns the metric labels for the payment option the buyer // actually satisfied, matching by the same fields findMatchingRequirementV1 // uses. Falls back to the primary option's labels if no match is found. @@ -576,6 +599,7 @@ func buildUpstreamProxy(rule *RouteRule) (http.Handler, error) { Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(target) strippedPath := stripRoutePrefix(rule.StripPrefix, pr.In.URL.Path) + strippedPath = normalizeChatCompletionsPath(rule.OfferType, pr.In.Method, strippedPath) pr.Out.URL.Path = singleJoiningSlash(target.Path, strippedPath) pr.Out.URL.RawQuery = pr.In.URL.RawQuery pr.Out.Host = target.Host @@ -591,6 +615,34 @@ func buildUpstreamProxy(rule *RouteRule) (http.Handler, error) { return proxy, nil } +// chatCompletionsPath is the OpenAI-compatible path served by inference and +// agent upstreams (LiteLLM, Hermes). +const chatCompletionsPath = "/v1/chat/completions" + +// normalizeChatCompletionsPath forgives the common wrong-path shapes buyers +// send to chat-completions offers. External x402 clients (and the prompts on +// older 402 pages) frequently POST to the bare service base or to +// /chat/completions; the upstream only serves /v1/chat/completions, so a +// verified, paid request would otherwise 404. For inference/agent offers the +// tolerated shapes are rewritten to the canonical path; every other sub-path +// (e.g. /v1/embeddings) passes through untouched, as do all non-POST methods +// and non-chat offer types. +func normalizeChatCompletionsPath(offerType, method, stripped string) string { + if method != http.MethodPost { + return stripped + } + switch offerType { + case "inference", "agent": + default: + return stripped + } + switch strings.TrimSuffix(stripped, "/") { + case "", "/v1", "/chat/completions": + return chatCompletionsPath + } + return stripped +} + func stripRoutePrefix(prefix, requestPath string) string { prefix = strings.TrimSuffix(prefix, "/") if prefix == "" || prefix == "/" { diff --git a/internal/x402/verifier_test.go b/internal/x402/verifier_test.go index 624f035e..980fe5b6 100644 --- a/internal/x402/verifier_test.go +++ b/internal/x402/verifier_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - x402types "github.com/x402-foundation/x402/go/v2/types" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" + x402types "github.com/x402-foundation/x402/go/v2/types" ) // ── Mock facilitator ──────────────────────────────────────────────────────── @@ -482,6 +482,93 @@ func TestVerifier_HandleProxy_ValidPayment_SettlesAndStripsPrefix(t *testing.T) } } +// TestVerifier_HandleProxy_TolerantChatPathRewrite covers the forgiving path +// normalization for chat-completions-shaped offers: buyers who POST to the +// bare service base (as older 402-page prompts instructed) or to +// /chat/completions must still land on the upstream's /v1/chat/completions +// instead of paying for a 404. Non-chat offers and non-tolerated sub-paths +// must pass through untouched. +func TestVerifier_HandleProxy_TolerantChatPathRewrite(t *testing.T) { + cases := []struct { + name string + offerType string + requestPath string + wantUpstream string + }{ + {"agent bare base", "agent", "/services/demo", "/v1/chat/completions"}, + {"agent trailing slash", "agent", "/services/demo/", "/v1/chat/completions"}, + {"agent missing v1", "agent", "/services/demo/chat/completions", "/v1/chat/completions"}, + {"agent canonical", "agent", "/services/demo/v1/chat/completions", "/v1/chat/completions"}, + {"inference bare base", "inference", "/services/demo", "/v1/chat/completions"}, + {"inference other v1 route untouched", "inference", "/services/demo/v1/embeddings", "/v1/embeddings"}, + {"http bare base untouched", "http", "/services/demo", "/"}, + {"http sub-path untouched", "http", "/services/demo/run", "/run"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + var seenPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + + v := newTestVerifier(t, fac.URL, []RouteRule{{ + Pattern: "/services/demo/*", + Price: "0.0001", + UpstreamURL: upstream.URL, + StripPrefix: "/services/demo", + OfferType: tc.offerType, + }}) + + req := httptest.NewRequest(http.MethodPost, tc.requestPath, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-PAYMENT", testPaymentHeader(t)) + w := httptest.NewRecorder() + v.HandleProxy(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body %q)", w.Code, w.Body.String()) + } + if seenPath != tc.wantUpstream { + t.Fatalf("upstream path = %q, want %q", seenPath, tc.wantUpstream) + } + }) + } +} + +// GET requests must never be rewritten — the tolerant rewrite is only for +// POSTed chat bodies. +func TestVerifier_HandleProxy_TolerantRewrite_SkipsGET(t *testing.T) { + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + var seenPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + v := newTestVerifier(t, fac.URL, []RouteRule{{ + Pattern: "/services/demo/*", + Price: "0.0001", + UpstreamURL: upstream.URL, + StripPrefix: "/services/demo", + OfferType: "agent", + }}) + + req := httptest.NewRequest(http.MethodGet, "/services/demo", nil) + req.Header.Set("X-PAYMENT", testPaymentHeader(t)) + w := httptest.NewRecorder() + v.HandleProxy(w, req) + + if seenPath != "/" { + t.Fatalf("upstream path = %q, want / (GET must not be rewritten)", seenPath) + } +} + func TestVerifier_HandleProxy_UpstreamFailure_DoesNotSettle(t *testing.T) { fac := newMockFacilitator(t, mockFacilitatorOpts{}) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {