Skip to content

Commit de9cf9c

Browse files
authored
feat: Call next http handler before payment settlement (#23)
* feat: Call next http handler before payment settlement * fix: typo and add Flush/Hijack/Push on the settlmentInterceptor
1 parent 9312937 commit de9cf9c

File tree

1 file changed

+127
-32
lines changed

1 file changed

+127
-32
lines changed

http/middleware.go

Lines changed: 127 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
package http
33

44
import (
5+
"bufio"
56
"context"
7+
"errors"
68
"log/slog"
9+
"net"
710
"net/http"
811

912
"github.com/mark3labs/x402-go"
@@ -170,42 +173,134 @@ func NewX402Middleware(config *Config) func(http.Handler) http.Handler {
170173
// Payment verified successfully
171174
logger.Info("payment verified", "payer", verifyResp.Payer)
172175

173-
// Settle payment if not verify-only mode
174-
var settlementResp *x402.SettlementResponse
175-
if !config.VerifyOnly {
176-
logger.Info("settling payment", "payer", verifyResp.Payer)
177-
settlementResp, err = facilitator.Settle(r.Context(), payment, requirement)
178-
if err != nil && fallbackFacilitator != nil {
179-
logger.Warn("primary facilitator settlement failed, trying fallback", "error", err)
180-
settlementResp, err = fallbackFacilitator.Settle(r.Context(), payment, requirement)
181-
}
182-
if err != nil {
183-
logger.Error("settlement failed", "error", err)
184-
http.Error(w, "Payment settlement failed", http.StatusServiceUnavailable)
185-
return
186-
}
187-
188-
if !settlementResp.Success {
189-
logger.Warn("settlement unsuccessful", "reason", settlementResp.ErrorReason)
190-
sendPaymentRequiredWithRequirements(w, requirementsWithResource)
191-
return
192-
}
193-
194-
logger.Info("payment settled", "transaction", settlementResp.Transaction)
195-
196-
// Add X-PAYMENT-RESPONSE header with settlement info
197-
if err := addPaymentResponseHeader(w, settlementResp); err != nil {
198-
logger.Warn("failed to add payment response header", "error", err)
199-
// Continue anyway - payment was successful
200-
}
201-
}
202-
203176
// Store payment info in context for handler access
204177
ctx := context.WithValue(r.Context(), PaymentContextKey, verifyResp)
205178
r = r.WithContext(ctx)
206179

207-
// Payment successful - call next handler
208-
next.ServeHTTP(w, r)
180+
interceptor := &settlementInterceptor{
181+
w: w,
182+
settleFunc: func() bool {
183+
if config.VerifyOnly {
184+
return true
185+
}
186+
187+
logger.Info("settling payment", "payer", verifyResp.Payer)
188+
settlementResp, err := facilitator.Settle(r.Context(), payment, requirement)
189+
if err != nil && fallbackFacilitator != nil {
190+
logger.Warn("primary facilitator settlement failed, trying fallback", "error", err)
191+
settlementResp, err = fallbackFacilitator.Settle(r.Context(), payment, requirement)
192+
}
193+
if err != nil {
194+
logger.Error("settlement failed", "error", err)
195+
http.Error(w, "Payment settlement failed", http.StatusServiceUnavailable)
196+
return false
197+
}
198+
199+
if !settlementResp.Success {
200+
logger.Warn("settlement unsuccessful", "reason", settlementResp.ErrorReason)
201+
sendPaymentRequiredWithRequirements(w, requirementsWithResource)
202+
return false
203+
}
204+
205+
logger.Info("payment settled", "transaction", settlementResp.Transaction)
206+
207+
// Add X-PAYMENT-RESPONSE header with settlement info
208+
if err := addPaymentResponseHeader(w, settlementResp); err != nil {
209+
logger.Warn("failed to add payment response header", "error", err)
210+
// Continue anyway - payment was successful
211+
}
212+
return true
213+
},
214+
onFailure: func(statusCode int) {
215+
logger.Warn("handler returned non-success, skipping payment settlement", "status", statusCode)
216+
},
217+
}
218+
next.ServeHTTP(interceptor, r)
209219
})
210220
}
211221
}
222+
223+
// settlementInterceptor wraps the ResponseWriter to intercept the moment of commitment.
224+
type settlementInterceptor struct {
225+
w http.ResponseWriter
226+
// settleFunc is the callback that performs the actual settlement logic
227+
settleFunc func() bool
228+
// onFailure is an internal logging callback
229+
onFailure func(statusCode int)
230+
committed bool
231+
hijacked bool
232+
}
233+
234+
func (i *settlementInterceptor) Header() http.Header {
235+
return i.w.Header()
236+
}
237+
238+
func (i *settlementInterceptor) Write(b []byte) (int, error) {
239+
// If the handler calls Write without WriteHeader, it implies 200 OK.
240+
// We must trigger our check now.
241+
if !i.committed {
242+
i.WriteHeader(http.StatusOK)
243+
}
244+
245+
// If settlement failed, we have "hijacked" the connection to send an error.
246+
// We silently discard the handler's payload to prevent mixed responses.
247+
if i.hijacked {
248+
return len(b), nil
249+
}
250+
251+
return i.w.Write(b)
252+
}
253+
254+
func (i *settlementInterceptor) WriteHeader(statusCode int) {
255+
if i.committed {
256+
return
257+
}
258+
i.committed = true
259+
260+
// Case 1: Handler is returning an error (e.g., 404, 500).
261+
// We do nothing. Let the error pass through. No settlement.
262+
if statusCode >= 400 {
263+
if i.onFailure != nil {
264+
i.onFailure(statusCode)
265+
}
266+
i.w.WriteHeader(statusCode)
267+
return
268+
}
269+
270+
// Case 2: Handler wants to succeed. STOP!
271+
// We run the settlement logic now.
272+
if !i.settleFunc() {
273+
// Settlement failed. We mark as hijacked.
274+
// The settleFunc has already written the 402/503 error to the underlying writer.
275+
i.hijacked = true
276+
return
277+
}
278+
279+
// Case 3: Settlement succeeded.
280+
// The settleFunc has already added the X-PAYMENT-RESPONSE headers.
281+
// We now allow the original status code to proceed.
282+
i.w.WriteHeader(statusCode)
283+
}
284+
285+
// Flush implements http.Flusher to support streaming responses.
286+
func (i *settlementInterceptor) Flush() {
287+
if flusher, ok := i.w.(http.Flusher); ok {
288+
flusher.Flush()
289+
}
290+
}
291+
292+
// Hijack implements http.Hijacker to support connection hijacking.
293+
func (i *settlementInterceptor) Hijack() (net.Conn, *bufio.ReadWriter, error) {
294+
if hijacker, ok := i.w.(http.Hijacker); ok {
295+
return hijacker.Hijack()
296+
}
297+
return nil, nil, errors.New("hijacking not supported")
298+
}
299+
300+
// Push implements http.Pusher to support HTTP/2 server push.
301+
func (i *settlementInterceptor) Push(target string, opts *http.PushOptions) error {
302+
if pusher, ok := i.w.(http.Pusher); ok {
303+
return pusher.Push(target, opts)
304+
}
305+
return http.ErrNotSupported
306+
}

0 commit comments

Comments
 (0)