A toolkit for building HTTP client libraries on the JVM. Dexpace is not an HTTP client: it is the machinery a client is made of. Immutable request and response models, a staged pipeline runtime, resilience steps, and seams for transport, I/O, serialization, and async runtimes.
Written in Kotlin, targeting JDK 8 bytecode. sdk-core has zero runtime dependencies beyond the Kotlin standard library and the SLF4J API (compile-only); every third-party library lives behind an adapter module, so consumers pay only for the runtime they use.
Current version 0.0.1-alpha.1. The public API is stabilising and breaking changes between alpha releases are expected. External pull requests are welcome.
Quick start · Design principles · Modules · Documentation · Usage · Pipeline stages · Package map · Shrinking with R8 / ProGuard · Building · Dependencies · License
Io.installProvider(OkioIoProvider) // once, at application startup
val transport = OkHttpTransport.builder()
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(30))
.build()
val pipeline = HttpPipelineBuilder(transport)
.append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3)))
.append(KeyCredentialAuthStep(KeyCredential("my-api-key")))
.build()
val request = Request.builder()
.method(Method.GET)
.url("https://api.example.com/v1/resource")
.build()
pipeline.send(request).use { response ->
if (response.status.isSuccess) {
val bytes = response.body?.source()?.readByteArray()
// process
}
}For a complete, runnable version of this wiring — an IoProvider, a transport, a serde, and a full pipeline driven against an embedded server — see the sdk-example module and run ./gradlew :sdk-example:run. The rest of this document covers the moving parts: transports, the async pipeline, runtime adapters, and body logging.
- The request/response model is async-first and immutable: private constructors, builders,
newBuilder()copies, and Java-friendly factories (@JvmOverloads,@JvmStatic,@JvmFieldwhere applicable). - The pipeline runtime orders steps by stage, supports surgical type-based edits (
insertAfter<T>,replace<T>,remove<T>), and enforces pillars: exactly one retry, redirect, auth, and instrumentation step per pipeline. - Resilience ships in the box. Retry honours
Retry-Afterand backs off exponentially with jitter; redirects strip authorization headers and reject HTTPS→HTTP downgrades; auth coversKeyCredential, cachedBearerToken, and RFC 7616 Digest (MD5, MD5-sess, SHA-256, SHA-256-sess); instrumentation provides structured logging, tracing, and metrics. - Body logging never disturbs the wire. Request bytes are captured through a
TeeSinkduring the write; responses are drained once and re-read throughpeek()views, with race-safe consumed-once guards and cached drain errors. - Two seams keep the core dependency-free:
IoProviderfor streams, andHttpClient/AsyncHttpClientfor transport. The core has no opinion about how bytes reach the wire.
| Module | Purpose | JVM target |
|---|---|---|
sdk-core |
Contracts, pipeline runtime, sync + async pipelines, built-in steps. Zero runtime deps beyond SLF4J API and Kotlin stdlib. | Java 8 |
sdk-io-okio3 |
Okio 3.x implementation of IoProvider. |
Java 8 |
sdk-async-coroutines |
Kotlin coroutines adapter: suspend extensions, CoroutineScope.completableFutureOf, MDC propagation. |
Java 8 |
sdk-async-reactor |
Reactor Mono / Flux adapter, including SSE → Flux with backpressure. |
Java 8 |
sdk-async-netty |
Netty io.netty.util.concurrent.Future adapter with bidirectional cancellation. |
Java 8 |
sdk-async-virtualthreads |
JDK 21+ virtual-thread executor adapter (AutoCloseable). |
Java 21 |
sdk-transport-okhttp |
OkHttp 5.x implementation of HttpClient + AsyncHttpClient. |
Java 8 |
sdk-transport-jdkhttp |
java.net.http.HttpClient (JEP 321) implementation of HttpClient + AsyncHttpClient. |
Java 11 |
sdk-serde-jackson |
Jackson 2.18 implementation of Serde with SDK-correct defaults (FAIL_ON_UNKNOWN_PROPERTIES=false, WRITE_DATES_AS_TIMESTAMPS=false) + Tristate<T> ser/de. |
Java 8 |
Each adapter module depends on sdk-core and exactly one third-party library. JDK 8 or newer is the baseline, with the two exceptions in the table: sdk-transport-jdkhttp needs JDK 11 and sdk-async-virtualthreads needs JDK 21. Local builds use Gradle 9.3.1 and Kotlin 2.3.21.
Two further modules build but are never published: sdk-example, the runnable end-to-end sample above, and sdk-shrink-test, a test-only harness that runs R8 against a consumer of the SDK to verify the toolkit survives downstream shrinking.
| Document | Description |
|---|---|
| Architecture overview | Design, module structure, component responsibilities |
| HTTP layer | Request/response models, headers, media types, context system, HttpClient |
| I/O module | I/O contracts and the IoProvider seam |
| HTTP body logging and concurrency | Body logging system, concurrency model, thread safety |
| Pipeline mechanism | Pipeline architecture, stages, step composition, async pipeline |
| Style guides | Kotlin and Kotlin-on-JVM style guides this codebase follows |
Bring your own HttpClient / AsyncHttpClient implementation, or use one of the two reference transports that ship with the project.
// BYO factory: pass your own preconfigured OkHttpClient
val transport = OkHttpTransport.create(myOkHttpClient)
// OR SDK-managed builder
val transport = OkHttpTransport.builder()
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(30))
.followRedirects(false) // default — SDK has DefaultRedirectStep
.build()Implements both HttpClient (sync) and AsyncHttpClient (async, via OkHttp Call.enqueue). CompletableFuture.cancel() propagates to okhttp3.Call.cancel(). Java 8 bytecode.
// BYO factory
val transport = JdkHttpTransport.create(myJdkHttpClient)
// OR SDK-managed builder
val transport = JdkHttpTransport.builder()
.connectTimeout(Duration.ofSeconds(5))
.responseTimeout(Duration.ofSeconds(30))
.httpVersion(JdkHttpTransport.HttpVersion.HTTP_2) // default
.build()Implements both SPIs through HttpClient.sendAsync; CompletableFuture.cancel() aborts the underlying exchange natively. Java 11 bytecode, so consumers must be on JDK 11 or newer.
The quick start above shows the minimal path. A production pipeline usually fills every pillar:
val pipeline = HttpPipelineBuilder(transport)
.append(SetDateStep())
.append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3)))
.append(DefaultRedirectStep())
.append(KeyCredentialAuthStep(KeyCredential("my-api-key")))
.append(DefaultInstrumentationStep(HttpInstrumentationOptions(logLevel = HttpLogLevel.HEADERS)))
.build()
val request = Request.builder()
.method(Method.POST)
.url("https://api.example.com/v1/resource")
.addHeader("Content-Type", "application/json")
.body(RequestBody.create("""{"key": "value"}""", MediaType.parse("application/json")))
.build()
pipeline.send(request).use { response ->
if (response.status.isSuccess) {
val bytes = response.body?.source()?.readByteArray()
// process
}
}val async = AsyncHttpPipelineBuilder(asyncTransport)
.append(/* AsyncHttpStep implementations */)
.build()
async.sendAsync(request).whenComplete { response, error ->
if (error != null) { /* handle */ }
else response.use { /* process */ }
}Bridge a sync pipeline to async:
val async = syncPipeline.toAsync(Executors.newVirtualThreadPerTaskExecutor())import org.dexpace.sdk.async.coroutines.send
val response = async.send(request) // suspend funimport org.dexpace.sdk.async.reactor.sendMono
async.sendMono(request)
.doOnNext { /* process */ }
.subscribe()Server-Sent Events as a Flux with backpressure:
response.body!!.source().readServerSentEventsAsFlux()
.doOnNext { event -> /* handle event */ }
.subscribe()import org.dexpace.sdk.async.netty.executeNetty
val nettyFuture = asyncClient.executeNetty(request, eventLoop)
nettyFuture.addListener { /* fire on event-loop thread */ }val syncTransport = /* a blocking HttpClient */
syncTransport.asAsyncVirtualThreads().use { vt ->
val future = vt.executeAsync(request)
// ...
} // close() releases the virtual-thread executor// Request: bytes captured during write via TeeSink
val loggedRequest = LoggableRequestBody(body)
// pass `loggedRequest` as the request body; transport calls writeTo()
logger.debug("request body: {}", loggedRequest.snapshot().take(8 * 1024))
// Response: drained lazily, drain errors cached, peek-based repeat reads
val loggedResponse = LoggableResponseBody(response.body!!)
val preview = loggedResponse.snapshot(maxBytes = 8 * 1024)
val full = loggedResponse.source().readByteArray() // still availableSteps execute in declaration order of Stage.entries. Pillar stages (isPillar = true) admit exactly one step; non-pillar stages admit any number, ordered by append and prepend.
REDIRECT (pillar) → POST_REDIRECT → RETRY (pillar) → POST_RETRY →
PRE_AUTH → AUTH (pillar) → POST_AUTH → PRE_LOGGING →
LOGGING (pillar) → POST_LOGGING → PRE_SERDE → SERDE (pillar) →
POST_SERDE → PRE_SEND → SEND (terminal — HttpClient.execute)
See docs/pipelines.md for the step-author walkthrough.
| Package | Highlights |
|---|---|
client |
HttpClient, AsyncHttpClient — the two transport SPIs (sync and async). |
http.request |
Request, RequestBody, FileRequestBody, LoggableRequestBody, Method. |
http.response |
Response, ResponseBody, LoggableResponseBody, Status (a value-carrying class with a total fromCode), plus the raw-vs-parsed seam: ResponseHandler<T> (with dep-free string()/empty() handlers) and a lazy, parse-once ParsedResponse<T>. |
http.response.exception |
Typed HttpException hierarchy (BadRequestException, RequestTimeoutException, TooManyRequestsException, ServiceUnavailableException, …) with isRetryable derived from RetryUtils.isRetryable and exposed via the Retryable interface, plus NetworkException and HttpExceptionFactory. |
http.common |
Headers, HttpHeaderName (interned), MediaType, Protocol, HttpRange, ETag, RequestConditions. |
http.context |
CallContext → DispatchContext → RequestContext → ExchangeContext chain, ContextStore. |
http.pipeline |
Sync (HttpStep / HttpPipeline / HttpPipelineBuilder / PipelineNext / Stage) and async (AsyncHttpStep / AsyncHttpPipeline / AsyncHttpPipelineBuilder / AsyncPipelineNext) pipeline machinery, plus AsyncPipelineBridges. |
http.pipeline.steps |
Concrete steps: RetryStep, RedirectStep, AuthStep, KeyCredentialAuthStep, BearerTokenAuthStep, InstrumentationStep, SetDateStep, and their *Options / *Condition types. |
http.auth |
Credential sealed hierarchy (KeyCredential, NamedKeyCredential, BearerToken), BearerTokenProvider, AuthScheme, AuthMetadata, RFC 7235 challenge parser, BasicChallengeHandler, DigestChallengeHandler, CompositeChallengeHandler. |
http.sse |
ServerSentEventReader (WHATWG spec), ServerSentEvent, ServerSentEventListener, BufferedSource.readServerSentEvents(). |
http.paging |
PagedIterable<T>, PagedResponse<T>, PagingOptions with byPage() and stream() accessors. |
pagination |
Paginator<T> (with a maxPages safety cap) over cursor / page-number / link-header PaginationStrategy implementations, plus Page<T> / SimplePage<T>. Token-style APIs use CursorPaginationStrategy with the query-param name set (e.g. "page_token"). |
pipeline |
Recovery-aware primitives: RequestPipeline, ResponsePipeline, ExecutionPipeline over a sealed ResponseOutcome, with steps (pipeline.step, pipeline.step.retry) like RetryStep, ResponseRecoveryStep, IdempotencyKeyStep, ClientIdentityStep. |
serde |
Serde, Serializer, Deserializer abstractions, Tristate<T> (absent / null / present), and SerdeException (the unchecked failure adapters translate codec errors into). |
io |
Source, Sink, Buffer, BufferedSource, BufferedSink, IoProvider, Io, TeeSink. |
instrumentation |
ClientLogger (zero-alloc disabled path), LoggingEvent, UrlRedactor, Tracer / NoopTracer, Span / NoopSpan, InstrumentationContext. |
instrumentation.metrics |
Meter, LongCounter, DoubleHistogram, NoopMeter. |
config |
Configuration (system-property + env-var layered lookup), ConfigurationBuilder. |
util |
Clock, Uuids (non-blocking v4), DateTimeRfc1123, RetryUtils, ProxyOptions, Futures. |
generics |
Builder<T> — the generic builder interface every SDK builder implements. |
Token-style APIs (next_page_token, pageToken, …) are served by CursorPaginationStrategy:
construct it with the desired query-param name, e.g. CursorPaginationStrategy(items, extractor, "page_token").
Every published jar carries its own consumer keep-rules under META-INF/proguard/. R8 and the
Android Gradle Plugin apply rules packaged there automatically, so a downstream application that
shrinks its build inherits them with no extra configuration. The rules protect the parts a shrinker
cannot prove reachable on its own: the SPI seams wired at runtime (IoProvider, the transport
clients, the serde) and the immutable HTTP models and Tristate that reflective serializers bind by
walking constructors and Kotlin metadata. The test-only sdk-shrink-test module runs R8 against a
consumer of the SDK on every build to keep those rules honest.
./gradlew build # build every module
./gradlew test # run all tests across modules
./gradlew koverHtmlReport # aggregate coverage report at build/reports/kover/html/
./gradlew apiCheck # binary-compatibility check against committed .api snapshots
./gradlew apiDump # regenerate .api snapshots after intentional API changesAggregate line coverage sits comfortably above the 80% floor; run koverHtmlReport for the current numbers.
All of these break the build:
explicitApi = ExplicitApiMode.Stricton every Kotlin module: every public declaration states its visibility and return type.allWarningsAsErrors = truefor every Kotlin compile task.- ktlint and detekt with
ignoreFailures = false. Detekt is skipped onsdk-async-virtualthreadsandsdk-transport-jdkhttp, whose JDK 21 / JDK 11 toolchains run analysis on a JDK 25 system JVM that detekt 1.23.x cannot parse; both build scripts link the upstream issue and the re-enable conditions. It runs everywhere else, including the JDK 8 transports. kotlinx-binary-compatibility-validatorgates the public API surface against committed.apisnapshots.- Aggregate Kover line coverage has an 80% floor.
- The
sdk-shrink-testR8 guard, wired intocheck, fails the build if the shipped consumer keep-rules stop protecting the toolkit under shrinking. It needs a JDK 11 toolchain and fetchescom.android.tools:r8from Google's Maven repo.
| Component | Version | Scope |
|---|---|---|
| Kotlin | 2.3.21 | All modules |
| Gradle | 9.3.1 | Build |
| SLF4J API | 2.0.18 | sdk-core (compileOnly) |
| Okio | 3.17.0 | sdk-io-okio3 |
| kotlinx-coroutines | 1.11.0 | sdk-async-coroutines |
| Reactor Core | 3.8.5 | sdk-async-reactor |
| Netty Common | 4.2.13.Final | sdk-async-netty |
| OkHttp | 5.0.0 | sdk-transport-okhttp |
| mockwebserver3 | 5.0.0 | sdk-transport-okhttp, sdk-transport-jdkhttp (test-only) |
| Jackson | 2.18.2 | sdk-serde-jackson |
| Kover | 0.9.8 | Coverage (root project) |
This project is licensed under the MIT License. Copyright © 2026 dexpace and Omar Aljarrah. Every source file carries an MIT license header.