Skip to content

Commit 30716d9

Browse files
fix(java): skip settlement for error responses (4xx/5xx)
Check response status before settlement in PaymentFilter. Skip settlement if handler returns 4xx/5xx to avoid charging users for failed requests. Matches Go/Python middleware behavior.
1 parent 6da9f28 commit 30716d9

File tree

2 files changed

+113
-28
lines changed

2 files changed

+113
-28
lines changed

java/src/main/java/com/coinbase/x402/server/PaymentFilter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ public void doFilter(ServletRequest req,
146146
chain.doFilter(req, res);
147147

148148
/* -------- settlement (return errors to user) ------------- */
149+
// Don't settle if response failed (4xx/5xx status codes)
150+
if (response.getStatus() >= 400) {
151+
return;
152+
}
153+
149154
try {
150155
SettlementResponse sr = facilitator.settle(header, buildRequirements(path));
151156
if (sr == null || !sr.success) {

java/src/test/java/com/coinbase/x402/server/PaymentFilterTest.java

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ void validHeader() throws Exception {
8989
VerificationResponse vr = new VerificationResponse();
9090
vr.isValid = true;
9191
when(fac.verify(eq(header), any())).thenReturn(vr);
92-
92+
93+
// handler returns 200 OK
94+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_OK);
95+
9396
// settlement succeeds
9497
SettlementResponse sr = new SettlementResponse();
9598
sr.success = true;
@@ -236,16 +239,19 @@ void settlementException() throws Exception {
236239
VerificationResponse vr = new VerificationResponse();
237240
vr.isValid = true;
238241
when(fac.verify(eq(header), any())).thenReturn(vr);
239-
242+
243+
// handler returns 200 OK
244+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_OK);
245+
240246
// But settlement throws exception (should return 402)
241247
doThrow(new IOException("Network error")).when(fac).settle(any(), any());
242-
248+
243249
filter.doFilter(req, resp, chain);
244-
250+
245251
// Request should be processed, but then settlement failure should return 402
246252
verify(chain).doFilter(req, resp);
247253
verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED);
248-
254+
249255
// Verify and settle were both called
250256
verify(fac).verify(eq(header), any());
251257
verify(fac).settle(eq(header), any());
@@ -269,19 +275,22 @@ void settlementFailure() throws Exception {
269275
VerificationResponse vr = new VerificationResponse();
270276
vr.isValid = true;
271277
when(fac.verify(eq(header), any())).thenReturn(vr);
272-
278+
279+
// handler returns 200 OK
280+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_OK);
281+
273282
// Settlement fails (facilitator returns success=false)
274283
SettlementResponse sr = new SettlementResponse();
275284
sr.success = false;
276285
sr.error = "insufficient balance";
277286
when(fac.settle(eq(header), any())).thenReturn(sr);
278-
287+
279288
filter.doFilter(req, resp, chain);
280-
289+
281290
// Request should be processed, but then settlement failure should return 402
282291
verify(chain).doFilter(req, resp);
283292
verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED);
284-
293+
285294
// Verify and settle were both called
286295
verify(fac).verify(eq(header), any());
287296
verify(fac).settle(eq(header), any());
@@ -326,25 +335,28 @@ void payerExtractedFromPaymentPayload() throws Exception {
326335

327336
String header = p.toHeader();
328337
when(req.getHeader("X-PAYMENT")).thenReturn(header);
329-
338+
330339
// Verification succeeds
331340
VerificationResponse vr = new VerificationResponse();
332341
vr.isValid = true;
333342
when(fac.verify(eq(header), any())).thenReturn(vr);
334-
335-
// Settlement succeeds
343+
344+
// handler returns 200 OK
345+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_OK);
346+
347+
// Settlement succeeds
336348
SettlementResponse sr = new SettlementResponse();
337349
sr.success = true;
338350
sr.txHash = "0xabcdef1234567890";
339351
sr.networkId = "base-sepolia";
340352
when(fac.settle(eq(header), any())).thenReturn(sr);
341-
353+
342354
filter.doFilter(req, resp, chain);
343-
355+
344356
// Verify request was processed successfully
345357
verify(chain).doFilter(req, resp);
346358
verify(resp, never()).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED);
347-
359+
348360
// Verify X-PAYMENT-RESPONSE header was set
349361
verify(resp).setHeader(eq("X-PAYMENT-RESPONSE"), any());
350362
verify(resp).setHeader(eq("Access-Control-Expose-Headers"), eq("X-PAYMENT-RESPONSE"));
@@ -378,29 +390,32 @@ void payerExtractionWithMissingAuthorization() throws Exception {
378390

379391
String header = p.toHeader();
380392
when(req.getHeader("X-PAYMENT")).thenReturn(header);
381-
393+
382394
// Verification succeeds
383395
VerificationResponse vr = new VerificationResponse();
384396
vr.isValid = true;
385397
when(fac.verify(eq(header), any())).thenReturn(vr);
386-
387-
// Settlement succeeds
398+
399+
// handler returns 200 OK
400+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_OK);
401+
402+
// Settlement succeeds
388403
SettlementResponse sr = new SettlementResponse();
389404
sr.success = true;
390405
sr.txHash = "0xabcdef1234567890";
391406
sr.networkId = "base-sepolia";
392407
when(fac.settle(eq(header), any())).thenReturn(sr);
393-
408+
394409
filter.doFilter(req, resp, chain);
395-
410+
396411
// Verify request was processed successfully
397412
verify(chain).doFilter(req, resp);
398413
verify(resp, never()).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED);
399-
414+
400415
// Capture the settlement response header
401416
org.mockito.ArgumentCaptor<String> headerCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
402417
verify(resp).setHeader(eq("X-PAYMENT-RESPONSE"), headerCaptor.capture());
403-
418+
404419
// Decode and verify the settlement response has null payer
405420
String base64Header = headerCaptor.getValue();
406421
String jsonString = new String(Base64.getDecoder().decode(base64Header));
@@ -427,25 +442,28 @@ void payerExtractionWithMalformedAuthorization() throws Exception {
427442

428443
String header = p.toHeader();
429444
when(req.getHeader("X-PAYMENT")).thenReturn(header);
430-
445+
431446
// Verification succeeds
432447
VerificationResponse vr = new VerificationResponse();
433448
vr.isValid = true;
434449
when(fac.verify(eq(header), any())).thenReturn(vr);
435-
436-
// Settlement succeeds
450+
451+
// handler returns 200 OK
452+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_OK);
453+
454+
// Settlement succeeds
437455
SettlementResponse sr = new SettlementResponse();
438456
sr.success = true;
439457
sr.txHash = "0xabcdef1234567890";
440458
sr.networkId = "base-sepolia";
441459
when(fac.settle(eq(header), any())).thenReturn(sr);
442-
460+
443461
filter.doFilter(req, resp, chain);
444-
462+
445463
// Verify request was processed successfully (payer extraction failure should not break processing)
446464
verify(chain).doFilter(req, resp);
447465
verify(resp, never()).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED);
448-
466+
449467
// Capture the settlement response header
450468
org.mockito.ArgumentCaptor<String> headerCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
451469
verify(resp).setHeader(eq("X-PAYMENT-RESPONSE"), headerCaptor.capture());
@@ -459,6 +477,68 @@ void payerExtractionWithMalformedAuthorization() throws Exception {
459477
"Settlement response should contain null payer when authorization malformed: " + jsonString);
460478
}
461479

