Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -363,20 +363,26 @@ protected <T> 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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions docs/src/doc/docs/docling-serve/serve-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/src/doc/docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading