Summary
The java.net.http transport materialises every non-replayable streaming request body into a heap Buffer before sending it, defeating the streaming path it sits on. A large one-shot upload (e.g. a 2 GiB InputStream-backed body) is copied in full into memory rather than streamed, which blows the heap and hard-fails past ~2 GiB. The OkHttp transport streams the same body fine.
Where
sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/BodyPublishers.kt
-
adaptBody routes any body with contentLength > 64 KiB or unknown length (-1) to streamingPublisher (lines 118-124).
-
streamingPublisher then immediately forces the body replayable up front (lines 165-170):
val replayable: SdkRequestBody =
if (body.isReplayable()) {
body
} else {
try {
body.toReplayable()
...
-
RequestBody.toReplayable (sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt, lines 78-83) drains the whole body into an in-memory Buffer:
public open fun toReplayable(provider: IoProvider = Io.provider): RequestBody {
if (isReplayable()) return this
val buffer = provider.buffer()
writeTo(buffer)
return BufferRequestBody(buffer, mediaType(), buffer.size)
}
So on the "streaming" path, a body that is by definition large or of unknown length is fully read into heap before a single byte is published. The pipe-and-daemon-writer machinery downstream (newSubscriptionStream) then streams from the already-buffered in-memory copy — it never streams from the original source. The eager (<= 64 KiB) path is fine; the problem is that the streaming path buffers too.
Why it buffers
The JDK invokes the ofInputStream supplier once per subscription and re-subscribes the same publisher on internal resends (407 proxy-auth retry, HTTP/2 GOAWAY replay — see the class KDoc, lines 50-58). To make a second subscription replay the same bytes, the code makes the body replayable up front. The cost of that decision is full materialisation of every large/unknown-length one-shot body.
Failure mode
- Heap blow-up proportional to body size; a 2 GiB upload needs ~2 GiB of contiguous heap.
- A buffer-backed body cannot exceed the max byte-array/segment limits, so very large uploads fail outright instead of streaming.
- Transport divergence: OkHttp honours the one-shot streaming contract for the same body, so the same SDK request that streams under OkHttp OOMs under the JDK transport.
Suggested direction
Stream the first subscription directly from the one-shot body (no up-front buffering), and make a second subscription fail loudly with a clear "one-shot body cannot be re-sent — supply a replayable body" error, matching the consume-once discipline already used by OneShotInputStreamRequestBody / BufferedSourceRequestBody in sdk-core. Reserve up-front buffering for bodies below a bounded threshold (mirroring the existing 64 KiB eager cutoff) so small bodies can still be replayed cheaply, while large/unknown-length bodies stream and refuse silent resend-corruption. This keeps the proxy-auth / GOAWAY resend correct (it fails visibly rather than truncating) without importing the OOM hazard the streaming path exists to avoid.
Relationship to PR #82
This is related to, but distinct from, the open PR #82 (fix: correct transport request-body handling for body-less and unbufferable streaming bodies). PR #82 only changes the catch (IOException) fallback in streamingPublisher so that a toReplayable() failure mid-write fails loudly (UncheckedIOException) instead of re-driving an already-consumed body. It does not alter the up-front toReplayable() buffering of non-replayable bodies — that line is byte-for-byte identical on main and on the PR #82 branch. The memory/heap issue described here remains regardless of whether PR #82 merges.
Summary
The
java.net.httptransport materialises every non-replayable streaming request body into a heapBufferbefore sending it, defeating the streaming path it sits on. A large one-shot upload (e.g. a 2 GiBInputStream-backed body) is copied in full into memory rather than streamed, which blows the heap and hard-fails past ~2 GiB. The OkHttp transport streams the same body fine.Where
sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/BodyPublishers.ktadaptBodyroutes any body withcontentLength > 64 KiBor unknown length (-1) tostreamingPublisher(lines 118-124).streamingPublisherthen immediately forces the body replayable up front (lines 165-170):RequestBody.toReplayable(sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt, lines 78-83) drains the whole body into an in-memoryBuffer:So on the "streaming" path, a body that is by definition large or of unknown length is fully read into heap before a single byte is published. The pipe-and-daemon-writer machinery downstream (
newSubscriptionStream) then streams from the already-buffered in-memory copy — it never streams from the original source. The eager (<= 64 KiB) path is fine; the problem is that the streaming path buffers too.Why it buffers
The JDK invokes the
ofInputStreamsupplier once per subscription and re-subscribes the same publisher on internal resends (407 proxy-auth retry, HTTP/2GOAWAYreplay — see the class KDoc, lines 50-58). To make a second subscription replay the same bytes, the code makes the body replayable up front. The cost of that decision is full materialisation of every large/unknown-length one-shot body.Failure mode
Suggested direction
Stream the first subscription directly from the one-shot body (no up-front buffering), and make a second subscription fail loudly with a clear "one-shot body cannot be re-sent — supply a replayable body" error, matching the consume-once discipline already used by
OneShotInputStreamRequestBody/BufferedSourceRequestBodyinsdk-core. Reserve up-front buffering for bodies below a bounded threshold (mirroring the existing 64 KiB eager cutoff) so small bodies can still be replayed cheaply, while large/unknown-length bodies stream and refuse silent resend-corruption. This keeps the proxy-auth /GOAWAYresend correct (it fails visibly rather than truncating) without importing the OOM hazard the streaming path exists to avoid.Relationship to PR #82
This is related to, but distinct from, the open PR #82 (
fix: correct transport request-body handling for body-less and unbufferable streaming bodies). PR #82 only changes thecatch (IOException)fallback instreamingPublisherso that atoReplayable()failure mid-write fails loudly (UncheckedIOException) instead of re-driving an already-consumed body. It does not alter the up-fronttoReplayable()buffering of non-replayable bodies — that line is byte-for-byte identical onmainand on the PR #82 branch. The memory/heap issue described here remains regardless of whether PR #82 merges.