From 7d1992f9de98d178fc7ceb6fc861aeac7915a016 Mon Sep 17 00:00:00 2001
From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com>
Date: Wed, 4 Nov 2026 08:17:41 +0000
Subject: [PATCH 1/3] fix(client): report the error message of a failed async
task
Signed-off-by: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com>
---
.../task/response/TaskStatusPollResponse.java | 12 +++++++
.../client/operations/AsyncOperations.java | 8 +++--
...tDoclingServeClientAsyncExecutorTests.java | 32 +++++++++++++++++++
docs/src/doc/docs/whats-new.md | 1 +
4 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/task/response/TaskStatusPollResponse.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/task/response/TaskStatusPollResponse.java
index 25c9ed49..797a74a4 100644
--- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/task/response/TaskStatusPollResponse.java
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/task/response/TaskStatusPollResponse.java
@@ -24,6 +24,7 @@
* - {@code taskStatus}: The current status of the task, as defined in the {@code TaskStatus} enum.
* - {@code taskPosition}: A numerical representation of the task's position in the processing queue.
* - {@code taskStatusMetadata}: Metadata providing detailed statistics and progress information (optional).
+ * - {@code errorMessage}: The error message reported by Docling Serve when the task failed (optional).
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@lombok.extern.jackson.Jacksonized
@@ -79,6 +80,16 @@ public class TaskStatusPollResponse {
@Nullable
private TaskStatusMetadata taskStatusMetadata;
+ /**
+ * Error message reported by Docling Serve when the task failed.
+ *
+ * @param errorMessage the error message, or null if not available
+ * @return the error message, or null if not available
+ */
+ @JsonProperty("error_message")
+ @Nullable
+ private String errorMessage;
+
/**
* Builder for creating {@link TaskStatusPollResponse} instances.
* Generated by Lombok's {@code @Builder} annotation.
@@ -90,6 +101,7 @@ public class TaskStatusPollResponse {
*
{@code taskStatus(TaskStatus)} - Set the task status
* {@code taskPosition(Long)} - Set the queue position
* {@code taskStatusMetadata(TaskStatusMetadata)} - Set the task metadata
+ * {@code errorMessage(String)} - Set the error message
*
*/
public static class Builder {
diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/AsyncOperations.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/AsyncOperations.java
index c7ca0f97..d611548f 100644
--- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/AsyncOperations.java
+++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/AsyncOperations.java
@@ -16,6 +16,7 @@
import ai.docling.serve.api.task.request.TaskResultRequest;
import ai.docling.serve.api.task.request.TaskStatusPollRequest;
import ai.docling.serve.api.task.response.TaskStatusPollResponse;
+import ai.docling.serve.api.util.Utils;
import ai.docling.serve.api.util.ValidationUtils;
/**
@@ -128,8 +129,11 @@ private CompletionStage pollTaskStatus(TaskStatusPollResponse statusRespo
}
case FAILURE -> {
- var errorMessage = Optional.ofNullable(statusResponse.getTaskStatusMetadata())
- .map(metadata -> "Task failed: %s".formatted(metadata))
+ // Prefer the reason reported by Docling Serve, as the task metadata only holds document counts
+ var errorMessage = Optional.ofNullable(statusResponse.getErrorMessage())
+ .filter(Utils::isNotNullOrBlank)
+ .or(() -> Optional.ofNullable(statusResponse.getTaskStatusMetadata()).map(Object::toString))
+ .map(reason -> "Task failed: %s".formatted(reason))
.orElse("Task failed");
yield CompletableFuture.failedStage(
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java
index d93c54e0..6cdf808b 100644
--- a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java
@@ -155,6 +155,38 @@ void failedTaskRunsEveryPollOnCustomExecutor() {
getWireMock().verify(0, getRequestedFor(urlPathEqualTo(RESULT_PATH)));
}
+ @Test
+ void failedTaskReportsTheErrorMessageOfTheServer() {
+ getWireMock().stubFor(
+ post(urlPathEqualTo("/v1/convert/source/async"))
+ .willReturn(okJson(taskStatus("pending")))
+ );
+
+ getWireMock().stubFor(
+ get(urlPathEqualTo(POLL_PATH))
+ .willReturn(okJson("""
+ {
+ "task_id": "%s",
+ "task_status": "failure",
+ "task_meta": {
+ "num_docs": 1,
+ "num_processed": 1,
+ "num_succeeded": 0,
+ "num_failed": 1
+ },
+ "error_message": "Input document is not valid"
+ }
+ """.formatted(TASK_ID)))
+ );
+
+ var future = client(null)
+ .convertSourceAsync(convertRequest())
+ .toCompletableFuture();
+
+ assertThatThrownBy(() -> future.get(10, TimeUnit.SECONDS))
+ .hasRootCauseMessage("Async conversion failed for task %s: Task failed: Input document is not valid".formatted(TASK_ID));
+ }
+
@Test
void asyncOperationsWorkWithoutCustomExecutor() throws Exception {
stubTask("/v1/convert/source/async", "success");
diff --git a/docs/src/doc/docs/whats-new.md b/docs/src/doc/docs/whats-new.md
index eb23be0f..415fb497 100644
--- a/docs/src/doc/docs/whats-new.md
+++ b/docs/src/doc/docs/whats-new.md
@@ -26,6 +26,7 @@ Docling Java {{ gradle.project_version }} includes important breaking changes, a
### {{ gradle.project_version }}
+* **A failed async task reports the reason given by Docling Serve** — When an async conversion or chunking task failed, the exception only showed the task metadata, which holds document counts, so the cause of the failure was lost. `TaskStatusPollResponse` now has an `errorMessage` field, mapped from the `error_message` that Docling Serve returns, and the exception message includes it when it is present.
* **New `HeadingHierarchyOptions` and `headingHierarchyOptions` on `ConvertDocumentOptions`** — When set, `headingHierarchyOptions` enables PDF section-header level inference from bookmarks, outline numbering and visual style. The helper getter `doPdfHeadingHierarchy()` is derived from the presence of these options (returns `true` when non-null, `false` otherwise), so callers only need to pass the options to enable hierarchy inference. The options class covers all docling-serve fields (`useBookmarks`, `useNumbering`, `useStyle`, `useFontStyle`, `styleSizeTolerance`, `numberingSchemes`, `maxLevel`, `bookmarkMatchThreshold`).
* **Breaking: new `DoclingServeApiProvider` SPI** — Implementations are now provided through `ai.docling.serve.api.spi.DoclingServeApiProvider`, which receives an immutable `DoclingServeApiConfig`, so new options can be added without breaking implementations. A provider can declare the options it doesn't honor via `unsupportedOptions()`, to have them ignored, logged or rejected when a caller sets them. `DoclingServeApi` has a new abstract `config()` method. `DoclingServeApiBuilderFactory`, `DoclingServeApi.DoclingApiBuilder` and `DoclingServeApi.toBuilder()` are deprecated for removal; legacy factories are still used when no provider is found. `docling-serve-api` now depends on `slf4j-api`. See [Migrating to `DoclingServeApiProvider`](docling-serve/serve-api-provider-migration.md).
* **Breaking: `DoclingServeApi.builder()` returns a `DoclingServeApiBuilder`** — It used to return the builder of the implementation through a generic ` B` return type. Fluent chains and `var` declarations are unaffected, but code declaring the builder type must be updated. Client-specific settings such as `httpClientBuilder` are available from `DoclingServeClient.builder()`, which detects the version of Jackson the same way, and the JSON mapper from `DoclingServeJackson3Client.builder()` / `DoclingServeJackson2Client.builder()`, which are now public. An API can be copied with `api.config().toBuilder()`.
From bdc20c4570da65d96eed684c265dc15be8c54abb Mon Sep 17 00:00:00 2001
From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com>
Date: Fri, 6 Nov 2026 20:46:46 +0000
Subject: [PATCH 2/3] test(client): cover how a failed async task is reported
Signed-off-by: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com>
---
...tDoclingServeClientAsyncExecutorTests.java | 32 -----
.../AbstractDoclingServeClientTests.java | 122 ++++++++++++++++++
2 files changed, 122 insertions(+), 32 deletions(-)
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java
index 6cdf808b..d93c54e0 100644
--- a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientAsyncExecutorTests.java
@@ -155,38 +155,6 @@ void failedTaskRunsEveryPollOnCustomExecutor() {
getWireMock().verify(0, getRequestedFor(urlPathEqualTo(RESULT_PATH)));
}
- @Test
- void failedTaskReportsTheErrorMessageOfTheServer() {
- getWireMock().stubFor(
- post(urlPathEqualTo("/v1/convert/source/async"))
- .willReturn(okJson(taskStatus("pending")))
- );
-
- getWireMock().stubFor(
- get(urlPathEqualTo(POLL_PATH))
- .willReturn(okJson("""
- {
- "task_id": "%s",
- "task_status": "failure",
- "task_meta": {
- "num_docs": 1,
- "num_processed": 1,
- "num_succeeded": 0,
- "num_failed": 1
- },
- "error_message": "Input document is not valid"
- }
- """.formatted(TASK_ID)))
- );
-
- var future = client(null)
- .convertSourceAsync(convertRequest())
- .toCompletableFuture();
-
- assertThatThrownBy(() -> future.get(10, TimeUnit.SECONDS))
- .hasRootCauseMessage("Async conversion failed for task %s: Task failed: Input document is not valid".formatted(TASK_ID));
- }
-
@Test
void asyncOperationsWorkWithoutCustomExecutor() throws Exception {
stubTask("/v1/convert/source/async", "success");
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 f3aaffef..023d9ce4 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
@@ -33,12 +33,14 @@
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Flow.Subscriber;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
@@ -655,6 +657,126 @@ private void stubHealth(String contentType, String body) {
}
}
+ // A failed async task carries the reason in error_message. task_meta only holds document counts, so the client
+ // falls back to it only when no reason is given
+ @Nested
+ class AsyncTaskFailureTests {
+ private static final String TASK_ID = "failed-task";
+ private static final String ERROR_MESSAGE = "Input document is not valid";
+ private static final String TASK_META = """
+ {
+ "num_docs": 1,
+ "num_processed": 1,
+ "num_succeeded": 0,
+ "num_failed": 1
+ }
+ """;
+ private static final String TASK_META_STRING = "TaskStatusMetadata(numDocs=1, numProcessed=1, numSucceeded=0, numFailed=1)";
+
+ @AfterEach
+ void resetStubs() {
+ getWiremockServer().resetAll();
+ }
+
+ @Test
+ void convertReportsTheErrorMessage() {
+ stubFailedTask("/v1/convert/source/async", failure(TASK_META, "\"%s\"".formatted(ERROR_MESSAGE)));
+
+ assertConvertFailsWith("Task failed: %s".formatted(ERROR_MESSAGE));
+ }
+
+ // docling-serve never sends a blank error_message, so only this test guards the blank check
+ @Test
+ void convertWithBlankErrorMessageReportsTheTaskMetadata() {
+ stubFailedTask("/v1/convert/source/async", failure(TASK_META, "\"\""));
+
+ assertConvertFailsWith("Task failed: %s".formatted(TASK_META_STRING));
+ }
+
+ @Test
+ void convertWithoutTaskMetadataReportsTheErrorMessage() {
+ stubFailedTask("/v1/convert/source/async", failure(null, "\"%s\"".formatted(ERROR_MESSAGE)));
+
+ assertConvertFailsWith("Task failed: %s".formatted(ERROR_MESSAGE));
+ }
+
+ @Test
+ void convertWithoutErrorMessageReportsTheTaskMetadata() {
+ stubFailedTask("/v1/convert/source/async", failure(TASK_META, null));
+
+ assertConvertFailsWith("Task failed: %s".formatted(TASK_META_STRING));
+ }
+
+ @Test
+ void chunkReportsTheErrorMessage() {
+ stubFailedTask("/v1/chunk/hybrid/source/async", failure(TASK_META, "\"%s\"".formatted(ERROR_MESSAGE)));
+
+ var request = HybridChunkDocumentRequest.builder()
+ .source(HttpSource.builder().url(URI.create("https://example.com/document.pdf")).build())
+ .build();
+
+ var future = getDoclingClient(false, true)
+ .chunkSourceWithHybridChunkerAsync(request)
+ .toCompletableFuture();
+
+ assertFailsWith(future, "Task failed: %s".formatted(ERROR_MESSAGE));
+ }
+
+ private void assertConvertFailsWith(String reason) {
+ var request = ConvertDocumentRequest.builder()
+ .source(HttpSource.builder().url(URI.create("https://example.com/document.pdf")).build())
+ .build();
+
+ var future = getDoclingClient(false, true)
+ .convertSourceAsync(request)
+ .toCompletableFuture();
+
+ assertFailsWith(future, reason);
+ }
+
+ private void assertFailsWith(CompletableFuture> future, String reason) {
+ assertThatThrownBy(() -> future.get(10, TimeUnit.SECONDS))
+ .hasRootCauseMessage("Async conversion failed for task %s: %s".formatted(TASK_ID, reason));
+ }
+
+ private void stubFailedTask(String asyncPath, String pollResponse) {
+ getWiremockServer().stubFor(
+ post(urlPathEqualTo(asyncPath))
+ .willReturn(okJson("""
+ {
+ "task_id": "%s",
+ "task_type": "convert",
+ "task_status": "pending",
+ "task_position": 0,
+ "task_meta": null
+ }
+ """.formatted(TASK_ID)))
+ );
+
+ getWiremockServer().stubFor(
+ get(urlPathEqualTo("/v1/status/poll/%s".formatted(TASK_ID)))
+ .willReturn(okJson(pollResponse))
+ );
+ }
+
+ // A null argument leaves the field out of the response
+ private String failure(@Nullable String taskMeta, @Nullable String errorMessage) {
+ var fields = new ArrayList();
+ fields.add("\"task_id\": \"%s\"".formatted(TASK_ID));
+ fields.add("\"task_status\": \"failure\"");
+
+ if (taskMeta != null) {
+ fields.add("\"task_meta\": %s".formatted(taskMeta));
+ }
+
+ if (errorMessage != null) {
+ fields.add("\"error_message\": %s".formatted(errorMessage));
+ }
+
+ return "{%s}".formatted(String.join(", ", fields));
+ }
+ }
+
// docling-serve sends "text": "" for a formula converted without formula enrichment and for a list item that only
// introduces a nested list. The text and orig fields are required strings in the DoclingDocument schema, so a document
// written back to JSON must keep them
From 1c7fb933c0d067db922310954cd2dd5fe90edf47 Mon Sep 17 00:00:00 2001
From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com>
Date: Fri, 6 Nov 2026 20:46:46 +0000
Subject: [PATCH 3/3] docs: note that the error message depends on error
sanitization
Signed-off-by: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com>
---
docs/src/doc/docs/whats-new.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/src/doc/docs/whats-new.md b/docs/src/doc/docs/whats-new.md
index 415fb497..eb5356fe 100644
--- a/docs/src/doc/docs/whats-new.md
+++ b/docs/src/doc/docs/whats-new.md
@@ -26,7 +26,7 @@ Docling Java {{ gradle.project_version }} includes important breaking changes, a
### {{ gradle.project_version }}
-* **A failed async task reports the reason given by Docling Serve** — When an async conversion or chunking task failed, the exception only showed the task metadata, which holds document counts, so the cause of the failure was lost. `TaskStatusPollResponse` now has an `errorMessage` field, mapped from the `error_message` that Docling Serve returns, and the exception message includes it when it is present.
+* **A failed async task reports the reason given by Docling Serve** — When an async conversion or chunking task failed, the exception only showed the task metadata, which holds document counts, so the cause of the failure was lost. `TaskStatusPollResponse` now has an `errorMessage` field, mapped from the `error_message` that Docling Serve returns, and the exception message includes it when it is present. How much detail that message carries depends on the server's error-sanitization setting: by default Docling Serve reports a generic `Internal processing error.` for most infrastructure failures, and the underlying exception text only when it is started with `DOCLING_SERVE_DEBUG_ERROR_DETAILS=true`.
* **New `HeadingHierarchyOptions` and `headingHierarchyOptions` on `ConvertDocumentOptions`** — When set, `headingHierarchyOptions` enables PDF section-header level inference from bookmarks, outline numbering and visual style. The helper getter `doPdfHeadingHierarchy()` is derived from the presence of these options (returns `true` when non-null, `false` otherwise), so callers only need to pass the options to enable hierarchy inference. The options class covers all docling-serve fields (`useBookmarks`, `useNumbering`, `useStyle`, `useFontStyle`, `styleSizeTolerance`, `numberingSchemes`, `maxLevel`, `bookmarkMatchThreshold`).
* **Breaking: new `DoclingServeApiProvider` SPI** — Implementations are now provided through `ai.docling.serve.api.spi.DoclingServeApiProvider`, which receives an immutable `DoclingServeApiConfig`, so new options can be added without breaking implementations. A provider can declare the options it doesn't honor via `unsupportedOptions()`, to have them ignored, logged or rejected when a caller sets them. `DoclingServeApi` has a new abstract `config()` method. `DoclingServeApiBuilderFactory`, `DoclingServeApi.DoclingApiBuilder` and `DoclingServeApi.toBuilder()` are deprecated for removal; legacy factories are still used when no provider is found. `docling-serve-api` now depends on `slf4j-api`. See [Migrating to `DoclingServeApiProvider`](docling-serve/serve-api-provider-migration.md).
* **Breaking: `DoclingServeApi.builder()` returns a `DoclingServeApiBuilder`** — It used to return the builder of the implementation through a generic ` B` return type. Fluent chains and `var` declarations are unaffected, but code declaring the builder type must be updated. Client-specific settings such as `httpClientBuilder` are available from `DoclingServeClient.builder()`, which detects the version of Jackson the same way, and the JSON mapper from `DoclingServeJackson3Client.builder()` / `DoclingServeJackson2Client.builder()`, which are now public. An API can be copied with `api.config().toBuilder()`.