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