Skip to content

fix: stream non-replayable request bodies in the JDK transport#141

Open
OmarAlJarrah wants to merge 1 commit into
mainfrom
fix/jdk-transport-streaming-body
Open

fix: stream non-replayable request bodies in the JDK transport#141
OmarAlJarrah wants to merge 1 commit into
mainfrom
fix/jdk-transport-streaming-body

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

Problem

The java.net.http transport routed every request body above the 64 KiB eager threshold (or of unknown declared length) through a "streaming" publisher that first forced the body replayable via RequestBody.toReplayable(). That drains the entire body into an in-memory Buffer before publishing a single byte.

For exactly the bodies that path exists to serve — large or unknown-length one-shot uploads — this defeated streaming:

  • Heap blow-up proportional to body size: a 2 GiB upload needed ~2 GiB of contiguous heap.
  • A buffer-backed body cannot exceed the byte-array/segment limits, so very large uploads failed outright instead of streaming.
  • Transport divergence: the same SDK request streamed fine under the OkHttp transport but OOM'd under the JDK transport.

Change

sdk-transport-jdkhttp/.../internal/BodyPublishers.kt now streams the first subscription directly from the body, with no up-front buffering:

  • Replayable bodies are unchanged — each subscription re-reads the body from its source, so the proxy-auth (407) retry and HTTP/2 GOAWAY replay still carry the full bytes.
  • Non-replayable (one-shot) bodies stream their first subscription straight from the source. A consumed one-shot body cannot be replayed, so a second subscription fails loudly with a clear "supply a replayable body" error rather than shipping a truncated or empty body. This matches the consume-once discipline of OneShotInputStreamRequestBody / BufferedSourceRequestBody in sdk-core: a resend of a one-shot body is a caller error, surfaced visibly instead of corrupting the request. Callers that need resend support supply a replayable body.

JDK 11 detail

The resend refusal is raised as an UncheckedIOException wrapping the explanatory IOException, not a bare checked IOException. The JDK 11 ofInputStream reader (StreamIterator) catches a checked IOException from read() and swallows it as a silent end-of-stream, which would let the resend complete with a truncated body. An unchecked throw is not caught there, so it propagates — synchronously on the subscriber's request stack under JDK 11, and via onError on later JDKs — with the original cause preserved.

No public/explicit API change (BodyPublishers is internal); apiCheck passes with no apiDump.

Tests

  • nonReplayableStreamingBodyIsNotBufferedUpFront — asserts the body's writeTo has not run after adaptBody returns (the decisive, non-flaky signal that the body was not drained up front), then that it streams the full body on subscription.
  • oneShotStreamingBodyRefusesResendLoudly — first subscription streams the full one-shot body; the second fails loudly with an IOException carrying the replayable-body message.
  • streamingBodyThatFailsMidWriteFailsLoudly — a non-replayable body that aborts mid-write surfaces as an IOException (no silent truncation).

Gated build

Ran (module-scoped, --no-daemon):

./gradlew :sdk-transport-jdkhttp:test :sdk-transport-jdkhttp:ktlintCheck :sdk-transport-jdkhttp:apiCheck --no-daemon

Result: BUILD SUCCESSFUL (detekt is skipped on this module per the repo's JDK-11 toolchain note).

Closes #113

The java.net.http transport routed every request body above the 64 KiB
eager threshold (or of unknown length) through a "streaming" publisher
that first forced the body replayable via toReplayable(), draining the
entire body into an in-memory Buffer before publishing a single byte. For
the bodies that path exists to serve — large or unknown-length one-shot
uploads — this defeated streaming: a 2 GiB upload needed ~2 GiB of
contiguous heap, and a body above the byte-array/segment limits failed
outright. The same SDK request streamed fine under the OkHttp transport,
so the two transports diverged on the one-shot streaming contract.

Stream the first subscription directly from the body, with no up-front
buffering:

- Replayable bodies are unchanged — each subscription re-reads the body
  from its source, so proxy-auth (407) retries and HTTP/2 GOAWAY replays
  still carry the full bytes.
- Non-replayable (one-shot) bodies stream their first subscription
  straight from the source. A consumed one-shot body cannot be replayed,
  so a second subscription fails loudly with a clear "supply a replayable
  body" error rather than shipping a truncated or empty body. This matches
  the consume-once discipline of OneShotInputStreamRequestBody /
  BufferedSourceRequestBody in sdk-core and keeps internal resends correct
  by failing visibly instead of corrupting the request.

The resend refusal is raised as an UncheckedIOException wrapping the
explanatory IOException: the JDK 11 ofInputStream reader catches a checked
IOException from read() and swallows it as a silent end-of-stream, which
would let the resend complete truncated. An unchecked throw propagates —
synchronously on the request stack under JDK 11, via onError on later JDKs.

Tests: a non-replayable streaming body's writeTo is asserted not to have
run after adaptBody returns (proving no up-front drain), a one-shot body's
resend is asserted to fail loudly with the replayable-body message, and a
mid-write failure is asserted to surface as an IOException.

Closes #113
@OmarAlJarrah OmarAlJarrah changed the title Stream non-replayable request bodies in the JDK transport fix: stream non-replayable request bodies in the JDK transport Jun 17, 2026
@OmarAlJarrah

Copy link
Copy Markdown
Member Author

This stops the JDK transport from draining a large/unknown-length non-replayable body fully into heap before publishing — the old toReplayable() in streamingPublisher defeated streaming and risked OOM. The new approach streams the first subscription straight from the source and hands later resends a ResendRefusedInputStream that fails loudly, with UncheckedIOException chosen so the JDK 11 StreamIterator doesn't swallow it as a silent EOF. The tests cover the key cases (no up-front drain, full stream, loud resend refusal, mid-write failure). Looks good to merge once the doc nit below is addressed.

Issues

  • Dangling KDoc reference to a non-existent oneShotSuppliersdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/BodyPublishers.kt:167. The streamingPublisher KDoc says "...so [oneShotSupplier] returns a stream that fails its first read...", but there is no oneShotSupplier member — the one-shot logic was inlined into streamingPublisher (the firstSubscription AtomicBoolean + ResendRefusedInputStream branch). KDoc cross-references aren't compile-checked and detekt is skipped on this module, so the build stays green, but it renders as a dead link in generated docs. Suggest pointing it at [ResendRefusedInputStream] or just rewording to describe the inline behaviour.

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.

JDK transport fully buffers non-replayable streaming request bodies in memory

1 participant