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()`.