From 076e8a505df6eccdcbb1383b92d379e82556f39a Mon Sep 17 00:00:00 2001 From: Eric Deandrea Date: Tue, 29 Sep 2026 16:55:44 -0400 Subject: [PATCH] fix(client): throw DoclingServeClientException for a 422 without validation details A 422 response whose body is a JSON object without validation details (for example {}, {"error":"..."} or {"detail":[]}) was deserialized into an empty ValidationError and thrown as a ValidationException with a blank message, losing the response body. Treat a ValidationError without details as not being a validation error and throw the generic DoclingServeClientException, which keeps the status code and the response body. Closes #710 Signed-off-by: Eric Deandrea --- .../serve/client/DoclingServeClient.java | 32 ++++++----- .../AbstractDoclingServeClientTests.java | 56 +++++++++++++++++++ docs/src/doc/docs/docling-serve/serve-api.md | 1 + docs/src/doc/docs/whats-new.md | 1 + 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java index e1d6cb71..924ac734 100644 --- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java @@ -363,20 +363,26 @@ protected T getResponse(HttpRequest request, HttpResponse response, Class } if (statusCode == 422) { - var validationError = readValue(body.toString(), ValidationError.class); - var errorText = validationError.getErrorDetails() - .stream() - .map(ValidationErrorDetail::getMessage) - .filter(Objects::nonNull) - .collect(Collectors.joining("\n")); - - throw new ValidationException( - validationError, "An error occurred while making %s request to %s:\n%s".formatted(request.method(), request.uri(), errorText) - ); - } - else { - throw new DoclingServeClientException("An error occurred: %s".formatted(body.toString()), statusCode, body.toString()); + // ValidationError is deserialized leniently, so any JSON object yields one. Only a + // ValidationError with details is a validation error; anything else is a generic error. + var validationError = Optional.ofNullable(readValue(body.toString(), ValidationError.class)) + .filter(error -> !error.getErrorDetails().isEmpty()); + + if (validationError.isPresent()) { + var errorText = validationError.get() + .getErrorDetails() + .stream() + .map(ValidationErrorDetail::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + + throw new ValidationException( + validationError.get(), "An error occurred while making %s request to %s:\n%s".formatted(request.method(), request.uri(), errorText) + ); + } } + + throw new DoclingServeClientException("An error occurred: %s".formatted(body.toString()), statusCode, body.toString()); } if (StreamResponse.class.equals(expectedReturnType)) { diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java index 2fb6740f..67401eef 100644 --- a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java +++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java @@ -51,6 +51,8 @@ import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.TestWatcher; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -555,6 +557,60 @@ private void stubHealth(int status, String contentType, String body) { } } + // A 422 response is only a validation error when its body carries validation details. ValidationError is read + // leniently, so any JSON object is read into one, which is empty when the body has no details + @Nested + class UnprocessableEntityResponseTests { + @AfterEach + void resetStubs() { + getWiremockServer().resetAll(); + } + + @ParameterizedTest + @ValueSource(strings = { + "{\"error\":\"gateway says no\"}", + "{}", + "{\"detail\":[]}" + }) + void jsonBodyWithoutValidationDetailsKeepsStatusAndBody(String body) { + stubHealth(body); + + assertThatThrownBy(() -> getDoclingClient(false, true).health()) + .isNotInstanceOf(ValidationException.class) + .asInstanceOf(InstanceOfAssertFactories.type(DoclingServeClientException.class)) + .returns(422, DoclingServeClientException::getStatusCode) + .returns(body, DoclingServeClientException::getResponseBody); + } + + @Test + void jsonBodyWithValidationDetailsIsAValidationException() { + stubHealth("{\"detail\":[{\"type\":\"missing\",\"loc\":[\"body\",\"sources\"],\"msg\":\"Field required\"}]}"); + + assertThatThrownBy(() -> getDoclingClient(false, true).health()) + .isNotInstanceOf(DoclingServeClientException.class) + .asInstanceOf(InstanceOfAssertFactories.throwable(ValidationException.class)) + .hasMessageEndingWith(":\nField required") + .extracting(ValidationException::getValidationError) + .extracting(ValidationError::getErrorDetails) + .asInstanceOf(InstanceOfAssertFactories.list(ValidationErrorDetail.class)) + .singleElement() + .extracting(ValidationErrorDetail::getMessage) + .isEqualTo("Field required"); + } + + private void stubHealth(String body) { + getWiremockServer().stubFor( + get(urlPathEqualTo("/health")) + .willReturn( + aResponse() + .withStatus(422) + .withHeader("Content-Type", "application/json") + .withBody(body) + ) + ); + } + } + @Nested class ConvertTests { static void assertConvertSingleHttpSourceWithDefaultTarget(ConvertDocumentResponse response) { diff --git a/docs/src/doc/docs/docling-serve/serve-api.md b/docs/src/doc/docs/docling-serve/serve-api.md index 275d64ab..22a7b919 100644 --- a/docs/src/doc/docs/docling-serve/serve-api.md +++ b/docs/src/doc/docs/docling-serve/serve-api.md @@ -266,6 +266,7 @@ you use. The reference client throws standard Java exceptions for HTTP and I/O f ### Validation errors In the case of a request validation error (i.e. `docling-serve` throws a `422` error), the docling-java API will throw an `ai.docling.serve.api.validation.ValidationException` which can be caught and inspected. +A `422` response that carries no validation details, such as one produced by a gateway or proxy in front of `docling-serve`, is reported as a `DoclingServeClientException`, like any other error response, so that the status code and the response body remain available. ## Logging and builders diff --git a/docs/src/doc/docs/whats-new.md b/docs/src/doc/docs/whats-new.md index 93f2f498..0922ef9e 100644 --- a/docs/src/doc/docs/whats-new.md +++ b/docs/src/doc/docs/whats-new.md @@ -32,6 +32,7 @@ Docling Java {{ gradle.project_version }} includes important breaking changes, a * **The builders of the reference client validate values when they are set** — Setting an invalid value on the builder of `DoclingServeJackson2Client` or `DoclingServeJackson3Client`, such as `connectTimeout(Duration.ZERO)` or `httpClientBuilder(null)`, now throws an `IllegalArgumentException` from the setter instead of from `build()`. The builders also gain `config(DoclingServeApiConfig)`, which applies a whole configuration at once, and `config()` on the reference client now reports only the options that were explicitly set. * **Custom `Executor` for async operations** — The async methods (`convertSourceAsync`, `convertSourceBatchAsync`, `convertFilesAsync`, `chunkSourceWith*ChunkerAsync`, ...) used to run on `CompletableFuture`'s default executor (usually `ForkJoinPool.commonPool()`). A new `asyncExecutor(Executor)` builder method lets you run them (task submission, status polling and result retrieval) on your own executor instead, e.g. a virtual-thread executor or one managed by your framework. When not set, the behavior is unchanged. The client never shuts the executor down. * **Logging a response that is not JSON no longer hides the error** — With both `logResponses()` and `prettyPrint()` enabled, a response body that is not JSON, such as an error page from a gateway or proxy in front of `docling-serve`, made the client fail with a raw Jackson exception while it was logging the response, so the status code and body were lost. The body is now logged as it is, and the request reports the usual `DoclingServeClientException`. The `readValue(String, Class)` method that subclasses of `DoclingServeClient` implement must now throw the new `JsonReadException`, with the failure of the JSON library as its cause, when the text is not valid JSON or does not match the expected type. +* **A `422` response without validation details is a `DoclingServeClientException`** — A `422` response whose body is a JSON object without any validation details, such as `{}`, `{"detail": []}` or an error object from a gateway or proxy in front of `docling-serve`, used to throw a `ValidationException` with an empty `ValidationError` and a blank message. It now throws a `DoclingServeClientException` with the status code and the response body, like any other error response. Only a `422` response that carries validation details throws a `ValidationException`. ### 0.6.6