480+
/* ------------ error response skips settlement ------------------------- */
481+
@Test
482+
void errorResponseSkipsSettlement() throws Exception {
483+
when(req.getRequestURI()).thenReturn("/private");
484+
485+
// Create a valid header
486+
PaymentPayload p = new PaymentPayload();
487+
p.x402Version = 1;
488+
p.scheme = "exact";
489+
p.network = "base-sepolia";
490+
p.payload = Map.of("resource", "/private");
491+
String header = p.toHeader();
492+
when(req.getHeader("X-PAYMENT")).thenReturn(header);
493+
494+
// Verification succeeds
495+
VerificationResponse vr = new VerificationResponse();
496+
vr.isValid = true;
497+
when(fac.verify(eq(header), any())).thenReturn(vr);
498+
499+
// Simulate handler returning 500 error
500+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
501+
502+
filter.doFilter(req, resp, chain);
503+
504+
// Handler should be called
505+
verify(chain).doFilter(req, resp);
506+
507+
// But settle should NOT be called for error responses
508+
verify(fac, never()).settle(any(), any());
509+
}
510+
511+
/* ------------ 4xx response skips settlement ---------------------------- */
512+
@Test
513+
void clientErrorResponseSkipsSettlement() throws Exception {
514+
when(req.getRequestURI()).thenReturn("/private");
515+
516+
// Create a valid header
517+
PaymentPayload p = new PaymentPayload();
518+
p.x402Version = 1;
519+
p.scheme = "exact";
520+
p.network = "base-sepolia";
521+
p.payload = Map.of("resource", "/private");
522+
String header = p.toHeader();
523+
when(req.getHeader("X-PAYMENT")).thenReturn(header);
524+
525+
// Verification succeeds
526+
VerificationResponse vr = new VerificationResponse();
527+
vr.isValid = true;
528+
when(fac.verify(eq(header), any())).thenReturn(vr);
529+
530+
// Simulate handler returning 404 error
531+
when(resp.getStatus()).thenReturn(HttpServletResponse.SC_NOT_FOUND);
532+
533+
filter.doFilter(req, resp, chain);
534+
535+
// Handler should be called
536+
verify(chain).doFilter(req, resp);
537+
538+
// But settle should NOT be called for 4xx responses
539+
verify(fac, never()).settle(any(), any());
540+
}
541+
462542
/* ------------ facilitator IOException returns 500 --------------------- */
463543
@Test
464544
void facilitatorIOExceptionReturns500() throws Exception {

0 commit comments

Comments
 (0)