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/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 diff --git a/docs/src/doc/docs/whats-new.md b/docs/src/doc/docs/whats-new.md index eb23be0f..eb5356fe 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. 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()`.