Skip to content

fix(client): close unexpected task result stream - #699

Open
Ashfaqbs wants to merge 1 commit into
docling-project:mainfrom
Ashfaqbs:fix/task-result-close-unexpected-stream
Open

Ashfaqbs wants to merge 1 commit into
docling-project:mainfrom
Ashfaqbs:fix/task-result-close-unexpected-stream

Conversation

@Ashfaqbs

Copy link
Copy Markdown

What

TaskOperations.convertTaskResult opens the result as a stream and switches on the Content-Type. The JSON branch closes the stream (try-with-resources) and the ZIP branch hands it to the caller, but the default branch threw Invalid Content-Type in Task API response without closing it, so the underlying HTTP connection stayed open.

Change

  • Close the body before throwing in the default branch (a failed close is ignored, since the Content-Type error is the one worth reporting).
  • Add TaskOperationsTests, which uses a stub HttpOperations returning a text/html response and asserts the exception is thrown and the body is closed. It fails without the fix.

Spotless applied; the formatter also re-wrapped a few lines in the touched file.

@edeandrea

Copy link
Copy Markdown
Contributor

@all-contributors add @Ashfaqbs for code, bug

@allcontributors

Copy link
Copy Markdown
Contributor

@edeandrea

I've put up a pull request to add @Ashfaqbs! 🎉

@edeandrea edeandrea left a comment

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.

Thanks @Ashfaqbs for finding this! The unexpected-Content-Type branch is easy to miss because it only runs when the server misbehaves, and leaking the connection there is exactly the kind of bug that shows up later as pool exhaustion instead of a clear error. Thanks also for including a regression test that really guards the fix: I removed the closeQuietly(...) call locally and the test failed as it should. And thanks for explaining the Spotless reformat in the description. Our ratchetFrom("origin/main") config requires it once a file is touched, so there's nothing to change there.

I've left two small, non-blocking suggestions inline. Both are GitHub suggestion blocks, so you can add them all in one commit from the "Files changed" tab:

  1. Use try-with-resources in the default branch instead of the closeQuietly helper. This matches the JSON branch just above it, keeps a failed close() as a suppressed exception instead of dropping it, and doesn't throw a NullPointerException if an HttpOperations implementation returns a null body.
  2. A companion test for the failing-close() case.

I applied both locally: the tests pass and spotlessCheck is green.

Comment on lines +84 to +87
default -> {
closeQuietly(response.getBody());
throw new DoclingServeClientException(null, "Invalid Content-Type in Task API response");
}

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.

Suggestion (non-blocking): try-with-resources would do the same job as closeQuietly and handle two edge cases better:

  • Failed close(): the Content-Type error is still what gets thrown, but the IOException from close() is attached to it as a suppressed exception instead of being dropped, so it still shows up in logs and stack traces.
  • null body: try-with-resources skips closing a null resource, so a custom HttpOperations that returns no body gets the Content-Type error. With closeQuietly it gets NullPointerException: Cannot invoke "java.io.InputStream.close()", which hides the real problem.

It also matches the CONTENT_TYPE_JSON branch above.

Suggested change
default -> {
closeQuietly(response.getBody());
throw new DoclingServeClientException(null, "Invalid Content-Type in Task API response");
}
default -> {
try (var ignored = response.getBody()) {
throw new DoclingServeClientException(null, "Invalid Content-Type in Task API response");
}
catch (IOException e) {
// never reached in practice: a close() failure is added as suppressed to the exception above
throw new DoclingServeClientException(e);
}
}

The catch is only there because InputStream.close() declares IOException. The one exception that leaves the try is the unchecked DoclingServeClientException, so the comment says it isn't a real code path.

Comment on lines +109 to +117
private static void closeQuietly(InputStream inputStream) {
try {
inputStream.close();
}
catch (IOException ignored) {
// the Content-Type error is the failure worth reporting
}
}

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.

If you take the try-with-resources suggestion above, this helper is no longer needed:

Suggested change
private static void closeQuietly(InputStream inputStream) {
try {
inputStream.close();
}
catch (IOException ignored) {
// the Content-Type error is the failure worth reporting
}
}

package ai.docling.serve.client.operations;

import java.io.IOException;
import java.io.InputStream;

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.

...and this import is unused once closeQuietly goes:

Suggested change
import java.io.InputStream;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.io.ByteArrayInputStream;
import java.io.InputStream;

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.

Needed by the test suggested below:

Suggested change
import java.io.InputStream;
import java.io.IOException;
import java.io.InputStream;

.isInstanceOf(DoclingServeClientException.class)
.hasMessageContaining("Invalid Content-Type");
assertThat(closed).isTrue();
}

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.

Optional: a companion test for the failing-close() case. It fails on the current code (getSuppressed() is empty) and passes with the try-with-resources suggestion:

Suggested change
}
}
@Test
void convertTaskResultKeepsCloseFailureAsSuppressedOnUnexpectedContentType() {
var body = new ByteArrayInputStream(new byte[0]) {
@Override
public void close() throws IOException {
throw new IOException("close failed");
}
};
var taskOperations = new TaskOperations(new StubHttpOperations(body, "text/html"));
var request = TaskResultRequest.builder().taskId("task-1").build();
assertThatThrownBy(() -> taskOperations.convertTaskResult(request))
.isInstanceOf(DoclingServeClientException.class)
.hasMessageContaining("Invalid Content-Type")
.satisfies(t -> assertThat(t.getSuppressed())
.singleElement()
.isInstanceOf(IOException.class)
.hasFieldOrPropertyWithValue("message", "close failed"));
}

@github-actions

Copy link
Copy Markdown

:java_duke: JaCoCo coverage report

Overall Project 49.61% 🔴

There is no coverage information present for the Files changed

@github-actions

Copy link
Copy Markdown
TestsPassed ✅SkippedFailed
Gradle Test Results (all modules & JDKs)2088 ran2088 passed0 skipped0 failed
TestResult
No test annotations available

@github-actions

Copy link
Copy Markdown

HTML test reports are available as workflow artifacts (zipped HTML).

• Download: Artifacts for this run

convertTaskResult threw on an unrecognised Content-Type without closing
the response body, leaking the underlying HTTP connection. Close it
before throwing and cover the case with a unit test.

Signed-off-by: Ashfaqbs <105435085+Ashfaqbs@users.noreply.github.com>
@edeandrea
edeandrea force-pushed the fix/task-result-close-unexpected-stream branch from c95c319 to 9d29e1f Compare September 25, 2026 05:11

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants