Skip to content
Open
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 @@ -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
Expand Down Expand Up @@ -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;
Comment on lines +83 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field shape is right, and I want to say so explicitly because the alternative is tempting. Nullable String plus @Nullable matches the four fields already on this type; returning Optional would follow our general "Optional over null" guidance but would make this one field behave unlike its neighbors inside a Lombok wire-format DTO. Consistency is the better call here.

And on what's deliberately not here, so it doesn't look like an oversight: upstream also carries failure: PublicFailureInfo (category / message / retryable / phase). Leaving it out is correct for this PR — it's Ray-only and v1.25.0+, while error_message works everywhere we support. I've opened #778 for it, which also captures an unknown-enum-value policy question we need to settle before adding FailureCategory as a Java enum.


/**
* Builder for creating {@link TaskStatusPollResponse} instances.
* Generated by Lombok's {@code @Builder} annotation.
Expand All @@ -90,6 +101,7 @@ public class TaskStatusPollResponse {
* <li>{@code taskStatus(TaskStatus)} - Set the task status</li>
* <li>{@code taskPosition(Long)} - Set the queue position</li>
* <li>{@code taskStatusMetadata(TaskStatusMetadata)} - Set the task metadata</li>
* <li>{@code errorMessage(String)} - Set the error message</li>
* </ul>
*/
public static class Builder {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -128,8 +129,11 @@ private <O> CompletionStage<O> 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");
Comment on lines +132 to 137

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging this so you don't wonder whether I skipped it: it's correct as written, including dropping the TaskStatusMetadata dump when there's a reason to show instead.

I'd actually drafted a comment asking you to keep the counts, on the theory that a failed batch would be reporting something like "3 of 10 documents failed." It isn't. FAILURE only ever gets set from an except Exception handler — per-document failures go into the result's errors list and the task still completes SUCCESS. So those counts are whatever partial progress had been recorded when the task died, not a failure tally. Swapping them for the actual reason is a straight improvement, and the dump was only ever there because nothing better was available.


yield CompletableFuture.failedStage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>();
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
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 @@ -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 `<T, B> 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()`.
Expand Down