This document covers the design of the SDK's HTTP body abstractions, the body logging system, and the concurrency decisions behind them.
- Overview
- HTTP Body Abstractions
- Body Logging System
- Internal Stream Utilities
- Concurrency Design
- Edge Cases and Safety
- Usage Examples
The SDK's HTTP bodies are built on its own Okio-inspired I/O abstraction rather than directly
on java.io. sdk-core defines the interfaces — Source/Sink, BufferedSource/BufferedSink,
Buffer, TeeSink — and carries zero runtime dependencies; a concrete implementation plugs in
through a single IoProvider seam. The reference provider is OkioIoProvider in sdk-io-okio3,
backed by Okio 3.x. Because sdk-core codes against the abstraction, the body layer keeps the
JDK 8+ target while staying safe to use from platform threads, virtual threads (Project
Loom), Kotlin coroutines, and reactive streams.
The body logging system wraps request and response bodies to capture their content for diagnostics without consuming the underlying data or altering the HTTP data flow.
| File | Package | Purpose |
|---|---|---|
RequestBody.kt |
http.request |
Abstract request body with factory methods (writeTo(BufferedSink)) |
ResponseBody.kt |
http.response |
Abstract response body exposing source(): BufferedSource |
LoggableRequestBody.kt |
http.request |
Tee-write wrapper that taps request bytes into a Buffer |
LoggableResponseBody.kt |
http.response |
Drain-once wrapper with thread-safe init and repeatable reads |
TeeSink.kt |
io |
BufferedSink that mirrors each write into a tap Buffer |
io/*.kt |
io |
Buffer-centric memory stream system (see I/O Module) |
RequestBody is an abstract class with a single core method:
abstract fun writeTo(sink: BufferedSink)Design decision: BufferedSink over a raw OutputStream.
The writeTo contract pushes bytes to a BufferedSink provided by the transport. The sink is
one of the SDK's own I/O interfaces, so typed writes (writeUtf8, writeString, write(ByteArray),
writeAll(source)) and buffer-to-buffer segment moves are available without forcing every body
to think in terms of a byte-at-a-time stream. Transports translate the sink to their native
output once, at the boundary.
Factory methods cover the common body shapes:
| Factory | Replayable | Backing |
|---|---|---|
create(source: BufferedSource, …) |
No (single-use) | BufferedSource drained and closed by writeTo |
create(buffer: Buffer, …) |
Yes | In-memory Buffer, read via non-consuming peek() |
create(bytes: ByteArray, …) |
Yes | ByteArray written directly |
create(content: String, …) |
Yes | Encoded to a ByteArray at construction |
create(input: InputStream, length, …) |
Conditional | Replayable when the stream supports mark/reset and length fits in a mark readLimit; otherwise single-use |
create(file: Path, …) |
Yes | FileRequestBody; transports may dispatch a zero-copy sendfile(2) |
create(formData: Map, …) |
Yes | URL-encoded once, delegates to the ByteArray shape |
Single-use bodies (BufferedSource-backed, non-resettable InputStream-backed) guard against
a second writeTo with an AtomicBoolean and throw IllegalStateException rather than silently
emitting zero bytes. isReplayable() reports whether writeTo is safe to call again, and
toReplayable(provider) drains a single-use body once into an in-memory Buffer and returns a
buffer-backed replayable equivalent — call it before writeTo when retries may be needed.
ResponseBody is an abstract Closeable class with a single read entry point:
abstract fun source(): BufferedSourceDesign decision: BufferedSource over a raw InputStream.
The response body exposes a BufferedSource for efficient reads. A BufferedSource can be
adapted to a java.io.InputStream (source().inputStream()) when a caller needs the JDK type,
so nothing is lost by speaking the SDK's I/O abstraction at the contract.
Single-use contract: source() returns the same source instance, and once consumed the
bytes are gone. The body must be closed after use to release the underlying connection —
prefer use {}. For repeatable access, wrap with LoggableResponseBody.
Both bodies are written against sdk-core's I/O interfaces, never a concrete implementation.
The single IoProvider is wired once at startup via Io.installProvider(provider); touching
Io.provider before a provider is installed throws IllegalStateException with the install
instruction. Production code installs the provider in the application startup path; tests use
the test-fixtures helper org.dexpace.sdk.core.testing.withProvider for scoped overrides. The
only shipping implementation today is OkioIoProvider (sdk-io-okio3), backed by Okio 3.x.
This is what keeps sdk-core dependency-free: the Okio dependency lives entirely in the adapter
module. See the I/O Module document for the segment model behind Buffer.
The logging system provides non-destructive observation of HTTP body content. Requests and
responses use complementary strategies, but both funnel captured bytes into a Buffer and
expose them through snapshot():
LoggableRequestBodytaps bytes as they stream to the transport (tee-write), so observation is a side effect of the normal write and never doubles a single-use body.LoggableResponseBodydrains the response once into aBufferand serves every subsequent read from that buffer, turning a single-use body into a repeatable one.
In both cases the captured bytes are surfaced as a raw ByteArray via snapshot() /
snapshot(maxBytes). The wrappers do not interpret the bytes — charset resolution and text
detection are the caller's job (the request/response MediaType exposes charset for that).
Problem: Capture request body bytes for logging without consuming the body or altering the write to the HTTP transport.
Solution: Tee-write. When writeTo(sink) is called, the wrapper clears its tap buffer and
then drives the delegate's writeTo through a TeeSink, which mirrors every write into both the
transport sink and an internal tap Buffer:
delegate.writeTo(...)
│
▼
TeeSink
│ │
▼ primary ▼ tap
Transport BufferedSink Buffer (segment-based)
(HTTP transport) │
▼
snapshot() → ByteArray (full captured body)
snapshot(maxBytes) → ByteArray (bounded prefix)
Why tee-write over pre-buffering?
An alternative approach would buffer the entire body first, then write from the buffer. Tee-write is superior because:
- It preserves single-use semantics —
BufferedSource-backed bodies work correctly. - It doesn't double the write cost for reusable bodies.
- Bytes reach the transport as they are produced, preserving streaming behavior.
- The capture is a side effect of the write, not a prerequisite.
Multi-attempt safety: each writeTo clears the tap before capturing, so a retry against a
replayable delegate keeps the snapshot in lock-step with the most recent attempt rather than
accumulating doubled bytes. If the delegate's writeTo throws partway, snapshot() returns the
bytes tee'd up to the failure point — useful for diagnosing a failed request. The wrapper mirrors
the delegate's replayability: isReplayable() delegates, and toReplayable() returns a new
LoggableRequestBody wrapping the delegate's replayable form.
Problem: Log response body content without consuming the stream that the caller needs, and without buffering an arbitrarily large body into memory.
Solution: Drain a bounded prefix on first access. The wrapper reads at most maxCaptureBytes
bytes from the delegate into an internal Buffer; what happens next depends on whether the body
fit within that cap:
First access (source / snapshot / captureException):
read up to maxCaptureBytes from delegate.source() ──► Buffer (the capture prefix)
Body fit within the cap (default, unlimited):
delegate closed; capture is the whole body
│
▼
Subsequent access: captured.peek() (fresh non-consuming view, fully repeatable)
Body exceeded the cap:
delegate left OPEN; only the prefix is buffered
│
▼
source() (once): captured.peek() ─then─► live delegate tail
(one-shot: replays the prefix, then continues from the wire)
When the whole body fits within maxCaptureBytes the behavior is the classic drain-once: the
delegate is closed and every source() call returns a fresh peek() view. When the body is
larger than the cap, only the preview prefix is buffered, the delegate stays open, and source()
returns a one-shot stream that replays the captured prefix and then continues from the live
delegate tail — so the caller still receives the complete body. Because the tail is single-use, a
second source() call on an over-cap body throws IllegalStateException.
The default maxCaptureBytes is Long.MAX_VALUE (unbounded, fully repeatable). The
instrumentation steps construct the wrapper via the internal bounded(...) seam capped at the
configured bodyPreviewMaxBytes, so logging never buffers more than a preview while the caller
still streams the rest.
Why bound the capture rather than always drain?
A tee-read (wrapping the source) would require the consumer to read the body to trigger observation — if the consumer never reads, nothing is logged. Draining a bounded prefix ensures a preview is always available for logging regardless of whether the consumer reads, while keeping memory bounded and avoiding a hang on a large or unbounded streaming body.
Trade-off: only the preview prefix is held in memory. For a body within the cap the capture is
the whole body and reads are fully repeatable; for a larger body the wrapper is single-consumer
(the live tail can be read only once). snapshot(maxBytes) returns a bounded prefix of the
capture, leaving the capture intact.
contentLength(): returns the captured size only when the body fit within the cap (then the
capture is the body); otherwise it returns the delegate's true length, since the capture is just
a prefix.
Failure semantics: a network error during the drain does not silently truncate. The wrapper retains whatever bytes were read before the failure and caches the exception:
source()re-throws the cached exception every time, so callers see the failure deterministically (no silent zero-byte body).snapshot()/snapshot(maxBytes)always return the partial bytes — useful for post-mortem logging that records "what we got" alongside the exception.captureExceptionsurfaces the cached exception (ornull) without triggering a drain.
When HttpLogLevel.BODY_AND_HEADERS is enabled, the instrumentation step
(DefaultInstrumentationStep / DefaultAsyncInstrumentationStep) wraps the response body in a
LoggableResponseBody bounded to HttpInstrumentationOptions.bodyPreviewMaxBytes (default
8 KiB, DEFAULT_BODY_PREVIEW_MAX_BYTES). Two consequences follow that are easy to miss when
reading the logs:
1. The body delivered downstream can be larger than the logged preview. The cap bounds only
the in-memory capture, not the body. For a response larger than bodyPreviewMaxBytes, the
step buffers the preview prefix and the wrapper then streams the full body to the consumer — it
replays the captured prefix and continues from the live tail (see the bounded-capture diagram
above). The preview you see in the log is a prefix; the consumer still reads every byte.
2. The logged size fields measure different things. The step emits two size-related fields
on the http.response event, and they are not the same number for an over-cap body:
| Field | Source | What it reports |
|---|---|---|
response.body.size |
loggableBody.snapshot(bodyPreviewMaxBytes) |
Size of the captured preview — bounded by bodyPreviewMaxBytes |
response.body.preview |
the same captured bytes, decoded as UTF-8 | The preview text (a prefix for an over-cap body) |
response.content.length |
response.body.contentLength() |
The body's true length when the origin declared one (Content-Length); -1 for unknown-length / streaming bodies |
So response.body.size is the captured/preview size, not necessarily the full body size.
When a body exceeds the cap, response.body.size saturates near bodyPreviewMaxBytes while
response.content.length still shows the real length. Read content.length (not
body.size) when you need the full size, and treat body.preview as a prefix that may be
truncated. The two agree only when the whole body fit within the cap — exactly the case where
contentLength() itself returns the captured size (see contentLength() above).
Streaming / unknown-length bodies (async path). DefaultAsyncInstrumentationStep skips the
capture entirely when contentLength() < 0, because the bounded drain would run on the
future-completion thread and a slow producer could stall it. Such bodies stream to the consumer
unwrapped, so they carry no response.body.size / response.body.preview fields at all —
absence of those fields is expected for chunked or streaming responses, not a logging bug. The
synchronous DefaultInstrumentationStep drains known-length and unknown-length bodies alike (it
runs on the caller's thread), but the size-vs-preview distinction above applies to it just the
same.
The only logging output is a raw ByteArray:
fun snapshot(): ByteArray // the full captured prefix (the whole body when it fit the cap)
fun snapshot(maxBytes: Int): ByteArray // a bounded slice of the capture, leaving it intactsnapshot() throws IllegalStateException if the captured size exceeds
Buffer.MAX_BYTE_ARRAY_SIZE (a single ByteArray can't hold it); reach for snapshot(maxBytes)
whenever the body may be unbounded. maxBytes is silently clamped to MAX_BYTE_ARRAY_SIZE, so
passing Int.MAX_VALUE on a multi-gigabyte body still yields a safely bounded array. Decoding
the bytes to text for a log line is left to the caller, who can consult the body's MediaType.charset.
The request-logging write path is built on TeeSink plus the Buffer-based I/O system (see
I/O Module).
TeeSink (package io) is a BufferedSink that mirrors every write into a tap Buffer while
forwarding to a primary sink. Typed writes (writeUtf8, writeString, write(ByteArray)) are
encoded once into a reused scratch buffer, then copied non-destructively into the tap and
drained destructively into the primary. On an Okio-backed Buffer both moves are segment-level
operations, so the cost is one encode plus two segment moves regardless of payload size.
TeeSink.close() closes the primary sink; the tap is owned by LoggableRequestBody and is not a
network resource. Direct buffer access (tee.buffer) is unsupported and throws — writing into the
backing buffer would reach only the tap, silently corrupting the wire body. Callers must use the
typed write* methods so bytes reach both sinks.
TeeSink accepts a tapLimit (default Long.MAX_VALUE) that bounds how many bytes are mirrored
into the tap. Once the limit is reached the tee stops copying into the tap entirely but keeps
forwarding the full payload to the primary — the wire body is never truncated. The
instrumentation steps build the request wrapper via LoggableRequestBody.bounded(...) with the
limit set to bodyPreviewMaxBytes, so a multi-GB FileRequestBody upload mirrors only a bounded
preview into memory while streaming zero-copy to the transport.
LoggableRequestBody allocates its tap Buffer lazily (only on first write or snapshot) from
the installed IoProvider. The delegate's writeTo runs through the TeeSink, after which
snapshot() / snapshot(maxBytes) read the captured bytes out of the tap. The tap is cleared at
the start of each writeTo so retries don't accumulate stale bytes.
LoggableResponseBody drains the delegate into a captured Buffer once, behind a
double-checked-locking guard, and serves repeatable reads via Buffer.peek().
LoggableResponseBody uses ReentrantLock to guard the one-time draining of the delegate body.
This was chosen over synchronized for virtual thread compatibility:
| Aspect | synchronized |
ReentrantLock |
|---|---|---|
| Platform threads | Monitor lock | AbstractQueuedSynchronizer |
| Virtual thread behavior | Pins the virtual thread to its carrier thread | Virtual thread unmounts, freeing the carrier |
| JDK availability | JDK 1.0+ | JDK 5+ (within JDK 8 target) |
| Kotlin idiom | synchronized(lock) { } |
lock.withLock { } |
Carrier pinning explained: When a virtual thread enters a synchronized block, the
JVM pins it to the carrier platform thread for the duration of the critical section. Other
virtual threads waiting for the same carrier are starved. ReentrantLock uses
LockSupport.park() internally, which the JVM recognizes as a yield point — the virtual
thread unmounts, and the carrier is free to run other virtual threads.
The drain initialization uses double-checked locking:
@Volatile
private var captured: Buffer? = null
private fun ensureCaptured(): Buffer {
captured?.let { return it } // Fast path: no lock, volatile read
return lock.withLock {
captured?.let { return it } // Re-check under lock
check(!closed) { "LoggableResponseBody was closed before the body was read; nothing to capture." }
drainAndCache() // closes the delegate via use {}; caches partial bytes + error on failure
}
}Why this pattern?
- Fast path: After draining,
captured != nullis a single volatile read with no locking. This is the common case — the body is drained once, then read many times. - Safety: The
@Volatileannotation ensures the captured buffer reference (and the bytes it holds) is visible to all threads after the lock is released (happens-before via the volatile write). - Single initialization: The re-check inside
withLockprevents duplicate drains when two threads race on the first access.
A drain failure is recorded rather than retried: drainAndCache caches the partial buffer and the
thrown exception in @Volatile fields, so source() re-throws deterministically and snapshot()
returns the partial bytes.
The captured Buffer is in-memory, but the drain reads the delegate's BufferedSource, which
performs blocking I/O. No lock or concurrency primitive can make that read non-blocking. The lock
choice affects only the one-time drain guard, not the I/O itself.
Both synchronized and ReentrantLock work correctly. The lock is held only during the
one-time drain; subsequent accesses are lock-free via the volatile fast path.
ReentrantLock is the correct choice. During the one-time drain:
- The lock acquisition uses
LockSupport.park()— if contended, the virtual thread unmounts from its carrier, freeing it for other virtual threads. - The blocking reads inside the drain also unmount (JDK 21+ recognizes blocking I/O as a yield point).
With synchronized, both the lock and the I/O would pin the carrier thread.
The SDK core module intentionally does not depend on kotlinx-coroutines. Callers
using coroutines should:
- Dispatch body reads to
Dispatchers.IO(or alimitedParallelismdispatcher). - Both the lock and the underlying I/O will block the dispatcher thread, which is expected
behavior on
Dispatchers.IO. - If running coroutines on virtual thread dispatchers (JDK 21+),
ReentrantLockavoids carrier pinning.
A coroutine-native surface ships separately in sdk-async-coroutines (suspend extensions, MDC
propagation), keeping the core blocking-friendly for the widest compatibility.
Callers should wrap body access in a blocking-compatible scheduler:
// Project Reactor
Mono.fromCallable(() -> loggable.snapshot())
.subscribeOn(Schedulers.boundedElastic());
// RxJava
Single.fromCallable(() -> loggable.snapshot())
.subscribeOn(Schedulers.io());The blocking is inherent to the drain — no lock choice can eliminate it. A Reactor-native surface
(Mono/Flux, including SSE → Flux) ships in sdk-async-reactor.
kotlinx.coroutines.sync.Mutex suspends instead of blocking, which would be ideal
for coroutine callers. However:
- Adding
kotlinx-coroutinesas a dependency to the core SDK module would force it on all consumers, including pure Java projects that don't use coroutines. Mutexrequires asuspendfunction context. Changingsource()andsnapshot()tosuspend funwould break theResponseBodycontract and make the class unusable from Java.
The chosen pattern keeps the core blocking-friendly and pushes coroutine and reactive ergonomics
into the dedicated adapter modules (sdk-async-coroutines, sdk-async-reactor,
sdk-async-netty, sdk-async-virtualthreads).
| Edge Case | Handling |
|---|---|
| Empty body | snapshot() returns an empty ByteArray; the tap/captured buffer holds zero bytes |
| Null media type | Body bytes are still captured; charset resolution is the caller's concern |
| Large body | Full body captured into pooled segments; use snapshot(maxBytes) for a bounded preview |
Snapshot exceeds MAX_BYTE_ARRAY_SIZE |
snapshot() throws IllegalStateException; use snapshot(maxBytes) instead |
Double close() |
Idempotent — LoggableResponseBody.close() checks the closed flag under lock |
close() before read |
Delegate is closed, releasing the connection; the captured buffer was never allocated |
close() after read |
Delegate already closed during the drain; the captured buffer survives close |
| Concurrent first access | ReentrantLock ensures exactly one thread drains the delegate |
| Drain fails mid-read | Partial bytes retained; source() re-throws, snapshot() returns the partial capture |
close() then source() |
ensureCaptured() throws IllegalStateException ("closed before the body was read") |
writeTo() on single-use RequestBody |
The underlying source is consumed and closed; a second call throws IllegalStateException |
writeTo() on reusable RequestBody |
ByteArray/Buffer-backed; safe to call multiple times |
val body = RequestBody.create("""{"name": "test"}""", MediaType.parse("application/json"))
val loggable = LoggableRequestBody(body)
// Pass to the transport — writeTo is called internally with the transport's BufferedSink
loggable.writeTo(transportSink)
// Log after write
val captured = loggable.snapshot()
logger.debug("Request body: {}", String(captured, Charsets.UTF_8))
// Output: Request body: {"name": "test"}val loggable = LoggableResponseBody(response.body!!)
// Log the response (drains once, in memory)
logger.debug("Response: {}", String(loggable.snapshot(), Charsets.UTF_8))
// Body is still fully available (repeatable reads)
val data = loggable.source().readByteArray()val loggable = LoggableResponseBody(response.body!!)
// Capture at most 4 KiB for the log line; the full body stays available for parsing
val preview = loggable.snapshot(maxBytes = 4 * 1024)
logger.debug("Response preview: {}", String(preview, Charsets.UTF_8))
val result = serde.deserializer.deserialize(loggable.source().inputStream(), MyType::class.java)// Dispatch to IO because the drain is blocking
val result = withContext(Dispatchers.IO) {
val loggable = LoggableResponseBody(response.body!!)
logger.debug("Response: {}", String(loggable.snapshot(), Charsets.UTF_8))
loggable.source().readByteArray()
}Mono.fromCallable(() -> {
LoggableResponseBody loggable = new LoggableResponseBody(response.body());
logger.debug("Response: {}", new String(loggable.snapshot(), StandardCharsets.UTF_8));
return loggable.source().readByteArray();
})
.subscribeOn(Schedulers.boundedElastic())
.subscribe(result -> process(result));