This document provides a comprehensive overview of the java-sdk project architecture,
covering module structure, package responsibilities, design philosophy, and cross-cutting
concerns.
- Design Philosophy
- Module Structure
- Kotlin Package Map
- Data Flow
- Cross-Cutting Design Decisions
- File Inventory
The SDK is built around these principles:
-
Zero-dependency core:
sdk-coredepends only on the SLF4J API (compileOnly) and the Kotlin stdlib. No HTTP client implementation, no serialization library, and no concrete I/O implementation — not even Okio. Every JDK API used is available since Java 8. -
Modular composition: Core components are interfaces or abstract classes. Concrete implementations are plugged in by consuming libraries. The SDK provides the abstractions and the plumbing, not the concrete HTTP transport.
-
Concurrency-model agnostic: The SDK exposes blocking calls guarded by
ReentrantLockfor thread safety. This works correctly on platform threads, virtual threads (Project Loom),Dispatchers.IO, and reactive schedulers. No coroutines or reactive types leak into the core API. -
Immutable data, mutable builders: HTTP models (
Request,Response,Headers,MediaType) are immutable data classes. Mutation happens exclusively through builder APIs that produce new instances. -
Pluggable I/O:
sdk-coredefines only I/O contracts (Source,Sink,BufferedSource,BufferedSink,Buffer). Concrete I/O implementations live in adapter modules (today:sdk-io-okio3). Performance characteristics are a property of the chosen adapter, not of the core.
The project is nine Gradle modules (settings.gradle.kts), all under group org.dexpace:
java-sdk/
sdk-core/ Primary SDK module — all public contracts (Java 8 target)
src/main/kotlin/ Kotlin sources — there is no Java source tree
org/dexpace/sdk/core/
io/ I/O contracts: Source, Sink, BufferedSource, BufferedSink, Buffer, IoProvider, Io, TeeSink
http/request/ Request, RequestBody, FileRequestBody, LoggableRequestBody, Method
http/response/ Response, ResponseBody, LoggableResponseBody, Status, typed exception hierarchy
http/common/ Headers, MediaType, Protocol, CommonMediaTypes, ETag, HttpRange, conditions
http/auth/ Credentials, RFC 7235 challenge parsing, Basic/Digest/Composite handlers
http/context/ Call/dispatch/request/exchange contexts + ContextStore
http/paging/ PagedIterable, PagedResponse, PagingOptions
http/pipeline/ Stage-based sync/async pipeline runtime (+ .steps)
http/sse/ WHATWG Server-Sent Events reader/listener/events
pipeline/ Recovery-aware Request/Response/Execution pipeline primitives (+ .step, .step.retry)
pagination/ Paginator + cursor/page-number/token/link-header strategies
client/ HttpClient + AsyncHttpClient interfaces (transport SPI)
serde/ Serialization abstractions + Tristate
instrumentation/ Tracing, spans, scopes, logging (+ .metrics)
config/ Configuration + ConfigurationBuilder
generics/ Builder<T>
util/ Clock, Futures, ProxyOptions, RetryUtils, SdkInfo, Uuids, DateTimeRfc1123, annotation helpers
sdk-io-okio3/ Okio 3.x IoProvider implementation (Java 8 target)
sdk-async-coroutines/ Kotlin coroutines adapter — suspend extensions, MDC propagation (Java 8 target)
sdk-async-reactor/ Reactor Mono/Flux adapter, incl. SSE → Flux (Java 8 target)
sdk-async-netty/ Netty Future adapter with bidirectional cancellation (Java 8 target)
sdk-async-virtualthreads/ Virtual-thread executor adapter (Java 21 target)
sdk-transport-okhttp/ Reference transport: OkHttp 5.x (Java 8 target)
src/main/kotlin/
org/dexpace/sdk/transport/okhttp/
OkHttpTransport.kt Public — implements HttpClient + AsyncHttpClient
internal/ Internal adapters (request, response, body, restricted-headers)
src/test/kotlin/ JUnit Platform tests (mockwebserver3)
sdk-transport-jdkhttp/ Reference transport: java.net.http.HttpClient (Java 11 target)
src/main/kotlin/
org/dexpace/sdk/transport/jdkhttp/
JdkHttpTransport.kt Public — implements HttpClient + AsyncHttpClient
internal/ Internal adapters (request, response, body publishers, restricted-headers)
src/test/kotlin/
sdk-serde-jackson/ Jackson 2.18 Serde adapter (Java 8 target)
src/main/kotlin/
org/dexpace/sdk/serde/jackson/
JacksonSerde.kt Public — Serde implementation + typed deserializeAs helpers
JacksonObjectMappers.kt Public — defaultObjectMapper() factory
TristateModule.kt Public — Jackson module wiring Tristate ser/de
src/test/kotlin/
docs/ Design documentation
All modules except sdk-async-virtualthreads and sdk-transport-jdkhttp target Java 8 bytecode. sdk-async-virtualthreads overrides the toolchain to JDK 21 because virtual threads require it; sdk-transport-jdkhttp overrides to JDK 11 because java.net.http.HttpClient was finalised in JEP 321. Consumers of each module must be on the corresponding JDK or newer.
sdk-core is written entirely in Kotlin — there is no src/main/java tree. It defines only contracts and contains no concrete I/O implementation. Adapter modules depend on sdk-core and bring exactly one third-party library each; consumers pay only for what they use.
Package: org.dexpace.sdk.core.io
A small set of interface contracts plus a single factory seam (IoProvider). sdk-core
contains no concrete I/O implementation — that lives in adapter modules (today only
sdk-io-okio3). The HTTP layer consumes the contracts; the consuming application installs
one provider at startup via Io.installProvider(...).
| Type | Visibility | Role |
|---|---|---|
Source |
public | Primitive byte source (read(Buffer, byteCount): Long) |
Sink |
public | Primitive byte sink (write(Buffer, byteCount), flush()) |
BufferedSource |
public | Typed reads: byte arrays, UTF-8 strings, lines, peek, java.io |
BufferedSink |
public | Typed writes: byte arrays, UTF-8 strings, writeAll, java.io |
Buffer |
public | In-memory queue (source + sink + snapshot() for body logging) |
IoProvider |
public | Single factory the adapter implements |
Io |
public | provider getter + installProvider(...) (one-shot install seam) |
TeeSink |
internal | BufferedSink that mirrors writes into a Buffer for logging |
See I/O Module for full design documentation.
Package: org.dexpace.sdk.core.http
The core HTTP models live in three sub-packages — http.request, http.response, and
http.common — with body logging spanning the first two. The remaining http.* sub-packages
(auth, context, paging, pipeline, sse) are documented in their own sections below.
| Type | Role |
|---|---|
Request |
Immutable HTTP request (method, URL, headers, body). Builder pattern. |
RequestBody |
Abstract body with writeTo(sink: BufferedSink) and isReplayable()/toReplayable(). Factory methods for byte array, string, form data, buffer, file, and input-stream variants. |
FileRequestBody |
Replayable file-backed body transports can recognize to dispatch a zero-copy sendfile(2). |
Method |
HTTP method enum (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) |
| Type | Role |
|---|---|
Response |
Immutable HTTP response (request, protocol, status, message, headers, body). Closeable. Builder pattern. |
ResponseBody |
Abstract body exposing source(): BufferedSource. Single-use contract; wrap with LoggableResponseBody for repeatable reads. |
Status |
A total status type — fromCode(code) never throws and returns an unknown Status for vendor codes (nginx 499, Cloudflare 520–526). Canonical codes carry a statusName. |
A typed exception hierarchy lives in http.response.exception: HttpException (abstract, with a retryable flag derived from RetryUtils.isRetryable(status.code)), per-status subclasses including RequestTimeoutException (408, retryable), and NetworkException.
| Type | Role |
|---|---|
Headers |
Immutable multi-map of HTTP headers. Builder pattern. Case-insensitive lookup. |
MediaType |
Parsed media type with type, subtype, and parameters. charset extraction. |
CommonMediaTypes |
Constants for common media types (JSON, XML, form-urlencoded, etc.) |
Protocol |
HTTP protocol version (HTTP/1.0, HTTP/1.1, HTTP/2, etc.) |
LoggableRequestBody lives in http.request; LoggableResponseBody lives in http.response.
There is no separate http.logging package.
| Type | Package | Role |
|---|---|---|
LoggableRequestBody |
http.request |
Wraps a RequestBody and mirrors bytes through a TeeSink into a Buffer during write |
LoggableResponseBody |
http.response |
Drain-once wrapper over a BufferedSource — repeatable reads + snapshot() previews |
Both wrappers stage captured bytes in the io package's Buffer/TeeSink and expose
race-safe, consumed-once previews. There is no separate http.logging package, and no
BodySnapshot/BodySegment types.
See HTTP Body Logging & Concurrency for full design documentation.
Package: org.dexpace.sdk.core.http.auth
Credential types and RFC 7235 challenge handling. Credentials authorize a request; challenge
handlers answer WWW-Authenticate challenges parsed from a 401/407.
| Type | Role |
|---|---|
Credential |
Sealed interface for credential kinds |
KeyCredential / NamedKeyCredential |
API-key credentials |
BearerToken |
OAuth bearer token with optional expiresAt |
ChallengeHandler |
Answers a parsed AuthenticateChallenge, producing an AuthorizationHeader |
BasicChallengeHandler / DigestChallengeHandler |
RFC 7617 Basic and RFC 7616 Digest handlers |
CompositeChallengeHandler |
Picks the strongest handler that can answer a challenge set |
AuthChallengeParser |
Parses WWW-Authenticate / Proxy-Authenticate into AuthenticateChallenges |
The matching pipeline steps that stamp credentials onto outgoing requests live in
http.pipeline.steps (BearerTokenAuthStep, KeyCredentialAuthStep, AuthStep).
Package: org.dexpace.sdk.core.http.context
The context system carries metadata through the request/response lifecycle:
| Type | Role |
|---|---|
CallContext |
Base interface — provides instrumentationContext and a per-call callKey; AutoCloseable |
DispatchContext |
Head of the promotion chain — mints the callKey for the call |
RequestContext |
Adds the outgoing Request to the chain |
ExchangeContext |
Full exchange context — carries both request and response |
ContextStore |
Thread-safe store keyed by callKey for retrieving a call's live context |
Flow: DispatchContext is created at dispatch time, promoted to RequestContext when
the request is available, and promoted to ExchangeContext after the response arrives. The
whole chain shares one callKey — a per-call key, not the trace id (which is not
call-unique: untraced calls share a constant trace id, and an inbound W3C trace shares one
trace id across many spans). Each context carries an InstrumentationContext for tracing.
Only the terminal context of a chain should be closed; eviction from ContextStore is
conditional on identity, so closing an earlier link never removes a live successor.
Two cooperating pipeline layers, both fully implemented — no placeholders.
HttpPipelineBuilder assembles ordered HttpSteps into an HttpPipeline. Each step belongs
to a Stage; lower-ordered stages run first. Five stages are pillars that admit exactly
one step each — REDIRECT, RETRY, AUTH, LOGGING, SERDE — while the interleaved
non-pillar stages (e.g. PRE_AUTH, POST_LOGGING) hold an ordered deque of user steps. The
terminal SEND stage is HttpClient.execute itself. Replacing a pillar emits a
pipeline.pillar.replaced SLF4J warning.
| Type | Role |
|---|---|
HttpStep |
process(request, next): Response — the stage-based step interface |
Stage |
Ordered stage enum; isPillar stages take a single step |
HttpPipelineBuilder |
Assembles steps; surgical edits via insertAfter/insertBefore/replace/remove taking a Class |
HttpPipeline |
The built, immutable pipeline |
AsyncHttpPipeline / AsyncHttpStep / AsyncHttpPipelineBuilder |
The async mirror, with sync→async bridges (AsyncPipelineBridges) |
Shipped pillar/step implementations live in http.pipeline.steps: DefaultRedirectStep,
DefaultRetryStep, AuthStep (+ BearerTokenAuthStep / KeyCredentialAuthStep),
DefaultInstrumentationStep, and the redirect/retry option types.
For why this layer uses ordered stages with pillar-uniqueness rather than nested HttpClient
decorators — and the one cost that buys (the next.copy() re-drive contract) — see
Pipeline Mechanism.
A lower-level layer that threads a sealed ResponseOutcome so recovery steps observe and
rescue failures uniformly, whether they originate in a request step, the transport, or a
response step.
| Type | Role |
|---|---|
RequestPipeline |
Folds a Request through a sequence of RequestPipelineSteps |
ResponsePipeline |
Runs ResponsePipelineSteps on success and ResponseRecoverySteps on every outcome |
ExecutionPipeline |
Wires request pipeline → HttpClient → response pipeline |
ResponseOutcome |
Sealed Success(Response) / Failure(Throwable) sum type |
| Type | Role |
|---|---|
PipelineStep<T, V> |
Generic step interface: execute(input: T, context: DispatchContext): V |
RequestPipelineStep |
Specialized: PipelineStep<Request, Request> |
ResponsePipelineStep |
Specialized: PipelineStep<Response, Response> |
ResponseRecoveryStep |
invoke(outcome): ResponseOutcome — rescue / replace / pass-through |
ClientIdentityStep |
Stamps client-identity tokens onto the request |
IdempotencyKeyStep |
Adds an idempotency-key header for configured methods |
Retry primitives live in pipeline.step.retry:
| Type | Role |
|---|---|
RetryStep |
Recovery step that re-invokes the transport with backoff + Retry-After honoring |
RetrySettings |
Immutable retry policy (timeout, backoff, max attempts, retryable statuses/methods) |
BackoffCalculator |
Computes the per-attempt delay |
RetryAfterParser |
Parses Retry-After / X-RateLimit-Reset pacing hints |
See Pipeline Mechanism for full design documentation.
Package: org.dexpace.sdk.core.client
fun interface HttpClient : AutoCloseable {
fun execute(request: Request): Response
override fun close() { /* default no-op */ }
}A minimal interface that consuming libraries implement against their chosen HTTP transport (HttpURLConnection, Apache HC, Jetty, Netty, etc.). The SDK provides everything around this interface — body abstractions, logging, pipelines, contexts — but not the transport.
Both HttpClient and AsyncHttpClient extend AutoCloseable with a default no-op
close(), so SAM literals (HttpClient { req -> ... }) and lightweight wrappers remain
valid without an explicit close override. Transports that own background threads,
connection pools, or executors override close() to release them. See the
Lifecycle cross-cutting section for the full contract (idempotency,
ownership distinction, interrupt-safety).
Two production-ready reference transports ship with the project today: sdk-transport-okhttp
(OkHttp 5.x, Java 8 bytecode) and sdk-transport-jdkhttp (java.net.http.HttpClient, Java 11
bytecode). Both implement HttpClient and AsyncHttpClient on a single class and can be
instantiated either by passing a preconfigured underlying client (BYO factory — close() is a
no-op, the caller owns the client's lifecycle) or by using the SDK-managed builder (close()
releases the underlying transport resources). See the README's "Choosing a transport" section
for usage examples.
Packages: org.dexpace.sdk.core.pagination, org.dexpace.sdk.core.http.paging
Two complementary surfaces for walking multi-page responses.
| Type | Role |
|---|---|
Paginator<T> |
Lazily iterates pages by re-issuing requests through an HttpClient; carries a maxPages safety cap |
PaginationStrategy<T> |
Computes the next-page request (or stops) from the current page |
CursorPaginationStrategy / PageNumberPaginationStrategy / LinkHeaderPaginationStrategy |
The shipped strategies |
PagedIterable<T> |
First/next-page fetcher abstraction over PagedResponse, with its own maxPages cap |
Token-style APIs (next_page_token, pageToken, …) are handled by CursorPaginationStrategy
constructed with the query-param name set (e.g. "page_token"), so no separate token strategy is needed.
Package: org.dexpace.sdk.core.serde
| Type | Role |
|---|---|
Serde |
Bundle exposing a serializer and a deserializer for one wire format |
Serializer |
Encode values to bytes / strings / streams |
Deserializer |
Decode from String, ByteArray, or InputStream using an explicit Class<T> type token |
Tristate<T> |
Three-valued container (Absent / Null / Present) for PATCH payloads |
The core module defines abstractions only. Concrete implementations (Jackson, Moshi, kotlinx.serialization) belong in
optional extension modules. sdk-serde-jackson ships today as the reference Jackson 2.18 adapter, including a
TristateModule that wires the Tristate<T> type through Jackson's
serializer / deserializer pipeline.
| Type | Visibility | Role |
|---|---|---|
JacksonSerde |
public | Serde impl + typed deserializeAs(input, TypeReference<T>) helpers |
JacksonObjectMappers |
public | defaultObjectMapper() factory with SDK-correct defaults |
TristateModule |
public | Jackson SimpleModule wiring Tristate<T> ser/de + property-omit hook |
SDK-correct mapper defaults installed by JacksonObjectMappers.defaultObjectMapper():
KotlinModule,JavaTimeModule,Jdk8Module,TristateModuleall registered.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIESdisabled — payloads can grow without breaking clients.SerializationFeature.WRITE_DATES_AS_TIMESTAMPSdisabled — emits ISO-8601 strings, not epoch numbers.
Package: org.dexpace.sdk.core.instrumentation
| Type | Role |
|---|---|
InstrumentationContext |
Base context carrying trace/span ids and span management |
Span / NoopSpan |
A unit of work in a trace, plus its no-op default |
Tracer / HttpTracer / NoopTracer |
Span factories, plus the no-op default |
TracingScope |
Scoped tracing lifecycle (AutoCloseable) |
TraceIdType |
Trace-id generation strategy |
NoopInstrumentationContext |
No-op default for non-instrumented calls |
ClientLogger / LoggingEvent |
Structured logging façade over SLF4J |
UrlRedactor / MdcSnapshot |
Log-safe URL redaction and MDC capture |
The instrumentation.metrics sub-package adds metric abstractions (Meter, LongCounter,
DoubleHistogram) with NoopMeter as the default. sdk-core ships only these abstractions
and their no-op implementations; a concrete OpenTelemetry adapter is expected to live in a
separate module.
Log correlation is wired through SLF4J MDC: Span.makeCurrentWithLoggingContext() pushes trace.id / span.id for the scope, and LoggingEvent.log() folds MDC into every emitted event as structured fields. MDC is per-thread; callers using CompletableFuture chains or coroutines must propagate explicitly (see TracingScope KDoc).
| Type | Package | Role |
|---|---|---|
Builder<out T> |
generics |
Generic builder interface implemented by every SDK builder: fun build(): T |
util |
util |
Clock, Futures, ProxyOptions, RetryUtils, SdkInfo, Uuids, DateTimeRfc1123, and small annotation helpers |
config |
config |
Configuration + ConfigurationBuilder — typed configuration lookup |
Application Code
│
▼
Request.builder().build()
│
▼
┌─── DispatchContext ───┐
│ InstrumentationCtx │
└──────────┬────────────┘
│
┌──────────▼────────────┐
│ RequestPipeline │
│ step1 → step2 → ... │ Add headers, auth, validation
└──────────┬────────────┘
│
┌──────────▼────────────┐
│ LoggableRequestBody │ (if logging enabled)
│ TeeSink mirror │
└──────────┬────────────┘
│
┌──────────▼────────────┐
│ HttpClient.execute() │ Transport layer
└──────────┬────────────┘
│
▼
Response
HttpClient.execute()
│
▼
┌──────────────────────┐
│ ResponsePipeline │ Post-processing steps
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ LoggableResponseBody │ (if logging enabled)
│ Drain-once + snapshot │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ ExchangeContext │ Request + Response paired
└──────────┬───────────┘
│
▼
Application Code
response.body?.source()?.readUtf8()
The SDK core avoids all third-party dependencies (beyond SLF4J and Kotlin stdlib):
- No Okio:
sdk-coredefines I/O interface contracts only (Source/Sink/Buffer…); the Okio 3.x dependency lives in the optionalsdk-io-okio3adapter (see I/O Module) - No Jackson/Moshi/kotlinx: Serialization is abstract in core; concrete implementations
belong in extension modules (
sdk-serde-jacksontoday) - No coroutines: The core exposes blocking calls that work on any scheduler; coroutine
support lives in the optional
sdk-async-coroutinesadapter - No HTTP transport:
HttpClient/AsyncHttpClientare interfaces; consumers pick their transport
This means any JVM project can depend on sdk-core without transitive dependency conflicts.
The reference transport modules are the deliberate exception to the zero-dep rule: each pulls
in exactly one transport library — OkHttp 5.x for sdk-transport-okhttp, and no additional
runtime dependency for sdk-transport-jdkhttp (it uses the JDK standard library's
java.net.http.HttpClient). The principle still holds: sdk-core itself has zero runtime
deps; transport libraries are isolated to their own modules so consumers only pay for the
transport they pick.
All code targets Java 8 bytecode (jvmTarget = "1.8"). Specific implications:
InputStream.transferTo()(Java 9+) is avoided; manual 8 KB copy loops are used insteadThread.threadId()(Java 19+) is avoided;Thread.currentThread().idis used with@Suppress("DEPRECATION")ReentrantLock(Java 5+) replacessynchronizedfor future-proofing with virtual threads- No
java.net.http.HttpClient(Java 11+); theHttpClientinterface is transport-agnostic
Most modules compile against Java 8 bytecode, but two need a newer JDK: sdk-transport-jdkhttp
targets 11 (java.net.http.HttpClient was finalised in JEP 321) and sdk-async-virtualthreads
targets 21 (virtual threads). Each of those modules raises its target by overriding three
things in its own build script:
kotlin {
jvmToolchain(21) // which JDK compiles the module
}
java {
sourceCompatibility = JavaVersion.VERSION_21 // Java-source level
targetCompatibility = JavaVersion.VERSION_21 // bytecode version `compileJava` emits
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) // bytecode version `compileKotlin` emits
}
}(sdk-transport-jdkhttp does the same with 11/VERSION_11/JVM_11.) All three overrides
are mandatory. The java {} block governs compileJava and keeps Gradle's JVM-target validation
between compileJava and compileKotlin happy; a module that sets only the Kotlin toolchain and
jvmTarget but omits the java {} block will trip that validation or compile its Java sources at
the wrong level.
The root build registers a plugins.withId("org.jetbrains.kotlin.jvm") callback that sets
jvmTarget to JVM_1_8 for every Kotlin module by default. A module that bumps only the
toolchain — say to JDK 21 — but leaves jvmTarget at the inherited 1.8 will compile against
the JDK 21 standard library while emitting Java-8-format class files. The result links fine on
the build machine but references methods that do not exist on a Java 8 runtime, so a downstream
Java 8 consumer fails at call time with NoSuchMethodError. Setting jvmTarget to match the
toolchain makes the Kotlin compiler reject newer-than-target stdlib references at compile time
instead, turning that runtime failure into a build error.
This per-module override is the current, deliberately safe arrangement. The discipline matters
under a hypothetical future consolidation onto a single newer toolchain (for build speed, or to
sidestep the detekt-1.23.x crash on JDK 25+). If every module were compiled by, say, JDK 17 while
the Java-8-target modules kept jvmTarget = JVM_1_8, those modules would again be compiling
against a newer stdlib than they emit bytecode for. Guarding that arrangement requires a
--release 8 / -Xjdk-release=8 flag on the Java-8-target modules so the compiler bounds the
visible API to Java 8, not just the bytecode version. As long as each module that needs a newer
runtime carries its own matched jvmToolchain + jvmTarget pair, no --release guard is needed;
adopt one only if the toolchain is ever unified.
All HTTP model classes follow the same pattern:
@ConsistentCopyVisibility
data class Request private constructor(
val method: Method,
val url: URL,
val headers: Headers,
val body: RequestBody?
) {
fun newBuilder(): RequestBuilder = RequestBuilder(this)
class RequestBuilder : Builder<Request> {
fun method(method: Method) = apply { ... }
fun url(url: String) = apply { ... }
override fun build(): Request = ...
}
companion object {
fun builder(): RequestBuilder = RequestBuilder()
}
}- Private constructor: Forces use of builder
data class: Givesequals(),hashCode(),toString(),copy()for freenewBuilder(): Creates a pre-filled builder for modificationBuilder<out T>: Generic interface ensuring all builders havefun build(): T
The SDK uses ReentrantLock over synchronized wherever locking is needed:
| Aspect | synchronized |
ReentrantLock |
|---|---|---|
| Virtual thread behavior | Pins carrier thread | Virtual thread unmounts |
| JDK availability | 1.0+ | 5+ (within target) |
| Kotlin idiom | synchronized(lock) { } |
lock.withLock { } |
This is a forward-compatible choice: the SDK works correctly on Java 8 today and takes full advantage of virtual threads on Java 21+ without code changes.
Kotlin's internal modifier scopes visibility to the compilation module. The SDK uses
this to hide implementation details:
sdk-core/iopackage —TeeSinkisinternal; callers interact throughBuffer,Source,Sink,BufferedSource,BufferedSink,IoProvider, andIo(all public).sdk-io-okio3— the concrete adapter classes (OkioBuffer,OkioBufferedSource,OkioBufferedSink) areinternal; onlyOkioIoProvideris public.
Concrete I/O implementations belong in adapter modules, not in sdk-core. This keeps the
core dependency-free while still letting internal hide adapter internals within their
respective modules.
Every blocking call in the SDK respects Thread.interrupt(). When a thread is interrupted
while the SDK is blocked on a network read, a Thread.sleep inside a retry policy, a
ReentrantLock acquire in a rate limiter, or any other blocking operation, the SDK
responds in a uniform way:
- Catches
InterruptedExceptionat the blocking site. - Calls
Thread.currentThread().interrupt()to preserve the interrupt status so any subsequent blocking call also surfaces it. - Throws
InterruptedIOException(or the operation's natural failure exception withInterruptedExceptionadded as a suppressed cause). - Classifies the interruption as non-retryable — an interrupt-driven failure is not a
Retryablecondition, so the retry step never re-issues it.
Loops bounded by user input (retry attempts, paged iteration, server-sent-event
consumption, drain loops in body logging) check Thread.currentThread().isInterrupted at
the top of each iteration to abort early between blocking calls.
What this means for consumers:
- Calling
Thread.interrupt()on a thread that's executing an SDK call is the supported cancellation mechanism. - Threads that catch
InterruptedExceptionfrom the SDK should re-throw or re-interrupt themselves — the SDK has already preserved the interrupt status, and swallowing the exception silently breaks downstream cancellation. - Coroutine consumers running SDK calls inside
withContext(Dispatchers.IO)get cancellation propagation for free —Jobcancellation interrupts the blocked thread, which the SDK handles per the convention above.
HttpClient and AsyncHttpClient extend java.lang.AutoCloseable. The interfaces ship a
default no-op close() so SAM literals (HttpClient { req -> ... }) and lightweight
wrappers stay valid without modification. Transports that own background threads,
connection pools, or executors override close() to release those resources.
The contract every transport implementation must uphold:
- Idempotent. Repeated
close()calls must be safe. The canonical pattern usesprivate val closed = AtomicBoolean(false)plusclosed.compareAndSet(false, true)— lock-free, virtual-thread-friendly, nosynchronized(which would pin a carrier thread under Loom). - Ownership-aware. Distinguish SDK-owned resources from user-supplied dependencies.
An
internal val owned: Booleanfield — set totrueonly by the SDK's own builder (OkHttpTransport.builder().build(),JdkHttpTransport.builder().build()) andfalseby the BYO factory (OkHttpTransport.create(yourClient),JdkHttpTransport.create(yourClient)) — gates the close action. Caller-suppliedOkHttpClients,java.net.http.HttpClients, andExecutors are NEVER touched by the SDK; their lifecycle belongs to the caller. - Interrupt-safe. If
close()waits onexecutorService.shutdown()or similar, it must respectThread.interrupt()per the Cancellation convention. The current OkHttp adapter callsshutdown()(non-blocking) rather thanshutdownNow()orawaitTermination(...), so this is enforced trivially — no blocking step exists. Any future blocking close path must catchInterruptedException, restore the interrupt status, and surface asInterruptedIOException. - Best-effort, non-throwing. A failure to shut down one sub-resource must not
prevent the rest of the close path from running. Adapters log the failure at
WARNvia the SDK'sClientLoggerand continue.
Concrete adapter behaviour:
sdk-transport-okhttp—OkHttpTransport.close()on an SDK-owned client callsdispatcher.executorService.shutdown()(graceful drain),connectionPool.evictAll()(release idle sockets), andcache?.close()(release file descriptors). On a user-supplied client, all three are skipped.sdk-transport-jdkhttp—JdkHttpTransport.close()on an SDK-owned client casts the underlyingjava.net.http.HttpClienttoAutoCloseable. The interface was added in JDK 21 (JEP 461), so on JDK 11–20 theinstanceofcheck returnsfalseand the close is a documented no-op; on JDK 21+ the JDK client's selector and internal daemon executor are shut down promptly. The transport additionally shuts down any SDK-ownedExecutorServiceit passed toHttpClient.Builder.executor(...); today the builder does not expose that knob, so the field is wired in advance for a futureBuilder.executor(...)opt-in.
After close() returns, the behaviour of subsequent execute(...) / executeAsync(...)
calls is undefined — implementations may throw or return an error response, but the SDK
does not mandate a specific failure mode. Callers should not reuse a closed transport;
they should construct a fresh one.
| Package | Key Types |
|---|---|
io |
Source, Sink, BufferedSource, BufferedSink, Buffer, IoProvider, Io, TeeSink (internal) |
http.request |
Request, RequestBody, FileRequestBody, LoggableRequestBody, Method |
http.response |
Response, ResponseBody, LoggableResponseBody, Status |
http.response.exception |
HttpException, HttpExceptionFactory, Retryable, RequestTimeoutException (and siblings), NetworkException |
http.common |
Headers, MediaType, CommonMediaTypes, Protocol, ETag, HttpRange, RequestConditions |
http.auth |
Credential, KeyCredential, BearerToken, ChallengeHandler, Basic/Digest/CompositeChallengeHandler, AuthChallengeParser |
http.context |
CallContext, DispatchContext, RequestContext, ExchangeContext, ContextStore |
http.paging |
PagedIterable, PagedResponse, PagingOptions |
http.pipeline |
HttpPipeline, HttpPipelineBuilder, HttpStep, Stage, AsyncHttpPipeline (+ .steps) |
http.sse |
ServerSentEvent, ServerSentEventReader, ServerSentEventListener |
pipeline |
RequestPipeline, ResponsePipeline, ExecutionPipeline, ResponseOutcome |
pipeline.step |
PipelineStep, RequestPipelineStep, ResponsePipelineStep, ResponseRecoveryStep, ClientIdentityStep, IdempotencyKeyStep |
pipeline.step.retry |
RetryStep, RetrySettings, BackoffCalculator, RetryAfterParser |
pagination |
Paginator, PaginationStrategy, Cursor/PageNumber/Token/LinkHeader strategies, Page |
client |
HttpClient, AsyncHttpClient |
serde |
Serde, Serializer, Deserializer, Tristate |
instrumentation |
InstrumentationContext, Span, NoopSpan, NoopInstrumentationContext, Tracer, TracingScope, TraceIdType, ClientLogger |
instrumentation.metrics |
Meter, LongCounter, DoubleHistogram, NoopMeter |
config |
Configuration, ConfigurationBuilder |
generics |
Builder |
util |
Clock, Futures, ProxyOptions, RetryUtils, SdkInfo, Uuids, DateTimeRfc1123 |
| Type | Visibility | Role |
|---|---|---|
OkioIoProvider |
public | Singleton IoProvider — Io.installProvider(OkioIoProvider) |
OkioBuffer |
internal | Buffer wrapping okio.Buffer |
OkioBufferedSource |
internal | BufferedSource wrapping okio.BufferedSource |
OkioBufferedSink |
internal | BufferedSink wrapping okio.BufferedSink |