From 211b2867505ef51edf19270369fd88a4700be620 Mon Sep 17 00:00:00 2001 From: Michael Broshi Date: Thu, 18 Dec 2025 12:52:26 -0500 Subject: [PATCH 1/2] Use HTTP status code in V2 errors --- .../stripe/exception/RateLimitException.java | 2 +- .../com/stripe/exception/StripeException.java | 9 +- .../stripe/net/LiveStripeResponseGetter.java | 131 +++--- .../java/com/stripe/functional/ErrorTest.java | 376 +++++++++++++++++- 4 files changed, 422 insertions(+), 96 deletions(-) diff --git a/src/main/java/com/stripe/exception/RateLimitException.java b/src/main/java/com/stripe/exception/RateLimitException.java index 78353fcd7e9..b8476ea4ee5 100644 --- a/src/main/java/com/stripe/exception/RateLimitException.java +++ b/src/main/java/com/stripe/exception/RateLimitException.java @@ -7,7 +7,7 @@ import lombok.Getter; @Getter -public class RateLimitException extends ApiException { +public class RateLimitException extends StripeException { private static final long serialVersionUID = 2L; private final String param; diff --git a/src/main/java/com/stripe/exception/StripeException.java b/src/main/java/com/stripe/exception/StripeException.java index ba7bf85ff83..fa05359734d 100644 --- a/src/main/java/com/stripe/exception/StripeException.java +++ b/src/main/java/com/stripe/exception/StripeException.java @@ -20,13 +20,16 @@ public abstract class StripeException extends Exception { ApiMode stripeErrorApiMode; public void setStripeError(StripeError err) { + setStripeError(err, ApiMode.V1); + } + + public void setStripeError(StripeError err, ApiMode mode) { stripeError = err; - stripeErrorApiMode = ApiMode.V1; + stripeErrorApiMode = mode; } public void setStripeV2Error(StripeError err) { - stripeError = err; - stripeErrorApiMode = ApiMode.V2; + setStripeError(err, ApiMode.V2); } /** * Returns the error code of the response that triggered this exception. For {@link ApiException} diff --git a/src/main/java/com/stripe/net/LiveStripeResponseGetter.java b/src/main/java/com/stripe/net/LiveStripeResponseGetter.java index cbfa82d64e8..d422b17b792 100644 --- a/src/main/java/com/stripe/net/LiveStripeResponseGetter.java +++ b/src/main/java/com/stripe/net/LiveStripeResponseGetter.java @@ -311,110 +311,69 @@ private void handleError(StripeResponse response, ApiMode apiMode) throws Stripe } private void handleV1ApiError(StripeResponse response) throws StripeException { - StripeException exception = null; - - StripeError error = - parseStripeError(response.body(), response.code(), response.requestId(), StripeError.class); - - error.setLastResponse(response); - switch (response.code()) { - case 400: - case 404: - if ("idempotency_error".equals(error.getType())) { - exception = - new IdempotencyException( - error.getMessage(), response.requestId(), error.getCode(), response.code()); - } else { - exception = - new InvalidRequestException( - error.getMessage(), - error.getParam(), - response.requestId(), - error.getCode(), - response.code(), - null); - } - break; - case 401: - exception = - new AuthenticationException( - error.getMessage(), response.requestId(), error.getCode(), response.code()); - break; - case 402: - exception = - new CardException( - error.getMessage(), - response.requestId(), - error.getCode(), - error.getParam(), - error.getDeclineCode(), - error.getCharge(), - response.code(), - null); - break; - case 403: - exception = - new PermissionException( - error.getMessage(), response.requestId(), error.getCode(), response.code()); - break; - case 429: - exception = - new RateLimitException( - error.getMessage(), - error.getParam(), - response.requestId(), - error.getCode(), - response.code(), - null); - break; - default: - exception = - new ApiException( - error.getMessage(), response.requestId(), error.getCode(), response.code(), null); - break; - } - exception.setStripeError(error); - - throw exception; + throwStripeException(response, ApiMode.V1); } private void handleV2ApiError(StripeResponse response) throws StripeException { + // First try to throw an exception based on the "type" field, if it exists and we + // recognize it. Otherwise, we will fall back to throwing an exception based on status code. JsonObject body = ApiResource.GSON.fromJson(response.body(), JsonObject.class).getAsJsonObject("error"); - JsonElement typeElement = body == null ? null : body.get("type"); - JsonElement codeElement = body == null ? null : body.get("code"); String type = typeElement == null ? "" : typeElement.getAsString(); - String code = codeElement == null ? "" : codeElement.getAsString(); - StripeException exception = StripeException.parseV2Exception(type, body, response.code(), response.requestId(), this); if (exception != null) { throw exception; } - StripeError error; - try { - error = - parseStripeError( - response.body(), response.code(), response.requestId(), StripeError.class); - } catch (ApiException e) { - String message = "Unrecognized error type '" + type + "'"; - JsonElement messageField = body == null ? null : body.get("message"); - if (messageField != null && messageField.isJsonPrimitive()) { - message = messageField.getAsString(); - } - - throw new ApiException(message, response.requestId(), code, response.code(), null); - } + throwStripeException(response, ApiMode.V2); + } + private void throwStripeException(StripeResponse response, ApiMode apiMode) + throws StripeException { + StripeError error = + parseStripeError(response.body(), response.code(), response.requestId(), StripeError.class); error.setLastResponse(response); - exception = - new ApiException(error.getMessage(), response.requestId(), code, response.code(), null); - exception.setStripeV2Error(error); + StripeException exception = exceptionFromStatus(response.code(), response.requestId(), error); + exception.setStripeError(error, apiMode); throw exception; } + private StripeException exceptionFromStatus(int statusCode, String requestId, StripeError error) { + switch (statusCode) { + case 400: + case 404: + if ("idempotency_error".equals(error.getType())) { + return new IdempotencyException( + error.getMessage(), requestId, error.getCode(), statusCode); + } else { + return new InvalidRequestException( + error.getMessage(), error.getParam(), requestId, error.getCode(), statusCode, null); + } + case 401: + return new AuthenticationException( + error.getMessage(), requestId, error.getCode(), statusCode); + case 402: + return new CardException( + error.getMessage(), + requestId, + error.getCode(), + error.getParam(), + error.getDeclineCode(), + error.getCharge(), + statusCode, + null); + case 403: + return new PermissionException(error.getMessage(), requestId, error.getCode(), statusCode); + case 429: + return new RateLimitException( + error.getMessage(), error.getParam(), requestId, error.getCode(), statusCode, null); + default: + return new ApiException(error.getMessage(), requestId, error.getCode(), statusCode, null); + } + } + private void handleOAuthError(StripeResponse response) throws StripeException { OAuthError error = null; StripeException exception = null; diff --git a/src/test/java/com/stripe/functional/ErrorTest.java b/src/test/java/com/stripe/functional/ErrorTest.java index e461a124a0e..392d82159e3 100644 --- a/src/test/java/com/stripe/functional/ErrorTest.java +++ b/src/test/java/com/stripe/functional/ErrorTest.java @@ -9,6 +9,7 @@ import com.stripe.model.Balance; import com.stripe.model.StripeError; import com.stripe.net.*; +import com.stripe.net.ApiMode; import java.io.IOException; import java.util.Collections; import lombok.Cleanup; @@ -19,8 +20,11 @@ import org.mockito.stubbing.Answer; public class ErrorTest extends BaseStripeTest { + + // ==================== V1 Error Tests ==================== + @Test - public void testStripeError() throws StripeException, IOException, InterruptedException { + public void testV1Error400InvalidRequest() throws StripeException, IOException, InterruptedException { InvalidRequestException exception = null; Mockito.doAnswer( (Answer) @@ -42,8 +46,187 @@ public void testStripeError() throws StripeException, IOException, InterruptedEx assertNotNull(exception.getStripeError()); assertEquals("invalid_request_error", exception.getStripeError().getType()); assertNotNull(exception.getStripeError().getLastResponse()); + assertEquals(ApiMode.V1, exception.getStripeErrorApiMode()); + } + + @Test + public void testV1Error400IdempotencyError() + throws StripeException, IOException, InterruptedException { + IdempotencyException exception = null; + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 400, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"idempotency_error\", \"code\": \"idempotency_key_in_use\", \"message\": \"Keys for idempotent requests can only be used with the same parameters.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + try { + Balance.retrieve(); + } catch (IdempotencyException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(IdempotencyException.class, exception); + assertEquals(Integer.valueOf(400), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + assertEquals("idempotency_error", exception.getStripeError().getType()); + } + + @Test + public void testV1Error401Authentication() + throws StripeException, IOException, InterruptedException { + AuthenticationException exception = null; + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 401, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"invalid_request_error\", \"code\": \"api_key_expired\", \"message\": \"Expired API Key provided.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + try { + Balance.retrieve(); + } catch (AuthenticationException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(AuthenticationException.class, exception); + assertEquals(Integer.valueOf(401), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + } + + @Test + public void testV1Error402CardError() throws StripeException, IOException, InterruptedException { + CardException exception = null; + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 402, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"card_error\", \"code\": \"card_declined\", \"message\": \"Your card was declined.\", \"decline_code\": \"generic_decline\", \"param\": \"card_number\", \"charge\": \"ch_123\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + try { + Balance.retrieve(); + } catch (CardException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(CardException.class, exception); + assertEquals(Integer.valueOf(402), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + assertEquals("generic_decline", exception.getDeclineCode()); + assertEquals("ch_123", exception.getCharge()); + } + + @Test + public void testV1Error403Permission() + throws StripeException, IOException, InterruptedException { + PermissionException exception = null; + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 403, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"invalid_request_error\", \"code\": \"api_key_permissions\", \"message\": \"The API key does not have permission to perform this action.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + try { + Balance.retrieve(); + } catch (PermissionException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(PermissionException.class, exception); + assertEquals(Integer.valueOf(403), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + } + + @Test + public void testV1Error404InvalidRequest() + throws StripeException, IOException, InterruptedException { + InvalidRequestException exception = null; + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 404, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"invalid_request_error\", \"code\": \"resource_missing\", \"message\": \"No such customer: cus_nonexistent\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + try { + Balance.retrieve(); + } catch (InvalidRequestException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(InvalidRequestException.class, exception); + assertEquals(Integer.valueOf(404), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + } + + @Test + public void testV1Error429RateLimit() + throws StripeException, IOException, InterruptedException { + RateLimitException exception = null; + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 429, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"rate_limit_error\", \"code\": \"rate_limit\", \"message\": \"Too many requests hit the API too quickly.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + try { + Balance.retrieve(); + } catch (RateLimitException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(RateLimitException.class, exception); + assertEquals(Integer.valueOf(429), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + } + + @Test + public void testV1Error500ApiError() throws StripeException, IOException, InterruptedException { + ApiException exception = null; + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 500, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"api_error\", \"message\": \"An unexpected error occurred.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + try { + Balance.retrieve(); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(ApiException.class, exception); + assertEquals(Integer.valueOf(500), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); } + // ==================== V2 Error Tests ==================== + @Test public void testV2OutboundPaymentInsufficientFundsError() throws StripeException, IOException, InterruptedException { @@ -96,13 +279,13 @@ public void testV2InvalidErrorEmpty() throws StripeException, IOException, Inter assertInstanceOf(ApiException.class, exception); assertNull(exception.getStripeError()); assertNull(exception.getUserMessage()); - assertEquals("Unrecognized error type ''; code: ", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid response object from API: {}. (HTTP response code was 404)")); } @Test public void testV2UnknownExceptionValidError() throws StripeException, IOException, InterruptedException { - ApiException exception = null; + Exception exception = null; @Cleanup MockWebServer server = new MockWebServer(); Mockito.doAnswer( (Answer) @@ -116,6 +299,186 @@ public void testV2UnknownExceptionValidError() Stripe.overrideApiBase(server.url("").toString()); + try { + mockClient.v2().core().events().retrieve("event_123"); + } catch (Exception e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(InvalidRequestException.class, exception); + InvalidRequestException apiException = (InvalidRequestException) exception; + assertInstanceOf(StripeError.class, apiException.getStripeError()); + assertNull(apiException.getUserMessage()); + assertEquals("good luck debugging this one; code: some_error_code", apiException.getMessage()); + assertEquals(ApiMode.V2, apiException.getStripeErrorApiMode()); + } + + @Test + public void testV2Error400IdempotencyError() + throws StripeException, IOException, InterruptedException { + IdempotencyException exception = null; + @Cleanup MockWebServer server = new MockWebServer(); + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 400, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"idempotency_error\", \"code\": \"idempotency_key_in_use\", \"message\": \"Keys for idempotent requests can only be used with the same parameters.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + + Stripe.overrideApiBase(server.url("").toString()); + + try { + mockClient.v2().core().events().retrieve("event_123"); + } catch (IdempotencyException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(IdempotencyException.class, exception); + assertEquals(Integer.valueOf(400), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + assertEquals("idempotency_error", exception.getStripeError().getType()); + } + + @Test + public void testV2Error401Authentication() + throws StripeException, IOException, InterruptedException { + AuthenticationException exception = null; + @Cleanup MockWebServer server = new MockWebServer(); + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 401, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"unknown_v2_type\", \"code\": \"api_key_expired\", \"message\": \"Expired API Key provided.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + + Stripe.overrideApiBase(server.url("").toString()); + + try { + mockClient.v2().core().events().retrieve("event_123"); + } catch (AuthenticationException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(AuthenticationException.class, exception); + assertEquals(Integer.valueOf(401), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + } + + @Test + public void testV2Error402CardError() + throws StripeException, IOException, InterruptedException { + CardException exception = null; + @Cleanup MockWebServer server = new MockWebServer(); + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 402, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"unknown_v2_type\", \"code\": \"card_declined\", \"message\": \"Your card was declined.\", \"decline_code\": \"generic_decline\", \"param\": \"card_number\", \"charge\": \"ch_123\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + + Stripe.overrideApiBase(server.url("").toString()); + + try { + mockClient.v2().core().events().retrieve("event_123"); + } catch (CardException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(CardException.class, exception); + assertEquals(Integer.valueOf(402), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + assertEquals("generic_decline", exception.getDeclineCode()); + assertEquals("ch_123", exception.getCharge()); + } + + @Test + public void testV2Error403Permission() + throws StripeException, IOException, InterruptedException { + PermissionException exception = null; + @Cleanup MockWebServer server = new MockWebServer(); + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 403, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"unknown_v2_type\", \"code\": \"api_key_permissions\", \"message\": \"The API key does not have permission to perform this action.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + + Stripe.overrideApiBase(server.url("").toString()); + + try { + mockClient.v2().core().events().retrieve("event_123"); + } catch (PermissionException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(PermissionException.class, exception); + assertEquals(Integer.valueOf(403), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + } + + @Test + public void testV2Error429RateLimit() + throws StripeException, IOException, InterruptedException { + RateLimitException exception = null; + @Cleanup MockWebServer server = new MockWebServer(); + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 429, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"unknown_v2_type\", \"code\": \"rate_limit\", \"message\": \"Too many requests hit the API too quickly.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + + Stripe.overrideApiBase(server.url("").toString()); + + try { + mockClient.v2().core().events().retrieve("event_123"); + } catch (RateLimitException e) { + exception = e; + } + + assertNotNull(exception); + assertInstanceOf(RateLimitException.class, exception); + assertEquals(Integer.valueOf(429), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); + } + + @Test + public void testV2Error500ApiError() + throws StripeException, IOException, InterruptedException { + ApiException exception = null; + @Cleanup MockWebServer server = new MockWebServer(); + Mockito.doAnswer( + (Answer) + invocation -> + new StripeResponse( + 500, + HttpHeaders.of(Collections.emptyMap()), + "{\"error\": {\"type\": \"unknown_v2_type\", \"message\": \"An unexpected error occurred.\"}}")) + .when(httpClientSpy) + .request(Mockito.any()); + + Stripe.overrideApiBase(server.url("").toString()); + try { mockClient.v2().core().events().retrieve("event_123"); } catch (ApiException e) { @@ -124,11 +487,12 @@ public void testV2UnknownExceptionValidError() assertNotNull(exception); assertInstanceOf(ApiException.class, exception); - assertInstanceOf(StripeError.class, exception.getStripeError()); - assertNull(exception.getUserMessage()); - assertEquals("good luck debugging this one; code: some_error_code", exception.getMessage()); + assertEquals(Integer.valueOf(500), exception.getStatusCode()); + assertNotNull(exception.getStripeError()); } + // ==================== OAuth Error Tests ==================== + @Test public void testOAuthError() throws StripeException, IOException, InterruptedException { String oldBase = Stripe.getConnectBase(); From 12969b00d931791edce081fdb1dde2f6385b6dc4 Mon Sep 17 00:00:00 2001 From: Michael Broshi Date: Thu, 18 Dec 2025 17:32:34 -0500 Subject: [PATCH 2/2] Fix formatting --- .../java/com/stripe/functional/ErrorTest.java | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/stripe/functional/ErrorTest.java b/src/test/java/com/stripe/functional/ErrorTest.java index 392d82159e3..b05fe007937 100644 --- a/src/test/java/com/stripe/functional/ErrorTest.java +++ b/src/test/java/com/stripe/functional/ErrorTest.java @@ -24,7 +24,8 @@ public class ErrorTest extends BaseStripeTest { // ==================== V1 Error Tests ==================== @Test - public void testV1Error400InvalidRequest() throws StripeException, IOException, InterruptedException { + public void testV1Error400InvalidRequest() + throws StripeException, IOException, InterruptedException { InvalidRequestException exception = null; Mockito.doAnswer( (Answer) @@ -127,8 +128,7 @@ public void testV1Error402CardError() throws StripeException, IOException, Inter } @Test - public void testV1Error403Permission() - throws StripeException, IOException, InterruptedException { + public void testV1Error403Permission() throws StripeException, IOException, InterruptedException { PermissionException exception = null; Mockito.doAnswer( (Answer) @@ -177,8 +177,7 @@ public void testV1Error404InvalidRequest() } @Test - public void testV1Error429RateLimit() - throws StripeException, IOException, InterruptedException { + public void testV1Error429RateLimit() throws StripeException, IOException, InterruptedException { RateLimitException exception = null; Mockito.doAnswer( (Answer) @@ -279,7 +278,10 @@ public void testV2InvalidErrorEmpty() throws StripeException, IOException, Inter assertInstanceOf(ApiException.class, exception); assertNull(exception.getStripeError()); assertNull(exception.getUserMessage()); - assertTrue(exception.getMessage().contains("Invalid response object from API: {}. (HTTP response code was 404)")); + assertTrue( + exception + .getMessage() + .contains("Invalid response object from API: {}. (HTTP response code was 404)")); } @Test @@ -374,8 +376,7 @@ public void testV2Error401Authentication() } @Test - public void testV2Error402CardError() - throws StripeException, IOException, InterruptedException { + public void testV2Error402CardError() throws StripeException, IOException, InterruptedException { CardException exception = null; @Cleanup MockWebServer server = new MockWebServer(); Mockito.doAnswer( @@ -405,8 +406,7 @@ public void testV2Error402CardError() } @Test - public void testV2Error403Permission() - throws StripeException, IOException, InterruptedException { + public void testV2Error403Permission() throws StripeException, IOException, InterruptedException { PermissionException exception = null; @Cleanup MockWebServer server = new MockWebServer(); Mockito.doAnswer( @@ -434,8 +434,7 @@ public void testV2Error403Permission() } @Test - public void testV2Error429RateLimit() - throws StripeException, IOException, InterruptedException { + public void testV2Error429RateLimit() throws StripeException, IOException, InterruptedException { RateLimitException exception = null; @Cleanup MockWebServer server = new MockWebServer(); Mockito.doAnswer( @@ -463,8 +462,7 @@ public void testV2Error429RateLimit() } @Test - public void testV2Error500ApiError() - throws StripeException, IOException, InterruptedException { + public void testV2Error500ApiError() throws StripeException, IOException, InterruptedException { ApiException exception = null; @Cleanup MockWebServer server = new MockWebServer(); Mockito.doAnswer(