From 412676c2ba5353a98d4d57afadf1ae962c4f7534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Mon, 26 Oct 2026 09:20:29 +0100 Subject: [PATCH 1/4] feat: expose typed Problem details on API errors Expose typed Problem details and preserve error response context. Add focused regression coverage and concise integration examples. --- README.md | 19 ++++++ .../java/com/sumup/sdk/core/ApiClient.java | 37 +++++++++-- .../sdk/core/ApiConnectionException.java | 8 +++ .../java/com/sumup/sdk/core/ApiException.java | 41 +++++++++++- .../sumup/sdk/core/ApiStatusException.java | 18 ++++++ .../sumup/sdk/core/ApiTimeoutException.java | 8 +++ .../com/sumup/sdk/core/ApiClientTest.java | 63 ++++++++++++++++--- 7 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/sumup/sdk/core/ApiConnectionException.java create mode 100644 src/main/java/com/sumup/sdk/core/ApiStatusException.java create mode 100644 src/main/java/com/sumup/sdk/core/ApiTimeoutException.java diff --git a/README.md b/README.md index 1b1a4a6..dbaeb50 100644 --- a/README.md +++ b/README.md @@ -307,3 +307,22 @@ export SUMUP_MERCHANT_CODE="your_merchant_code" Build the aggregated API reference locally with `just javadoc` (or `./gradlew aggregateJavadoc`). The HTML output is placed under `build/docs/javadoc/index.html`. [docs-badge]: https://img.shields.io/badge/SumUp-documentation-white.svg?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgY29sb3I9IndoaXRlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPHBhdGggZD0iTTIyLjI5IDBIMS43Qy43NyAwIDAgLjc3IDAgMS43MVYyMi4zYzAgLjkzLjc3IDEuNyAxLjcxIDEuN0gyMi4zYy45NCAwIDEuNzEtLjc3IDEuNzEtMS43MVYxLjdDMjQgLjc3IDIzLjIzIDAgMjIuMjkgMFptLTcuMjIgMTguMDdhNS42MiA1LjYyIDAgMCAxLTcuNjguMjQuMzYuMzYgMCAwIDEtLjAxLS40OWw3LjQ0LTcuNDRhLjM1LjM1IDAgMCAxIC40OSAwIDUuNiA1LjYgMCAwIDEtLjI0IDcuNjlabTEuNTUtMTEuOS03LjQ0IDcuNDVhLjM1LjM1IDAgMCAxLS41IDAgNS42MSA1LjYxIDAgMCAxIDcuOS03Ljk2bC4wMy4wM2MuMTMuMTMuMTQuMzUuMDEuNDlaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPC9zdmc+ + +## Handling errors + +Catch `ApiStatusException` to inspect an unsuccessful API response: + +```java +import com.sumup.sdk.core.ApiStatusException; + +try { + client.checkouts().get("your-checkout-id"); +} catch (ApiStatusException error) { + error.getProblem().ifPresent(problem -> + System.out.println(problem.type() + ": " + problem.detail())); +} +``` + +`getStatusCode()`, `getResponseBody()`, and `getResponseHeaders()` provide the HTTP +status, original response text, and headers. Connection failures raise +`ApiConnectionException`; timeouts raise `ApiTimeoutException`. Both keep the original cause. diff --git a/src/main/java/com/sumup/sdk/core/ApiClient.java b/src/main/java/com/sumup/sdk/core/ApiClient.java index a7fc87d..8d3ca48 100644 --- a/src/main/java/com/sumup/sdk/core/ApiClient.java +++ b/src/main/java/com/sumup/sdk/core/ApiClient.java @@ -83,7 +83,7 @@ public T send( if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } - throw new ApiException("Failed to execute request", e); + throw transportException(e); } } @@ -109,8 +109,18 @@ public CompletableFuture sendAsync( buildRequest(method, path, queryParams, headerParams, body, requestOptions); return httpClient .sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply( - response -> { + .handle( + (response, failure) -> { + if (failure != null) { + Throwable cause = + failure instanceof java.util.concurrent.CompletionException + && failure.getCause() != null + ? failure.getCause() + : failure; + if (cause instanceof java.util.concurrent.CancellationException cancelled) + throw cancelled; + throw transportException(cause); + } try { return readResponse(response, responseType); } catch (IOException e) { @@ -169,7 +179,26 @@ private T readResponse(HttpResponse response, TypeReference respo } return objectMapper.readValue(responseBody, responseType); } - throw new ApiException("Request failed", status, response.body()); + com.sumup.sdk.models.Problem problem = null; + try { + com.fasterxml.jackson.databind.JsonNode json = objectMapper.readTree(response.body()); + if (json != null && json.hasNonNull("type") && json.get("type").isTextual()) { + problem = objectMapper.treeToValue(json, com.sumup.sdk.models.Problem.class); + } + } catch (IOException | IllegalArgumentException ignored) { + // Preserve unrecognized or malformed bodies in the HTTP exception. + } + String message = + problem != null && problem.detail() != null ? problem.detail() : "Request failed"; + throw new ApiStatusException( + message, status, response.body(), problem, response.headers(), response.uri()); + } + + private static ApiConnectionException transportException(Throwable cause) { + if (cause instanceof java.net.http.HttpTimeoutException) { + return new ApiTimeoutException("Request timed out", cause); + } + return new ApiConnectionException("Failed to execute request", cause); } private Duration effectiveTimeout(RequestOptions requestOptions) { diff --git a/src/main/java/com/sumup/sdk/core/ApiConnectionException.java b/src/main/java/com/sumup/sdk/core/ApiConnectionException.java new file mode 100644 index 0000000..4fa399e --- /dev/null +++ b/src/main/java/com/sumup/sdk/core/ApiConnectionException.java @@ -0,0 +1,8 @@ +package com.sumup.sdk.core; + +/** The request could not be completed. */ +public class ApiConnectionException extends ApiException { + public ApiConnectionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/core/ApiException.java b/src/main/java/com/sumup/sdk/core/ApiException.java index ea79351..c14a747 100644 --- a/src/main/java/com/sumup/sdk/core/ApiException.java +++ b/src/main/java/com/sumup/sdk/core/ApiException.java @@ -1,22 +1,48 @@ package com.sumup.sdk.core; +import com.sumup.sdk.models.Problem; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.util.List; +import java.util.Map; +import java.util.Optional; + /** * Exception thrown when the SumUp API responds with a non-success status or the HTTP request fails. */ -public final class ApiException extends RuntimeException { +public class ApiException extends RuntimeException { private final int statusCode; private final String responseBody; + private final Problem problem; + private final Map> responseHeaders; + private final URI requestUri; public ApiException(String message, int statusCode, String responseBody) { + this(message, statusCode, responseBody, null, null, null); + } + + public ApiException( + String message, + int statusCode, + String responseBody, + Problem problem, + HttpHeaders headers, + URI requestUri) { super(message + " (" + statusCode + ")"); this.statusCode = statusCode; this.responseBody = responseBody; + this.problem = problem; + this.responseHeaders = headers == null ? Map.of() : headers.map(); + this.requestUri = requestUri; } public ApiException(String message, Throwable cause) { super(message, cause); this.statusCode = -1; this.responseBody = null; + this.problem = null; + this.responseHeaders = Map.of(); + this.requestUri = null; } public int getStatusCode() { @@ -26,4 +52,17 @@ public int getStatusCode() { public String getResponseBody() { return responseBody; } + + /** Typed Problem details, when the API returned a Problem response. */ + public Optional getProblem() { + return Optional.ofNullable(problem); + } + + public Map> getResponseHeaders() { + return responseHeaders; + } + + public Optional getRequestUri() { + return Optional.ofNullable(requestUri); + } } diff --git a/src/main/java/com/sumup/sdk/core/ApiStatusException.java b/src/main/java/com/sumup/sdk/core/ApiStatusException.java new file mode 100644 index 0000000..46d45db --- /dev/null +++ b/src/main/java/com/sumup/sdk/core/ApiStatusException.java @@ -0,0 +1,18 @@ +package com.sumup.sdk.core; + +import com.sumup.sdk.models.Problem; +import java.net.URI; +import java.net.http.HttpHeaders; + +/** The API returned an unsuccessful HTTP response. */ +public final class ApiStatusException extends ApiException { + public ApiStatusException( + String message, + int status, + String body, + Problem problem, + HttpHeaders headers, + URI requestUri) { + super(message, status, body, problem, headers, requestUri); + } +} diff --git a/src/main/java/com/sumup/sdk/core/ApiTimeoutException.java b/src/main/java/com/sumup/sdk/core/ApiTimeoutException.java new file mode 100644 index 0000000..49ff59a --- /dev/null +++ b/src/main/java/com/sumup/sdk/core/ApiTimeoutException.java @@ -0,0 +1,8 @@ +package com.sumup.sdk.core; + +/** The request timed out. */ +public class ApiTimeoutException extends ApiConnectionException { + public ApiTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/test/java/com/sumup/sdk/core/ApiClientTest.java b/src/test/java/com/sumup/sdk/core/ApiClientTest.java index 3d04a0e..a078fce 100644 --- a/src/test/java/com/sumup/sdk/core/ApiClientTest.java +++ b/src/test/java/com/sumup/sdk/core/ApiClientTest.java @@ -1,6 +1,8 @@ package com.sumup.sdk.core; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.sumup.sdk.clients.ReadersClient; @@ -124,8 +126,51 @@ void readerPathParamsEncodeStrings() { httpClient.lastRequest().uri()); } + @Test + void apiErrorsExposeTypedProblemsAndKeepResponseContext() { + String body = "{\"type\":\"about:blank\",\"detail\":\"Invalid amount\"}"; + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(400, body)).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + assertEquals("Invalid amount", error.getProblem().orElseThrow().detail()); + assertEquals(body, error.getResponseBody()); + assertEquals(400, error.getStatusCode()); + assertEquals("10", error.getResponseHeaders().get("Retry-After").get(0)); + java.util.concurrent.CompletionException asyncError = + assertThrows( + java.util.concurrent.CompletionException.class, + () -> client.sendAsync(HttpMethod.GET, "/error", null, null, null, null, null).join()); + assertInstanceOf(ApiStatusException.class, asyncError.getCause()); + } + + @Test + void malformedErrorBodiesRemainHttpErrors() { + ApiClient client = + ApiClient.builder().httpClient(new CapturingHttpClient(502, "{invalid")).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + assertTrue(error.getProblem().isEmpty()); + assertEquals("{invalid", error.getResponseBody()); + assertEquals(502, error.getStatusCode()); + } + private static final class CapturingHttpClient extends HttpClient { private HttpRequest lastRequest; + private final int status; + private final String body; + + CapturingHttpClient() { + this(200, ""); + } + + CapturingHttpClient(int status, String body) { + this.status = status; + this.body = body; + } HttpRequest lastRequest() { return lastRequest; @@ -180,7 +225,7 @@ public Optional executor() { public HttpResponse send( HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { this.lastRequest = request; - return (HttpResponse) new CapturingHttpResponse(request); + return (HttpResponse) new CapturingHttpResponse(request, status, body); } @Override @@ -188,7 +233,7 @@ public CompletableFuture> sendAsync( HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { this.lastRequest = request; return CompletableFuture.completedFuture( - (HttpResponse) new CapturingHttpResponse(request)); + (HttpResponse) new CapturingHttpResponse(request, status, body)); } @Override @@ -198,20 +243,24 @@ public CompletableFuture> sendAsync( HttpResponse.PushPromiseHandler pushPromiseHandler) { this.lastRequest = request; return CompletableFuture.completedFuture( - (HttpResponse) new CapturingHttpResponse(request)); + (HttpResponse) new CapturingHttpResponse(request, status, body)); } } private static final class CapturingHttpResponse implements HttpResponse { private final HttpRequest request; + private final int status; + private final String body; - CapturingHttpResponse(HttpRequest request) { + CapturingHttpResponse(HttpRequest request, int status, String body) { this.request = request; + this.status = status; + this.body = body; } @Override public int statusCode() { - return 200; + return status; } @Override @@ -226,12 +275,12 @@ public Optional> previousResponse() { @Override public HttpHeaders headers() { - return HttpHeaders.of(Map.of(), (a, b) -> true); + return HttpHeaders.of(Map.of("Retry-After", java.util.List.of("10")), (a, b) -> true); } @Override public String body() { - return ""; + return body; } @Override From 5a4beb9b474f3735dd4d167a504c84ab8ed927b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Mon, 26 Oct 2026 18:25:02 +0100 Subject: [PATCH 2/4] fix(codegen): preserve null-valued Problem extensions Keep the additional properties map immutable while allowing valid JSON null values. Cover typed API errors containing a null extension. --- .../internal/generator/templates/model.tmpl | 2 +- .../java/com/sumup/sdk/models/Problem.java | 3 ++- .../java/com/sumup/sdk/core/ApiClientTest.java | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/codegen/internal/generator/templates/model.tmpl b/codegen/internal/generator/templates/model.tmpl index 6d7bdfc..f8e7ed4 100644 --- a/codegen/internal/generator/templates/model.tmpl +++ b/codegen/internal/generator/templates/model.tmpl @@ -82,7 +82,7 @@ public record {{ .ClassName }}( public {{ .ClassName }} { additionalProperties = additionalProperties == null ? java.util.Map.of() - : java.util.Map.copyOf(additionalProperties); + : java.util.Collections.unmodifiableMap(new java.util.LinkedHashMap<>(additionalProperties)); } @JsonAnyGetter diff --git a/src/main/java/com/sumup/sdk/models/Problem.java b/src/main/java/com/sumup/sdk/models/Problem.java index 820a6f9..e8abebf 100644 --- a/src/main/java/com/sumup/sdk/models/Problem.java +++ b/src/main/java/com/sumup/sdk/models/Problem.java @@ -34,7 +34,8 @@ public record Problem( additionalProperties = additionalProperties == null ? java.util.Map.of() - : java.util.Map.copyOf(additionalProperties); + : java.util.Collections.unmodifiableMap( + new java.util.LinkedHashMap<>(additionalProperties)); } @JsonAnyGetter diff --git a/src/test/java/com/sumup/sdk/core/ApiClientTest.java b/src/test/java/com/sumup/sdk/core/ApiClientTest.java index a078fce..4f3f3cb 100644 --- a/src/test/java/com/sumup/sdk/core/ApiClientTest.java +++ b/src/test/java/com/sumup/sdk/core/ApiClientTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -145,6 +146,23 @@ void apiErrorsExposeTypedProblemsAndKeepResponseContext() { assertInstanceOf(ApiStatusException.class, asyncError.getCause()); } + @Test + void problemExtensionsCanContainNull() { + String body = "{\"type\":\"about:blank\",\"detail\":\"Token expired\",\"trace_id\":null}"; + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(401, body)).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + var problem = error.getProblem().orElseThrow(); + assertEquals("Token expired", problem.detail()); + assertTrue(problem.additionalProperties().containsKey("trace_id")); + assertNull(problem.additionalProperties().get("trace_id")); + assertThrows( + UnsupportedOperationException.class, + () -> problem.additionalProperties().put("trace_id", "changed")); + } + @Test void malformedErrorBodiesRemainHttpErrors() { ApiClient client = From 9eb3d1e5408bd229df0b7f2bdf53b4916e57bfe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Mon, 26 Oct 2026 18:47:50 +0100 Subject: [PATCH 3/4] fix: distinguish response decoding from connection failures Keep synchronous and asynchronous error classification consistent, and use Problem titles when details are absent. Put error handling beside usage and move contributor instructions out of the README. --- CONTRIBUTING.md | 5 ++ README.md | 42 ++++++----- .../java/com/sumup/sdk/core/ApiClient.java | 41 +++++++---- .../com/sumup/sdk/core/ApiClientTest.java | 71 ++++++++++++++++++- 4 files changed, 123 insertions(+), 36 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a15a270 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +## Generating Javadoc + +Build the aggregated API reference locally with `just javadoc` (or `./gradlew aggregateJavadoc`). The HTML output is placed under `build/docs/javadoc/index.html`. diff --git a/README.md b/README.md index dbaeb50..187d4aa 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,25 @@ readerIdFuture .join(); ``` +## Handling errors + +Catch `ApiStatusException` to inspect an unsuccessful API response: + +```java +import com.sumup.sdk.core.ApiStatusException; + +try { + client.checkouts().get("your-checkout-id"); +} catch (ApiStatusException error) { + error.getProblem().ifPresent(problem -> + System.out.println(problem.type() + ": " + problem.detail())); +} +``` + +`getStatusCode()`, `getResponseBody()`, and `getResponseHeaders()` provide the HTTP +status, original response text, and headers. Connection failures raise +`ApiConnectionException`; timeouts raise `ApiTimeoutException`. Both keep the original cause. + ## Handling events ```java @@ -302,27 +321,6 @@ export SUMUP_MERCHANT_CODE="your_merchant_code" ./gradlew :examples:card-reader-checkout:run ``` -## Generating Javadoc - -Build the aggregated API reference locally with `just javadoc` (or `./gradlew aggregateJavadoc`). The HTML output is placed under `build/docs/javadoc/index.html`. +For development instructions, see [Contributing](CONTRIBUTING.md). [docs-badge]: https://img.shields.io/badge/SumUp-documentation-white.svg?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgY29sb3I9IndoaXRlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPHBhdGggZD0iTTIyLjI5IDBIMS43Qy43NyAwIDAgLjc3IDAgMS43MVYyMi4zYzAgLjkzLjc3IDEuNyAxLjcxIDEuN0gyMi4zYy45NCAwIDEuNzEtLjc3IDEuNzEtMS43MVYxLjdDMjQgLjc3IDIzLjIzIDAgMjIuMjkgMFptLTcuMjIgMTguMDdhNS42MiA1LjYyIDAgMCAxLTcuNjguMjQuMzYuMzYgMCAwIDEtLjAxLS40OWw3LjQ0LTcuNDRhLjM1LjM1IDAgMCAxIC40OSAwIDUuNiA1LjYgMCAwIDEtLjI0IDcuNjlabTEuNTUtMTEuOS03LjQ0IDcuNDVhLjM1LjM1IDAgMCAxLS41IDAgNS42MSA1LjYxIDAgMCAxIDcuOS03Ljk2bC4wMy4wM2MuMTMuMTMuMTQuMzUuMDEuNDlaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPC9zdmc+ - -## Handling errors - -Catch `ApiStatusException` to inspect an unsuccessful API response: - -```java -import com.sumup.sdk.core.ApiStatusException; - -try { - client.checkouts().get("your-checkout-id"); -} catch (ApiStatusException error) { - error.getProblem().ifPresent(problem -> - System.out.println(problem.type() + ": " + problem.detail())); -} -``` - -`getStatusCode()`, `getResponseBody()`, and `getResponseHeaders()` provide the HTTP -status, original response text, and headers. Connection failures raise -`ApiConnectionException`; timeouts raise `ApiTimeoutException`. Both keep the original cause. diff --git a/src/main/java/com/sumup/sdk/core/ApiClient.java b/src/main/java/com/sumup/sdk/core/ApiClient.java index 8d3ca48..e23852a 100644 --- a/src/main/java/com/sumup/sdk/core/ApiClient.java +++ b/src/main/java/com/sumup/sdk/core/ApiClient.java @@ -4,8 +4,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sumup.sdk.models.Problem; import java.io.IOException; import java.lang.reflect.Array; import java.net.URI; @@ -13,6 +15,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.OffsetDateTime; @@ -23,7 +26,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; /** Minimal HTTP client wrapper used by the generated SDK clients. */ public final class ApiClient { @@ -75,16 +80,20 @@ public T send( RequestOptions requestOptions) { HttpRequest request = buildRequest(method, path, queryParams, headerParams, body, requestOptions); + HttpResponse response; try { - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - return readResponse(response, responseType); + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } throw transportException(e); } + try { + return readResponse(response, responseType); + } catch (IOException e) { + throw new ApiException("Failed to decode response", e); + } } public CompletableFuture sendAsync( @@ -113,18 +122,18 @@ public CompletableFuture sendAsync( (response, failure) -> { if (failure != null) { Throwable cause = - failure instanceof java.util.concurrent.CompletionException - && failure.getCause() != null + failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure; - if (cause instanceof java.util.concurrent.CancellationException cancelled) + if (cause instanceof CancellationException cancelled) { throw cancelled; + } throw transportException(cause); } try { return readResponse(response, responseType); } catch (IOException e) { - throw new ApiException("Failed to execute request", e); + throw new ApiException("Failed to decode response", e); } }); } @@ -179,23 +188,29 @@ private T readResponse(HttpResponse response, TypeReference respo } return objectMapper.readValue(responseBody, responseType); } - com.sumup.sdk.models.Problem problem = null; + Problem problem = null; try { - com.fasterxml.jackson.databind.JsonNode json = objectMapper.readTree(response.body()); + JsonNode json = objectMapper.readTree(response.body()); if (json != null && json.hasNonNull("type") && json.get("type").isTextual()) { - problem = objectMapper.treeToValue(json, com.sumup.sdk.models.Problem.class); + problem = objectMapper.treeToValue(json, Problem.class); } } catch (IOException | IllegalArgumentException ignored) { // Preserve unrecognized or malformed bodies in the HTTP exception. } - String message = - problem != null && problem.detail() != null ? problem.detail() : "Request failed"; + String message = "Request failed"; + if (problem != null) { + if (problem.detail() != null && !problem.detail().isBlank()) { + message = problem.detail(); + } else if (problem.title() != null && !problem.title().isBlank()) { + message = problem.title(); + } + } throw new ApiStatusException( message, status, response.body(), problem, response.headers(), response.uri()); } private static ApiConnectionException transportException(Throwable cause) { - if (cause instanceof java.net.http.HttpTimeoutException) { + if (cause instanceof HttpTimeoutException) { return new ApiTimeoutException("Request timed out", cause); } return new ApiConnectionException("Failed to execute request", cause); diff --git a/src/test/java/com/sumup/sdk/core/ApiClientTest.java b/src/test/java/com/sumup/sdk/core/ApiClientTest.java index 4f3f3cb..17928ec 100644 --- a/src/test/java/com/sumup/sdk/core/ApiClientTest.java +++ b/src/test/java/com/sumup/sdk/core/ApiClientTest.java @@ -1,12 +1,16 @@ package com.sumup.sdk.core; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.core.type.TypeReference; import com.sumup.sdk.clients.ReadersClient; +import java.io.IOException; import java.net.Authenticator; import java.net.CookieHandler; import java.net.ProxySelector; @@ -163,6 +167,17 @@ void problemExtensionsCanContainNull() { () -> problem.additionalProperties().put("trace_id", "changed")); } + @Test + void problemTitleIsUsedWhenDetailIsMissing() { + String body = "{\"type\":\"about:blank\",\"title\":\"Unauthorized\"}"; + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(401, body)).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + assertEquals("Unauthorized (401)", error.getMessage()); + } + @Test void malformedErrorBodiesRemainHttpErrors() { ApiClient client = @@ -176,10 +191,54 @@ void malformedErrorBodiesRemainHttpErrors() { assertEquals(502, error.getStatusCode()); } + @Test + void malformedSuccessIsNotAConnectionFailure() { + ApiClient client = + ApiClient.builder().httpClient(new CapturingHttpClient(200, "{invalid")).build(); + var type = new TypeReference>() {}; + ApiException error = + assertThrows( + ApiException.class, + () -> client.send(HttpMethod.GET, "/test", null, null, null, type, null)); + assertFalse(error instanceof ApiConnectionException); + assertEquals("Failed to decode response", error.getMessage()); + var asyncError = + assertThrows( + java.util.concurrent.CompletionException.class, + () -> client.sendAsync(HttpMethod.GET, "/test", null, null, null, type, null).join()); + assertEquals(ApiException.class, asyncError.getCause().getClass()); + assertEquals(error.getMessage(), asyncError.getCause().getMessage()); + } + + @Test + void transportFailuresPreserveTheirTypeAndCause() { + for (IOException cause : + new IOException[] { + new IOException("Disconnected"), new java.net.http.HttpTimeoutException("Deadline") + }) { + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(cause)).build(); + var error = + assertThrows( + ApiConnectionException.class, + () -> client.send(HttpMethod.GET, "/test", null, null, null, null, null)); + assertSame(cause, error.getCause()); + assertEquals( + cause instanceof java.net.http.HttpTimeoutException, + error instanceof ApiTimeoutException); + var asyncError = + assertThrows( + java.util.concurrent.CompletionException.class, + () -> client.sendAsync(HttpMethod.GET, "/test", null, null, null, null, null).join()); + assertEquals(error.getClass(), asyncError.getCause().getClass()); + assertSame(cause, asyncError.getCause().getCause()); + } + } + private static final class CapturingHttpClient extends HttpClient { private HttpRequest lastRequest; private final int status; private final String body; + private final IOException failure; CapturingHttpClient() { this(200, ""); @@ -188,6 +247,13 @@ private static final class CapturingHttpClient extends HttpClient { CapturingHttpClient(int status, String body) { this.status = status; this.body = body; + this.failure = null; + } + + CapturingHttpClient(IOException failure) { + this.status = 0; + this.body = ""; + this.failure = failure; } HttpRequest lastRequest() { @@ -241,8 +307,9 @@ public Optional executor() { @Override public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) throws IOException { this.lastRequest = request; + if (failure != null) throw failure; return (HttpResponse) new CapturingHttpResponse(request, status, body); } @@ -250,6 +317,7 @@ public HttpResponse send( public CompletableFuture> sendAsync( HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { this.lastRequest = request; + if (failure != null) return CompletableFuture.failedFuture(failure); return CompletableFuture.completedFuture( (HttpResponse) new CapturingHttpResponse(request, status, body)); } @@ -260,6 +328,7 @@ public CompletableFuture> sendAsync( HttpResponse.BodyHandler responseBodyHandler, HttpResponse.PushPromiseHandler pushPromiseHandler) { this.lastRequest = request; + if (failure != null) return CompletableFuture.failedFuture(failure); return CompletableFuture.completedFuture( (HttpResponse) new CapturingHttpResponse(request, status, body)); } From b2ba4468051d3199bd03825e4972bd40c94efae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Mon, 26 Oct 2026 19:36:08 +0100 Subject: [PATCH 4/4] fix: replace deprecated Jackson inclusion setter Use the supported default property inclusion API while preserving the existing omission of null properties and map entries. --- src/main/java/com/sumup/sdk/core/ApiClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/sumup/sdk/core/ApiClient.java b/src/main/java/com/sumup/sdk/core/ApiClient.java index e23852a..9f27a92 100644 --- a/src/main/java/com/sumup/sdk/core/ApiClient.java +++ b/src/main/java/com/sumup/sdk/core/ApiClient.java @@ -363,7 +363,7 @@ private static ObjectMapper defaultObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); return mapper; }