From a7245c14402560dd1d13184bf2cc6acac4234a1c Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Tue, 28 Jul 2026 12:20:07 +0530 Subject: [PATCH 1/3] feat: expose structured API error data in RazorpayException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RazorpayException now stores the full error JSON object and HTTP status code from API responses, accessible via getErrorResponse() and getStatusCode(). The existing string message (code:description) is preserved for backward compatibility. Also adds JSONException guards in processResponse, processCollectionResponse, and processDeleteResponse to handle non-JSON responses (e.g. 503 HTML) gracefully instead of crashing with an unhandled RuntimeException. Addresses the Coursera ask pending since 2020 — merchants can now detect specific error types by checking the code field directly instead of parsing a concatenated string, and access all fields (source, step, reason, metadata, field) for their error handling logic. Co-authored-by: ankitdas13 --- src/main/java/com/razorpay/ApiClient.java | 16 +- .../java/com/razorpay/RazorpayException.java | 33 ++++ .../com/razorpay/RazorpayExceptionTest.java | 144 ++++++++++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/razorpay/RazorpayExceptionTest.java diff --git a/src/main/java/com/razorpay/ApiClient.java b/src/main/java/com/razorpay/ApiClient.java index b9c82ce1..56349ca0 100755 --- a/src/main/java/com/razorpay/ApiClient.java +++ b/src/main/java/com/razorpay/ApiClient.java @@ -8,6 +8,7 @@ import org.apache.commons.text.WordUtils; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import okhttp3.HttpUrl; @@ -107,6 +108,8 @@ else if(response.code()==204){ } } catch (IOException e) { throw new RazorpayException(e.getMessage()); + } catch (JSONException e) { + throw new RazorpayException("Unable to parse response: " + e.getMessage(), statusCode); } if (statusCode < STATUS_OK || statusCode >= STATUS_MULTIPLE_CHOICE) { @@ -171,6 +174,8 @@ T processResponse(Response response) throws RazorpayException } } catch (IOException e) { throw new RazorpayException(e.getMessage()); + } catch (JSONException e) { + throw new RazorpayException("Unable to parse response: " + e.getMessage(), statusCode); } if (statusCode >= STATUS_OK && statusCode < STATUS_MULTIPLE_CHOICE) { @@ -196,6 +201,8 @@ ArrayList processCollectionResponse(Response response) responseJson = new JSONObject(responseBody); } catch (IOException e) { throw new RazorpayException(e.getMessage()); + } catch (JSONException e) { + throw new RazorpayException("Unable to parse response: " + e.getMessage(), statusCode); } String collectionName = null; @@ -221,20 +228,21 @@ private String getEntity(JSONObject jsonObj, HttpUrl url) { } private void throwException(int statusCode, JSONObject responseJson) throws RazorpayException { - if (responseJson.has(ERROR)) { + if (responseJson != null && responseJson.has(ERROR)) { JSONObject errorResponse = responseJson.getJSONObject(ERROR); String code = errorResponse.getString(STATUS_CODE); String description = errorResponse.getString(DESCRIPTION); - throw new RazorpayException(code + ":" + description); + throw new RazorpayException(code + ":" + description, errorResponse, statusCode); } - throwServerException(statusCode, responseJson.toString()); + String bodyForServerException = responseJson != null ? responseJson.toString() : "Invalid or empty response from server"; + throwServerException(statusCode, bodyForServerException); } private void throwServerException(int statusCode, String responseBody) throws RazorpayException { StringBuilder sb = new StringBuilder(); sb.append("Status Code: ").append(statusCode).append("\n"); sb.append("Server response: ").append(responseBody); - throw new RazorpayException(sb.toString()); + throw new RazorpayException(sb.toString(), statusCode); } private Class getClass(String entity) { diff --git a/src/main/java/com/razorpay/RazorpayException.java b/src/main/java/com/razorpay/RazorpayException.java index f89c7f45..1f9d16e0 100644 --- a/src/main/java/com/razorpay/RazorpayException.java +++ b/src/main/java/com/razorpay/RazorpayException.java @@ -1,21 +1,54 @@ package com.razorpay; +import org.json.JSONObject; + public class RazorpayException extends Exception { + private JSONObject errorResponse; + private int statusCode; + public RazorpayException(String message) { super(message); + this.statusCode = 0; + this.errorResponse = null; } public RazorpayException(String message, Throwable cause) { super(message, cause); + this.statusCode = 0; + this.errorResponse = null; } public RazorpayException(Throwable cause) { super(cause); + this.statusCode = 0; + this.errorResponse = null; } public RazorpayException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); + this.statusCode = 0; + this.errorResponse = null; + } + + public RazorpayException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + this.errorResponse = null; + } + + public RazorpayException(String message, JSONObject errorResponse, int statusCode) { + super(message); + this.errorResponse = errorResponse; + this.statusCode = statusCode; + } + + public JSONObject getErrorResponse() { + return errorResponse; + } + + public int getStatusCode() { + return statusCode; } } diff --git a/src/test/java/com/razorpay/RazorpayExceptionTest.java b/src/test/java/com/razorpay/RazorpayExceptionTest.java new file mode 100644 index 00000000..b5248ef1 --- /dev/null +++ b/src/test/java/com/razorpay/RazorpayExceptionTest.java @@ -0,0 +1,144 @@ +package com.razorpay; + +import okhttp3.ResponseBody; +import org.json.JSONObject; +import org.junit.Test; +import org.mockito.InjectMocks; + +import java.io.IOException; +import java.util.Collections; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class RazorpayExceptionTest extends BaseTest { + + @InjectMocks + protected OrderClient orderClient = new OrderClient(TEST_SECRET_KEY); + + private static final String ERROR_RESPONSE_JSON = "{" + + "\"error\":{" + + "\"code\":\"BAD_REQUEST_ERROR\"," + + "\"description\":\"The amount must be at least 100\"," + + "\"source\":\"business\"," + + "\"step\":\"payment_initiation\"," + + "\"reason\":\"input_validation_failed\"," + + "\"metadata\":{}," + + "\"field\":\"amount\"" + + "}}"; + + private void mockNonJsonResponse(String rawBody) { + ResponseBody rb = mock(ResponseBody.class); + try { + when(rb.string()).thenReturn(rawBody); + } catch (IOException e) { + fail("Mock setup failed"); + } + when(mockedResponse.body()).thenReturn(rb); + } + + @Test + public void testStructuredErrorResponseIsAvailable() { + try { + mockResponseFromExternalClient(ERROR_RESPONSE_JSON); + mockResponseHTTPCodeFromExternalClient(400); + mockURL(Collections.singletonList("orders")); + orderClient.create(new JSONObject("{\"amount\":50,\"currency\":\"INR\"}")); + fail("Expected RazorpayException"); + } catch (RazorpayException e) { + assertNotNull(e.getErrorResponse()); + JSONObject error = e.getErrorResponse(); + assertEquals("BAD_REQUEST_ERROR", error.getString("code")); + assertEquals("The amount must be at least 100", error.getString("description")); + assertEquals("business", error.getString("source")); + assertEquals("payment_initiation", error.getString("step")); + assertEquals("input_validation_failed", error.getString("reason")); + assertEquals("amount", error.getString("field")); + assertTrue(error.has("metadata")); + } catch (IOException e) { + fail("Unexpected IOException"); + } + } + + @Test + public void testStatusCodeIsAvailable() { + try { + mockResponseFromExternalClient(ERROR_RESPONSE_JSON); + mockResponseHTTPCodeFromExternalClient(400); + mockURL(Collections.singletonList("orders")); + orderClient.create(new JSONObject("{\"amount\":50,\"currency\":\"INR\"}")); + fail("Expected RazorpayException"); + } catch (RazorpayException e) { + assertEquals(400, e.getStatusCode()); + } catch (IOException e) { + fail("Unexpected IOException"); + } + } + + @Test + public void testMessageIsBackwardCompatible() { + try { + mockResponseFromExternalClient(ERROR_RESPONSE_JSON); + mockResponseHTTPCodeFromExternalClient(400); + mockURL(Collections.singletonList("orders")); + orderClient.create(new JSONObject("{\"amount\":50,\"currency\":\"INR\"}")); + fail("Expected RazorpayException"); + } catch (RazorpayException e) { + assertEquals("BAD_REQUEST_ERROR:The amount must be at least 100", e.getMessage()); + } catch (IOException e) { + fail("Unexpected IOException"); + } + } + + @Test + public void testServerExceptionHasStatusCode() { + try { + mockResponseFromExternalClient("{\"unrecognized\":\"body\"}"); + mockResponseHTTPCodeFromExternalClient(500); + mockURL(Collections.singletonList("orders")); + orderClient.create(new JSONObject("{\"amount\":50,\"currency\":\"INR\"}")); + fail("Expected RazorpayException"); + } catch (RazorpayException e) { + assertEquals(500, e.getStatusCode()); + assertNull(e.getErrorResponse()); + assertTrue(e.getMessage().contains("Status Code: 500")); + } catch (IOException e) { + fail("Unexpected IOException"); + } + } + + @Test + public void testNonJsonResponseDoesNotCrash() { + try { + mockNonJsonResponse("503 Service Unavailable"); + mockResponseHTTPCodeFromExternalClient(503); + mockURL(Collections.singletonList("orders")); + orderClient.create(new JSONObject("{\"amount\":50,\"currency\":\"INR\"}")); + fail("Expected RazorpayException"); + } catch (RazorpayException e) { + assertEquals(503, e.getStatusCode()); + assertNull(e.getErrorResponse()); + assertTrue(e.getMessage().contains("Unable to parse response")); + } + } + + @Test + public void testExceptionConstructorsDefaults() { + RazorpayException ex1 = new RazorpayException("test message"); + assertEquals("test message", ex1.getMessage()); + assertEquals(0, ex1.getStatusCode()); + assertNull(ex1.getErrorResponse()); + + RazorpayException ex2 = new RazorpayException("test message", new JSONObject("{\"code\":\"ERR\"}"), 404); + assertEquals("test message", ex2.getMessage()); + assertEquals(404, ex2.getStatusCode()); + assertNotNull(ex2.getErrorResponse()); + assertEquals("ERR", ex2.getErrorResponse().getString("code")); + + RazorpayException ex3 = new RazorpayException("server error", 502); + assertEquals("server error", ex3.getMessage()); + assertEquals(502, ex3.getStatusCode()); + assertNull(ex3.getErrorResponse()); + } +} From 44e1fce3b7ce1312c9823156bc87678970a3da0e Mon Sep 17 00:00:00 2001 From: Ankit Das Date: Tue, 28 Jul 2026 15:50:09 +0530 Subject: [PATCH 2/3] fix: add transient field, safe getters, and statusCode in IOException catches P0: Mark errorResponse as transient to prevent NotSerializableException in Spring apps and other serialization contexts. JSONObject is not Serializable and Exception implements Serializable. P1: Add safe convenience getters (getCode, getDescription, getField, getReason, getSource, getStep) that return null for missing fields instead of throwing JSONException. This prevents crashes when merchants access optional fields on errors that don't have them (e.g. 401 auth errors have no 'field' or 'step'). Fix: Pass statusCode to RazorpayException in all 3 IOException catch blocks in ApiClient for consistency with JSONException catches. Co-authored-by: ankitdas13 --- src/main/java/com/razorpay/ApiClient.java | 6 +- .../java/com/razorpay/RazorpayException.java | 26 +++++- .../com/razorpay/RazorpayExceptionTest.java | 84 +++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/razorpay/ApiClient.java b/src/main/java/com/razorpay/ApiClient.java index 56349ca0..b9697087 100755 --- a/src/main/java/com/razorpay/ApiClient.java +++ b/src/main/java/com/razorpay/ApiClient.java @@ -107,7 +107,7 @@ else if(response.code()==204){ responseJson = new JSONObject(responseBody); } } catch (IOException e) { - throw new RazorpayException(e.getMessage()); + throw new RazorpayException(e.getMessage(), statusCode); } catch (JSONException e) { throw new RazorpayException("Unable to parse response: " + e.getMessage(), statusCode); } @@ -173,7 +173,7 @@ T processResponse(Response response) throws RazorpayException responseJson = new JSONObject(responseBody); } } catch (IOException e) { - throw new RazorpayException(e.getMessage()); + throw new RazorpayException(e.getMessage(), statusCode); } catch (JSONException e) { throw new RazorpayException("Unable to parse response: " + e.getMessage(), statusCode); } @@ -200,7 +200,7 @@ ArrayList processCollectionResponse(Response response) responseBody = response.body().string(); responseJson = new JSONObject(responseBody); } catch (IOException e) { - throw new RazorpayException(e.getMessage()); + throw new RazorpayException(e.getMessage(), statusCode); } catch (JSONException e) { throw new RazorpayException("Unable to parse response: " + e.getMessage(), statusCode); } diff --git a/src/main/java/com/razorpay/RazorpayException.java b/src/main/java/com/razorpay/RazorpayException.java index 1f9d16e0..6d2b4092 100644 --- a/src/main/java/com/razorpay/RazorpayException.java +++ b/src/main/java/com/razorpay/RazorpayException.java @@ -4,7 +4,7 @@ public class RazorpayException extends Exception { - private JSONObject errorResponse; + private transient JSONObject errorResponse; private int statusCode; public RazorpayException(String message) { @@ -51,4 +51,28 @@ public JSONObject getErrorResponse() { public int getStatusCode() { return statusCode; } + + public String getCode() { + return errorResponse != null ? errorResponse.optString("code", null) : null; + } + + public String getDescription() { + return errorResponse != null ? errorResponse.optString("description", null) : null; + } + + public String getField() { + return errorResponse != null ? errorResponse.optString("field", null) : null; + } + + public String getReason() { + return errorResponse != null ? errorResponse.optString("reason", null) : null; + } + + public String getSource() { + return errorResponse != null ? errorResponse.optString("source", null) : null; + } + + public String getStep() { + return errorResponse != null ? errorResponse.optString("step", null) : null; + } } diff --git a/src/test/java/com/razorpay/RazorpayExceptionTest.java b/src/test/java/com/razorpay/RazorpayExceptionTest.java index b5248ef1..584bebf6 100644 --- a/src/test/java/com/razorpay/RazorpayExceptionTest.java +++ b/src/test/java/com/razorpay/RazorpayExceptionTest.java @@ -5,7 +5,9 @@ import org.junit.Test; import org.mockito.InjectMocks; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.ObjectOutputStream; import java.util.Collections; import static org.junit.Assert.*; @@ -141,4 +143,86 @@ public void testExceptionConstructorsDefaults() { assertEquals(502, ex3.getStatusCode()); assertNull(ex3.getErrorResponse()); } + + @Test + public void testExceptionIsSerializable() { + RazorpayException ex = new RazorpayException( + "BAD_REQUEST_ERROR:The amount must be at least 100", + new JSONObject(ERROR_RESPONSE_JSON).getJSONObject("error"), + 400 + ); + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(ex); + oos.close(); + } catch (IOException e) { + fail("RazorpayException with errorResponse should be serializable: " + e.getMessage()); + } + } + + @Test + public void testExceptionSerializableWithoutErrorResponse() { + RazorpayException ex = new RazorpayException("server error", 502); + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(ex); + oos.close(); + } catch (IOException e) { + fail("RazorpayException without errorResponse should be serializable: " + e.getMessage()); + } + } + + @Test + public void testSafeGettersWithFullError() { + try { + mockResponseFromExternalClient(ERROR_RESPONSE_JSON); + mockResponseHTTPCodeFromExternalClient(400); + mockURL(Collections.singletonList("orders")); + orderClient.create(new JSONObject("{\"amount\":50,\"currency\":\"INR\"}")); + fail("Expected RazorpayException"); + } catch (RazorpayException e) { + assertEquals("BAD_REQUEST_ERROR", e.getCode()); + assertEquals("The amount must be at least 100", e.getDescription()); + assertEquals("amount", e.getField()); + assertEquals("input_validation_failed", e.getReason()); + assertEquals("business", e.getSource()); + assertEquals("payment_initiation", e.getStep()); + } catch (IOException e) { + fail("Unexpected IOException"); + } + } + + @Test + public void testSafeGettersWithMissingFieldsReturnNull() { + String authErrorJson = "{\"error\":{\"code\":\"BAD_REQUEST_ERROR\",\"description\":\"Invalid API Key\"}}"; + try { + mockResponseFromExternalClient(authErrorJson); + mockResponseHTTPCodeFromExternalClient(401); + mockURL(Collections.singletonList("orders")); + orderClient.create(new JSONObject("{\"amount\":50,\"currency\":\"INR\"}")); + fail("Expected RazorpayException"); + } catch (RazorpayException e) { + assertEquals("BAD_REQUEST_ERROR", e.getCode()); + assertEquals("Invalid API Key", e.getDescription()); + assertNull(e.getField()); + assertNull(e.getReason()); + assertNull(e.getSource()); + assertNull(e.getStep()); + } catch (IOException e) { + fail("Unexpected IOException"); + } + } + + @Test + public void testSafeGettersReturnNullWhenNoErrorResponse() { + RazorpayException ex = new RazorpayException("network error", 503); + assertNull(ex.getCode()); + assertNull(ex.getDescription()); + assertNull(ex.getField()); + assertNull(ex.getReason()); + assertNull(ex.getSource()); + assertNull(ex.getStep()); + } } From caafcc343c75092603a8b378e1fb93bf398e9665 Mon Sep 17 00:00:00 2001 From: ankitdas13 Date: Tue, 28 Jul 2026 17:14:13 +0530 Subject: [PATCH 3/3] feat: add getMetadata() safe getter for error response Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/com/razorpay/RazorpayException.java | 4 ++++ src/test/java/com/razorpay/RazorpayExceptionTest.java | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/razorpay/RazorpayException.java b/src/main/java/com/razorpay/RazorpayException.java index 6d2b4092..9843dc4f 100644 --- a/src/main/java/com/razorpay/RazorpayException.java +++ b/src/main/java/com/razorpay/RazorpayException.java @@ -75,4 +75,8 @@ public String getSource() { public String getStep() { return errorResponse != null ? errorResponse.optString("step", null) : null; } + + public JSONObject getMetadata() { + return errorResponse != null ? errorResponse.optJSONObject("metadata") : null; + } } diff --git a/src/test/java/com/razorpay/RazorpayExceptionTest.java b/src/test/java/com/razorpay/RazorpayExceptionTest.java index 584bebf6..dba19472 100644 --- a/src/test/java/com/razorpay/RazorpayExceptionTest.java +++ b/src/test/java/com/razorpay/RazorpayExceptionTest.java @@ -26,7 +26,10 @@ public class RazorpayExceptionTest extends BaseTest { "\"source\":\"business\"," + "\"step\":\"payment_initiation\"," + "\"reason\":\"input_validation_failed\"," + - "\"metadata\":{}," + + "\"metadata\":{" + + "\"payment_id\":\"pay_EDNBKIP31Y4jl8\"," + + "\"order_id\":\"order_DBJKIP31Y4jl8\"" + + "}," + "\"field\":\"amount\"" + "}}"; @@ -189,6 +192,9 @@ public void testSafeGettersWithFullError() { assertEquals("input_validation_failed", e.getReason()); assertEquals("business", e.getSource()); assertEquals("payment_initiation", e.getStep()); + assertNotNull(e.getMetadata()); + assertEquals("pay_EDNBKIP31Y4jl8", e.getMetadata().getString("payment_id")); + assertEquals("order_DBJKIP31Y4jl8", e.getMetadata().getString("order_id")); } catch (IOException e) { fail("Unexpected IOException"); } @@ -210,6 +216,7 @@ public void testSafeGettersWithMissingFieldsReturnNull() { assertNull(e.getReason()); assertNull(e.getSource()); assertNull(e.getStep()); + assertNull(e.getMetadata()); } catch (IOException e) { fail("Unexpected IOException"); } @@ -224,5 +231,6 @@ public void testSafeGettersReturnNullWhenNoErrorResponse() { assertNull(ex.getReason()); assertNull(ex.getSource()); assertNull(ex.getStep()); + assertNull(ex.getMetadata()); } }