feat!: developer-experience improvements across the SDK surface#208
Open
OmarAlJarrah wants to merge 46 commits into
Open
feat!: developer-experience improvements across the SDK surface#208OmarAlJarrah wants to merge 46 commits into
OmarAlJarrah wants to merge 46 commits into
Conversation
…sErrorStatus Add isInformational (1xx), isRedirect (3xx), isClientError (4xx), isServerError (5xx), isError (4xx–5xx) to Status alongside the existing isSuccess. HttpExceptionFactory.isErrorStatus now delegates to Status.fromCode(code).isError, making the two consistent.
Response.isSuccessful delegates to status.isSuccess, eliminating the need to reach through the status property. ResponseBody gains string(charset) and bytes() convenience readers that both close the body in a finally block, removing the common null-chasing pattern of body?.source()?.readUtf8().
…se.deserialize() throwOnError() buffers the error body into an in-memory ResponseBody before throwing so the exception's body remains readable even after the transport connection is released. HttpException.bodyAs() decodes the error body via a Deserializer, returning null on missing body or decode failure without propagating. Response.deserialize() streams the body through a Serde and closes the response in a finally block; a reified overload provides Kotlin-only type-inference sugar.
…factory T4 — six @JvmField constants on MediaType.Companion (APPLICATION_JSON, TEXT_PLAIN, APPLICATION_OCTET_STREAM, APPLICATION_FORM_URLENCODED, TEXT_HTML, APPLICATION_XML) that delegate to CommonMediaTypes, giving callers a single canonical access point (MediaType.APPLICATION_JSON) without duplicating the registry. T8 — default contentType() method on Serde returns CommonMediaTypes.APPLICATION_JSON so implementations do not need to declare it unless they deviate from JSON. A matching RequestBody.create overload accepts (Any, Serde, MediaType = serde.contentType()), serializes via Serde.serializer.serialize, and delegates to the existing String create; no Jackson dependency required in sdk-core.
…ult GET, and verb shortcuts T5 — five typed HttpHeaderName overloads on RequestBuilder mirror what Headers.Builder already exposes: addHeader/setHeader(name, String), addHeader/setHeader(name, List<String>), and a convenience addHeader(name, MediaType) that takes the wire form of the media type. T6 — url(String) no longer declares @throws(MalformedURLException). Internally it catches MalformedURLException and re-throws IllegalArgumentException("Invalid URL: …", cause) so callers do not have to handle a checked exception on a builder chain. A new url(URI) overload converts via URI.toURL() with the same wrapping. T7 — build() defaults the method to Method.GET when none was set, so a minimal Request.builder().url(url).build() no longer throws. Six verb shortcuts (get, post, put, delete, head, patch) set the method and body in one call; get() and head() also clear any previously-set body. Request.Companion gains static factories get(url) and post(url, body).
from() now operates on a defensive copy of the caller's mapper (never mutating the original) and registers TristateModule on that copy so that Tristate PATCH fields — Present/Null/Absent — serialize correctly without silent data corruption. Adds a registerTristate: Boolean = true parameter (@jvmoverloads so Java callers keep the single-arg form) for callers who pre-registered the module themselves and want to opt out of the automatic registration. Tests cover all three Tristate states, the no-mutation guarantee, and the opt-out path.
Adds a thin public jsonBody(serde, value) function to Extensions.kt that delegates to RequestBody.create(value, serde) — a discoverable entry point for building a JSON request body without importing sdk-core factory methods directly from the Jackson adapter module.
…, clear body on delete()
…licit install When no provider is explicitly installed and exactly one IoProvider is discoverable via ServiceLoader on the classpath, Io.provider resolves it automatically and caches the result. Explicit installProvider still wins. - Io: add @volatile resolved field; double-checked lock for ServiceLoader scan - Io.selectProvider (internal): testable 0/1/many decision; 0 → actionable error, 1 → return it, many → ambiguity error naming all candidates - Io.installProvider / swapProvider: both clear resolved so explicit installs and test teardowns always take full effect - OkioIoProviderLoader: internal shim with public no-arg ctor allowing ServiceLoader to instantiate OkioIoProvider (Kotlin object, private ctor) - META-INF/services: registers OkioIoProviderLoader in sdk-io-okio3 - IoTest: adds selectProvider unit tests (0/1/2 candidates), ServiceLoader discovery assertion, and integration test proving Io.provider resolves without installProvider
Add a ThrowOnHttpErrorStep (and its AsyncHttpStep mirror) that maps a 4xx/5xx response to the matching typed HttpException, reusing Response.throwOnError() to buffer a bounded (<=1 MiB) copy of the error body so the exception stays readable after the connection is released while capping memory against rogue payloads. A 2xx (or any non-error status that reaches the step) is returned untouched — the success-path body is never read, consumed, or closed. The step occupies a new outermost non-pillar stage, PRE_REDIRECT, so it only evaluates the terminal response: it runs outside both the redirect and retry loops and therefore never throws on an intermediate 3xx hop nor preempts a retry of a retryable 5xx. Expose throwOnHttpError() on both pipeline builders to append the step in one call.
… preset Add a turnkey preset that assembles the standard resilience pillars in one call. HttpPipelineBuilder.appendStandardResilience() appends DefaultRedirectStep, DefaultRetryStep, and DefaultInstrumentationStep with their no-arg defaults, each landing in its own pillar slot so the run order is fixed by Stage. The HttpPipeline.standard(transport) companion is the shorthand for building that pipeline over a transport. The async mirror — AsyncHttpPipelineBuilder.appendStandardResilience(scheduler) and AsyncHttpPipeline.standard(transport, scheduler) — appends the async retry and instrumentation defaults. It takes a ScheduledExecutorService because the async retry step schedules its non-blocking backoff on one, and it omits a redirect step since the SDK ships no async redirect default.
…-replace A pillar stage in the staged HTTP pipeline admits exactly one step (redirect, retry, auth, logging, serde). Previously, appending or prepending a second step to an already-occupied pillar silently overwrote the first and only logged an SLF4J warning, so a misconfiguration — e.g. two retry steps — was easy to miss until runtime. Installing a distinct second step on an occupied pillar now throws IllegalStateException naming the stage, the existing step's type, and the incoming step's type, and points the caller at replace<T>() to swap. Re-adding the same instance stays idempotent, and the replace<T>() path is unchanged. The behaviour is symmetric across the sync and async builders; the enforcement lives in the shared StagedSteps helper, so the per-builder warning callback and its logger are gone. BREAKING CHANGE: a second distinct pillar step now throws rather than replacing the first. Callers that relied on the last-write-wins overwrite must use replace<T>() explicitly.
HttpPipeline now implements HttpClient (execute delegates to send) and AsyncHttpPipeline implements AsyncHttpClient (executeAsync delegates to sendAsync). send/sendAsync remain the primary entry points; the SPI methods are thin conformance delegates. This lets a fully configured pipeline stand in anywhere a transport is expected — most usefully as the client backing a Paginator/AsyncPaginator, so every page request runs through the pipeline's redirect / retry / auth / logging steps instead of hitting the raw transport. The inherited close() stays a no-op: a pipeline wraps a caller-supplied transport it does not own and must not close. BREAKING CHANGE: HttpPipeline and AsyncHttpPipeline gain a public supertype (HttpClient / AsyncHttpClient) and the corresponding execute/executeAsync/close members. The .api snapshot is updated accordingly.
…ures Both reference transports (OkHttp and java.net.http) now wrap a non-interrupt transport-level IOException — connection refused, DNS lookup failure, peer reset, TLS handshake failure, a non-interrupt timeout — in NetworkException before it leaves execute()/executeAsync(), instead of propagating the raw client exception. NetworkException is itself an IOException, so existing catch (IOException) sites are unaffected; callers that catch NetworkException now match the documented contract, and both retry stacks already classify it as retryable (it is a Retryable and an IOException). Interrupt semantics are preserved: an InterruptedIOException (including OkHttp's SocketTimeoutException and a thread interrupted mid-send on the JDK client) passes through untouched with the interrupt flag re-asserted, never repackaged as a NetworkException. The wrapping is scoped to the client dispatch call only, so a later response-adaptation failure keeps its own exception type. The JDK async path routes the exchange future's failure through a mapper that strips CompletionException/ExecutionException envelopes before classifying the root cause; the AsyncResponseBridge gains an opt-in error-mapping hook for that.
Add a proxyFromEnvironment() method to the OkHttpTransport and JdkHttpTransport builders. It resolves proxy settings from the process-wide global Configuration via ProxyOptions.fromConfiguration — the standard HTTPS_PROXY / HTTP_PROXY / NO_PROXY environment variables plus their https.proxy* / http.nonProxyHosts system-property equivalents — and applies the resolved proxy to the client being built. The method is strictly opt-in: the environment is never consulted unless it is called, and it is a no-op when nothing there configures a proxy (or when NO_PROXY=* bypasses everything). It writes the same slot as proxy(...), so an explicit proxy() and proxyFromEnvironment() follow last-writer-wins.
Add a terminal operator that maps an async Response future to a typed result via a ResponseHandler, mirroring how a ResponseHandler is invoked on a synchronous Response: - CompletableFuture<Response>.handleWith(handler) applies handler.handle on success and guarantees the Response is closed afterward (idempotent double close is safe even for handlers that read nothing), and on failure fails the returned future with the unwrapped cause so callers see the real transport/step failure rather than a Completion/Execution wrapper. - AsyncHttpPipeline.sendAsync(request, handler) as a convenience over sendAsync(request).handleWith(handler).
HttpPipeline.toAsync(executor) returned a future whose cancel(true) interrupts the blocking worker, but HttpClient.asAsync(executor) used a plain supplyAsync whose cancel(true) did nothing and leaked the running request. Extract the interrupting-future construction into a single internal helper (Futures.interruptibleFuture) that captures the worker thread and interrupts it on cancel(true), and route both bridges through it so cancellation behaves identically: cancel(true) interrupts the in-flight blocking send, cancel(false) lets it run to completion, and a not-yet-started or already-finished task is never interrupted.
The cursor, Link-header, and page-number pagination strategies each took a raw Kotlin function type (`(Response) -> CursorResult<T>` / `-> List<T>`) as their extractor constructor parameter. From Java that surfaced as `kotlin.jvm.functions.Function1` — an awkward, mangled type to implement. Replace each with a dedicated single-method functional interface — `CursorExtractor<T>`, `LinkExtractor<T>`, `PageNumberExtractor<T>` — mirroring the exact signature it replaces. Kotlin call sites pass a lambda unchanged via SAM conversion; Java callers now implement a clean, named interface (or pass a lambda) instead of `Function1`. This is a source- and binary-breaking change to the three strategy constructors; the `.api` snapshot is updated accordingly.
Decoding a parametric response (`List<User>`, `Map<String, Dto>`) previously
forced the call site to name a Jackson `TypeReference`, coupling application
code to the serde library the SPI exists to hide. A bare `Class<T>` token can't
carry the element type.
Add `TypeRef<T>`, a dependency-free type-capture base in sdk-core built purely
on `java.lang.reflect` (the Gson `TypeToken` pattern): `object : TypeRef<List<User>>() {}`
records the generic superclass and reads the reflective `Type` and erased
`rawClass` back out. `Deserializer` gains `deserialize(input, TypeRef<T>)` for
String and InputStream, with a default that delegates to the existing `Class`
overload when the ref wraps a plain class and otherwise fails clearly — so a
format-agnostic deserializer never silently returns a map. `JacksonSerde`
overrides these to resolve the full type via `ObjectMapper.constructType`, so
`List<User>` decodes with no Jackson type at the call site.
Also add `jsonHandlerOrThrow` (Class and TypeReference overloads): a status-aware
response handler that decodes only on 2xx and, on a non-2xx response, throws the
mapped `HttpException` (carrying the unconsumed error body) instead of trying to
deserialize the error payload as the success type. The plain `jsonHandler` is
unchanged.
…itional-header overloads ETag and HttpRange were @JvmInline value classes, so Kotlin mangled the JVM names of their factories and accessors (e.g. parse-1jReFK8, bytes-Oepi37k, Builder.ifMatch-a6HYibk). Those suffixes are not legal Java identifiers, which left the entire typed conditional-request / range surface unreachable from Java. Demote both to plain immutable final classes with hand-written equals/hashCode/ toString over the wrapped header string, preserving every factory, accessor, and flag. The factories now compile to clean, unmangled names (ETag.parse, ETag.strong, ETag.weak, HttpRange.bytes/from/suffix/parse) and ETag.ANY becomes a plain static field. Boxing is irrelevant for these header helpers. Add String overloads ifMatch(String)/ifNoneMatch(String) on RequestConditions.Builder that parse a raw RFC 7232 header value, giving Java callers a String escape hatch alongside the typed ETag overloads (which are also now unmangled). Blank/malformed values are rejected with IllegalArgumentException.
Introduce RequestOptions, an immutable per-request override carrier holding a timeout, an opaque tag map, and a maxRetries budget. Every field defaults to null/empty, meaning use-the-pipeline/transport-default, and a shared EMPTY instance represents override-nothing. Follows the SDK's private-ctor + Builder<T> + newBuilder() convention with a @JvmStatic builder() and value equality. This is the model half of per-request overrides; later commits thread it through the transport SPIs, the pipeline, and the retry step.
Add a per-call execute(request, options) / executeAsync(request, options) overload to the HttpClient and AsyncHttpClient SPIs. The overload is a default method that ignores options and delegates to the single-argument form, so both SPIs stay single-abstract-method fun interfaces and every existing implementer (SAM literals, older transports, test fakes) keeps compiling unchanged. Both reference transports override the overload to apply RequestOptions.timeout as the per-call timeout: OkHttp sets it on Call.timeout() (overriding the client's callTimeout for this call only), and the JDK transport resolves it against the configured responseTimeout and sets it via HttpRequest.timeout. A null timeout keeps the configured default. The single-argument execute paths now delegate to the overload with RequestOptions.EMPTY. Tests cover a short per-call timeout firing on a slow response, a generous per-call timeout not prematurely failing, and the EMPTY no-override path — sync and async, on both transports.
Thread the caller's RequestOptions through the sync and async pipelines. HttpPipeline.send and AsyncHttpPipeline.sendAsync gain a (request, options) overload; the no-options forms delegate with RequestOptions.EMPTY, and the HttpClient / AsyncHttpClient per-call SPI overrides route through them so options survive when a pipeline is used as a transport. The options ride on the per-call state, are exposed to steps via PipelineNext.options / AsyncPipelineNext.options, and are threaded into the terminal transport dispatch so per-call timeouts take effect. The stage retry steps (DefaultRetryStep and DefaultAsyncRetryStep) now read RequestOptions.maxRetries for the in-flight call: a non-null value replaces the configured HttpRetryOptions.maxRetries budget for that call only, so a caller can fail fast (maxRetries=0) or widen/narrow the budget without a second pipeline; a null value keeps the configured default. Tags are carried on the options and readable by steps; no further wiring — the recovery-package steps are a later unit's concern. Tests cover options reaching the transport and steps (sync + async, empty and stepped pipelines) and per-call maxRetries overriding, capping, and falling back to the configured budget on both retry stacks.
…ecovery to end the naming collision The recovery-aware primitives in `org.dexpace.sdk.core.pipeline` shared "Pipeline" and "RetryStep"/"ThrowOnHttpErrorStep" names with the user-facing stage-based dispatch pipeline in `org.dexpace.sdk.core.http.pipeline`, so it was impossible to tell which layer a given type belonged to. Give the recovery layer a distinct "Recovery" vocabulary: - ExecutionPipeline -> RecoveryChain (dispatch method execute -> recover) - RequestPipeline -> RequestRecoveryChain (dispatch method execute -> recover) - ResponsePipeline -> ResponseRecoveryChain (fold entry point stays `apply`) - pipeline.step.retry.RetryStep -> RetryRecovery - pipeline.step.ThrowOnHttpErrorStep -> ThrowOnHttpErrorRecovery The colliding stage-layer types (http.pipeline.steps.RetryStep / ThrowOnHttpErrorStep) are unchanged. Step interfaces and the shared retry utilities (RequestPipelineStep, ResponsePipelineStep, ResponseRecoveryStep, RetrySettings, BackoffCalculator, RetryAfterParser) keep their names. ClientIdentityStep previously exposed both the RequestPipelineStep `execute` override and a public `apply(request)`; collapse to a single public verb (`execute`), moving the transform to a private helper. This is a source- and binary-breaking rename (pre-1.0). Docs (pipelines.md, architecture.md, http.md) and the package maps in README/CLAUDE are updated to the new names, an orientation note distinguishes the dispatch pipeline from the recovery layer, and the stale claim that a duplicate pillar step "emits a warning" is corrected to reflect that a distinct second pillar step throws IllegalStateException.
… and a minimal GET sample Add Installation section with Gradle/Maven coordinates. Rewrite Quick start to lead with HttpPipeline.of + Request.get and show parsedWith(jsonHandler(...)).value() for typed reads. Add Coordinate column to Modules table. Add How-do-I section with bearer auth, throwOnHttpError, per-request RequestOptions, pagination, and jsonBody snippets. Rewrite ExampleApp.createUser to use the ergonomic jsonBody / parsedWith / throwOnError path. Add SimpleGetApp.kt (minimal plain-HTTP GET sample, ~30 lines) and SimpleGetAppTest smoke test.
…per-call overload
A per-call RequestOptions.maxRetries override was applied verbatim by the sync and async retry steps. A negative value therefore collapsed the retry budget to zero attempts, which is both surprising and inconsistent with the configured retry path — where a negative HttpRetryOptions.maxRetries is already clamped to the default budget. Treat a negative per-call override the same way: fall back to the configured budget rather than disabling retries. A caller that genuinely wants no retries still passes 0. Covers both DefaultRetryStep and DefaultAsyncRetryStep with a regression test on each stack.
RecoveryChain and its collaborators were renamed to the RequestRecoveryChain / ResponseRecoveryChain "chain" vocabulary, but the two constructor properties were left as requestPipeline / responsePipeline, so the getters and KDoc still spoke of "pipelines" while every referenced type said "chain". Rename the properties to requestChain / responseChain to match, and update the KDoc and references. This renames the public getters (getRequestChain / getResponseChain), a binary-incompatible change.
jsonBody(serde, value) was a thin pass-through to RequestBody.create(value, serde) with the arguments reversed and a name implying JSON-specific behavior it did not have — RequestBody.create already defaults the media type to the serde's content type. The two entry points did the same thing and invited confusion about which to reach for. Remove jsonBody and point callers (README, example app) at RequestBody.create(value, serde). Drops jsonBody from the public API.
…serialize The Request companion offered only get(url) and post(url, body) static factories even though the builder exposes all six verbs, and Response.deserialize could only decode via a Class token or a reified Kotlin type — neither of which lets a Java caller decode a parametric target such as List<User>. Add symmetric put/patch (with body) and delete/head (body-less) static factories mirroring the builder verbs, and a Response.deserialize(serde, TypeRef) overload that streams the body through the serde's deserializer for parametric decodes, closing the response afterward exactly like the Class overload.
…-close
On a non-2xx response, jsonHandlerOrThrow threw an HttpException that carried the
live, still-open response body. Handlers are typically run inside response.use {
... }, so as the exception unwound the block closed the response — and any caller
that later read the exception body hit a use-after-close on a released stream.
Route the error path through Response.throwOnError, which buffers a bounded
(<= 1 MiB) copy of the body into the thrown exception. The body now stays
readable after the response is closed. The 2xx path still streams and decodes as
before; KDoc updated to state the error body is buffered.
Several of the new developer-experience conveniences had edge-case bugs or incomplete threading. This tightens them: - MediaType's common constants (APPLICATION_JSON, TEXT_PLAIN, ...) are built directly via of() instead of delegating to CommonMediaTypes, breaking a class-initialization cycle that could leave the constants null depending on which class initialized first. - Request.build() defaults to GET only when no body is present; a body with no method now reports the missing method rather than the misleading "GET must not carry a request body". - Response.throwOnError() returns 1xx/3xx responses unchanged with their body intact instead of draining the body and throwing IllegalArgumentException; only 4xx/5xx map to an HttpException. - Per-call RequestOptions now thread through every sync<->async bridge (asAsync/asBlocking/toAsync/toBlocking) and through the virtual-thread and coroutine adapters, which previously dropped them via the one-arg SPI default. - OkHttp timeouts surface as a retryable NetworkException instead of a thread interrupt on both the sync and async paths, matching the JDK transport, and no longer set a spurious interrupt flag; a sub-millisecond per-call timeout is clamped so it is not truncated to okio's "no timeout"; RequestOptions rejects a non-positive timeout at construction. - interruptibleFuture closes a Response that loses the completion/cancel race instead of leaking its connection. - Io.provider consults the thread-context classloader before the defining one so ServiceLoader discovery works under nested classloaders, and warns when an explicit install replaces a provider that was already resolved and handed out. - appendStandardResilience() validates its target pillars up front, so a call that collides with an already-configured pillar leaves the builder unchanged, and its documentation states the precondition. - Serde.contentType() is abstract (JacksonSerde declares application/json) so a non-JSON serde cannot silently stamp the wrong Content-Type; JacksonSerde.from documents the ObjectMapper.copy() subclass requirement. - RequestBody.create(value, serde) serializes straight to bytes; the sync and async retry steps share one max-retries-override helper; error-status checks use Status.isError; the JDK transport reuses Futures.unwrap; both transports share ProxyOptions.fromEnvironment(). Public API snapshots are regenerated: Serde.contentType() is now abstract and ProxyOptions gains fromEnvironment().
- ResponseBody.string()/ResponseHandler.string() decode using the charset declared in the body's Content-Type, falling back to UTF-8, instead of hard-coding UTF-8 (silent mojibake on non-UTF-8 responses). - ResponseBody.create() close() is now idempotent so a source that throws on a second close cannot turn a successful call into a failure. - Response gains isInformational/isRedirect/isClientError/isServerError/isError alongside isSuccessful so callers need not reach into status.*. - CommonMediaTypes' six overlapping constants delegate to the MediaType companion constants, removing duplicate instances. - ETag.strong()/weak() reject characters outside the RFC 7232 etagc set. - RequestConditions.build() rejects mixing '*' with concrete entity-tags and collapses repeated '*' (RFC 7232 grammar). - Request builder verbs share one private helper; url(URI) also maps the unwrapped IllegalArgumentException from a non-absolute URI to the contextual message. - HttpRange KDoc corrected to describe the grammar the parser actually accepts.
- Io.installProvider and Io.provider are now @JvmStatic so Java callers use the documented Io.installProvider(...) / Io.getProvider() spelling directly. - The provider fast path no longer issues a volatile store on every read once a provider is resolved. - IoProvider gains an 'underlying' hook; the install-after-resolve WARN compares through it, so installing a singleton after its ServiceLoader shim was auto-resolved no longer logs a spurious mixed-provider warning. - Clarified the resolution KDoc: the scan repeats until it succeeds, a resolved provider is cached process-wide, and hierarchical-classloader deployments with distinct per-loader providers should install explicitly. - HttpRedirectOptions takes Set<Method> (matching its getter and the sibling option builders) and makes a single defensive copy per build. - RequestOptions.timeout KDoc documents the per-transport scope difference and that the timeout is per-attempt, not an overall deadline.
- TypeRef rejects a captured unresolved type variable at construction instead of silently resolving it to Object (heap pollution / late ClassCastException). - The reified Deserializer.deserialize and Response.deserialize extensions capture T as a TypeRef so a parametric target (List<User>) routes through the generic overloads rather than erasing to the raw class. - Deserializer gains a ByteArray + TypeRef overload for parity with the String and InputStream forms. - throwOnError buffers the error body into a replayable in-memory copy through a shared helper, so bodyAs(), string(), and bodySnapshot() can each read it; the buffer is acquired inside the body's use block so a provider-resolution failure still closes the connection. - Response.deserialize throws SerdeException (not a raw IllegalStateException) for a null body, staying inside the serde exception hierarchy callers catch. - ResponseExtensions gets @file:JvmName so Java reaches the helpers on a clean facade class.
- DefaultRetryStep / DefaultAsyncRetryStep no longer misclassify a
SocketTimeoutException (a subclass of InterruptedIOException) as cancellation;
a read timeout is now left to the normal retry classification and retried.
- DefaultRetryStep lets an unchecked exception (e.g. a typed HttpException thrown
by an inner throwOnHttpError pipeline used as its transport) propagate as-is
instead of burying it under IOException("HTTP pipeline failure").
- DefaultAsyncRetryStep stops the retry loop and closes any in-flight response
once the returned future is cancelled or completed, so a cancelled call
launches no further network attempts.
- RetryRecovery re-classifies each re-sent response: a status in
retryableStatuses is mapped to the matching HttpException (body buffered into a
bounded replayable copy, connection released), so a 503,503,200 sequence keeps
retrying to the 200 and the terminal outcome is a typed exception, not a raw
error response.
- RetryRecovery treats retryableStatuses as authoritative for an HttpException,
so a configured status the built-in classifier does not mark retryable (e.g.
425) is now retried, and the default set is the effective set.
- StagedSteps.reload is now all-or-nothing: it validates the rebuilt pillar
state in scratch collections and commits only on success, so a pillar
collision no longer leaves the builder half-reloaded with steps dropped.
- HttpPipelineBuilder/AsyncHttpPipelineBuilder gain explicit flattening
constructors that take a pipeline (from() delegates to them); the generic
transport constructor still nests. KDoc spells out flatten vs nest so the
common Builder(pipeline) call no longer silently nests.
- The duplicated pillar-vacancy check moves onto the shared StagedSteps.
- HttpPipeline/AsyncHttpPipeline document that close() does NOT close the
underlying transport, so use{}/try-with-resources over a pipeline no longer
implies the transport was released.
- AsyncHttpPipeline gains sendAsync(request, options, handler).
- AuthStep gates its 401-challenge replay on body replayability: a
non-replayable body surfaces the real 401 instead of a masked IOException.
- The async standard preset documents that it follows no redirects.
- ThrowOnHttpErrorRecovery reuses the shared bufferErrorBody helper.
- Stage.PRE_REDIRECT documents its outermost terminal-response role.
- Location / Content-Location header values are redacted through UrlRedactor (new redactUrlValue helper handling absolute and relative values) before logging, so an OAuth code, pre-signed signature, or implicit-flow token in a redirect target is no longer written to logs verbatim — matching url.full. - interruptibleFuture surfaces a RejectedExecutionException from a shut-down or saturated executor through the returned future instead of throwing synchronously, honouring the async-only failure-delivery contract. - InterruptibleFuture.cancel(true) hands off the interrupt under a short guard so a stale interrupt cannot land on the worker after it returns to the pool. - HttpClient.execute overloads declare @throws(IOException) (NetworkException extends IOException), so a Java transport can throw it and a Java caller can catch it at the call site; the @throws moves off asBlocking()'s pure-factory onto the wrapper's execute where the blocking wait happens. - A shared closeQuietly util replaces the duplicated silent-close copy in Futures.
- PageNumberExtractor and LinkExtractor (byte-identical SAMs) collapse into one ItemsExtractor<T>; CursorExtractor stays distinct. - Paginator/AsyncPaginator take an optional RequestOptions and pass it to every page fetch, so per-call overrides reach pagination when a pipeline drives it. - CloseablePages owns any page prefetched by a hasNext() probe and closes both the current and the prefetched page on close(), so an emptiness probe or a peeking wrapper no longer leaks a connection. - ClientIdentityStep append mode preserves all values of a multi-valued header and treats an all-blank token list as a no-op instead of emitting a blank one. - A serde-encoded multipart part defaults its Content-Type to the serde's media type when the caller gives none.
…oding - sdk-io-okio3 ships a consumer keep rule for OkioIoProviderLoader and its no-arg constructor, so the ServiceLoader-based zero-config provider survives R8/ProGuard shrinking instead of being tree-shaken away. - JacksonSerde.deserializeAs(InputStream, TypeReference) drives a per-call parser with AUTO_CLOSE_SOURCE disabled, so it no longer closes the caller's stream. - Every non-null Jackson decode overload throws DeserializationException when the body is the JSON literal null instead of returning null through a non-null type. - JacksonDeserializer overrides deserialize(ByteArray, TypeRef) for parametric parity. - jsonHandlerOrThrow lands on a clean JsonResponseHandlers Java facade. - decode() lets a genuine mid-stream IOException propagate unwrapped instead of rebranding it as a SerdeException. - A non-2xx non-error status is reported as a protocol condition (with the status code and ETag/Location captured) rather than a deserialization failure.
…ferences - Rewrite the README cursor-pagination example against the real Paginator and CursorPaginationStrategy/CursorExtractor signatures (the previous snippet used parameters that do not exist and would not compile). - Add PRE_REDIRECT to the pipeline-stages diagram and note its outermost, final-response-only role. - Clarify that throwOnHttpError maps 4xx/5xx and passes 1xx/3xx/2xx through. - Update docs/refs-comparison.md and docs/implementation-plan.md to the current recovery-stack type names (RequestRecoveryChain / ResponseRecoveryChain / RecoveryChain / RetryRecovery), leaving the *Step interfaces unchanged.
- OkHttp: a call cancelled inside OkHttp (dispatcher/interceptor) completes with a non-retryable InterruptedIOException instead of a retryable NetworkException, so a deliberately cancelled request is not re-sent; a call-timeout is discriminated by exception type and stays retryable. - OkHttp: an explicit caller Content-Type header now wins over the body's auto-stamped media type (previously BridgeInterceptor overwrote it). - JDK transport: emits Content-Type from the body's media type when the caller set none, so the RequestBody.create(value, serde) pattern carries a Content-Type on both transports; documents the residual bodyless-DELETE Content-Length: 0 difference the java.net.http API cannot avoid. - Documented the OkHttp vs JDK default response-timeout divergence and the JDK HTTPS-CONNECT Basic-proxy-auth limitation (with the workaround). - Coroutines/reactor/netty adapters gain per-call RequestOptions overloads so options are no longer dropped; asAsyncCoroutines closes a Response discarded in the cancellation race; corrected the coroutines and virtual-threads KDocs.
- apiDump for the accumulated sdk-core / sdk-io-okio3 public-API changes. - Update two RetryStepTest cases that asserted the old IOException-wrapping to expect the unchecked exception propagating as-is. - ktlint formatting and detekt suppressions (ReturnCount on the auth/async-retry early-exit paths; IteratorHasNextCallsNextMethod on the deliberate page prefetch) for the new code.
Cover the trickiest corrected behaviors so a future change cannot silently reintroduce them: RetryRecovery re-classifying re-sent responses (503,503,200 reaches 200; exhaustion yields the mapped exception; a configured retryable status widens the set); TypeRef rejecting a captured type variable; the Response status-range predicates; charset-aware ResponseBody.string(); the replayable throwOnError error body; SocketTimeoutException being retried rather than treated as cancellation; no spurious mixed-provider warning; atomic StagedSteps.reload and the flattening HttpPipelineBuilder(pipeline) constructor; the auth-challenge non-replayable-body path; Jackson rejecting a literal-null body and not closing a caller stream; and UrlRedactor.redactUrlValue.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The toolkit had strong internals whose ergonomics and documentation lagged behind: making a first request meant a mandatory global bootstrap, building a request meant a verbose builder with no verb shortcuts, reading a response meant null-chasing through
body?.source()?.readUtf8(), and the typed error model, streaming decoders, and resilience steps that already existed were undemonstrated or missing their last-mile one-liner. Two subsystems both called themselves "pipeline," and two different classes were both namedRetryStep.This change closes those gaps across the whole surface. It is a broad, mostly-additive pass with a set of deliberate pre-1.0 breaking changes where a name or signature was worth correcting now rather than after 1.0.
sdk-corekeeps its zero-runtime-dependency invariant; every convenience that needs Okio or Jackson lives in an adapter module.First-request friction
Io.providernow resolves a singleIoProvideron the classpath viaServiceLoader, soIo.installProvider(OkioIoProvider)is optional when the Okio adapter is present. This removes the one bootstrap line every caller previously had to run before the first request or hit a runtime failure; explicit install still overrides, and 0-or-many providers still fail loudly with an actionable message.HttpPipeline.standard(transport)/HttpPipelineBuilder.appendStandardResilience()assemble redirect + retry + instrumentation with sane defaults in one call, so getting resilience no longer requires knowing and ordering the individualDefault*Stepclasses. (The async mirror assembles retry + instrumentation; there is no async redirect step to include.)SimpleGetAppsample alongside the full one — the quick start now teaches the ergonomic path instead of the manual one.Request & response ergonomics
build()no longer requires an explicit method and no longer throws when one is omitted) and gains verb methods (get/post/put/delete/head/patch, where the body-less verbs also clear any previously-set body);Request.get/post/put/delete/head/patch(...)static factories for the common one-liners; typedaddHeader/setHeader(HttpHeaderName, String | List<String> | MediaType)overloads so callers holding a typed header name or media type no longer.toString()both by hand; and aurl(URI)overload.url(String)now throws an uncheckedIllegalArgumentExceptioninstead of a checkedMalformedURLException, so a literal URL no longer forces atry/catch.RequestBody.create(value, serde)serializes a typed value in one call instead of the manual serialize-then-wrap two-step;Serde.contentType()supplies the default media type so it doesn't have to be restated at every call site.MediaType.APPLICATION_JSON(and the other common constants) are now reachable directly onMediaType; previously the constants lived only on a separateCommonMediaTypesobject, so callers reached for the parse-a-string form (a typo risk).Response.isSuccessful,ResponseBody.string()/bytes(), and the fiveStatusrange predicates (isInformational,isRedirect,isClientError,isServerError,isError) remove the common null-chasing and status arithmetic;HttpExceptionFactory.isErrorStatusnow delegates toStatus.isError.response.deserialize(serde, Class | TypeRef)andresponse.parsedWith(jsonHandler(serde, T::class.java)).value()stream and close the body. The new dependency-freeTypeRef<T>enables parametric decode (List<User>) without a Jackson import at the call site (Deserializergains defaultTypeRefmethods; the Jackson adapter resolves the generic type), and a newjsonHandlerOrThrow(...)handler decodes on 2xx and throws the mappedHttpException(with a bounded, buffered error body) on non-2xx.Error handling
HttpPipelineBuilder.throwOnHttpError()(and its async mirror) maps 4xx/5xx to the typedHttpExceptionhierarchy so callers stop hand-writingcheck(status.isSuccess). It runs at a new outermostStage.PRE_REDIRECTdeliberately: evaluating the response at the outermost stage means it only ever sees the final response, so it never throws on an intermediate 3xx redirect hop and never preempts a retryable 5xx that the inner retry step would have recovered.Response.throwOnError()does the same at a call site, buffering the error body (bounded to 1 MiB) so the exception's body survives the response being closed;HttpException.bodyAs(...)decodes a typed error payload and closes the body.NetworkException(which extendsIOException, so existingcatch (IOException)still works) so the documentedNetworkExceptioncontract is real, and both retry stacks now classify the same failure identically.Configuration & per-request control
RequestOptionscarrier (send(request, options)) threads a per-calltimeoutandmaxRetriesoverride — plus an opaquetagsmap for correlation/extension — through the pipeline to the transports and the retry step, with fallback to the configured defaults. The SPI overloads (execute/executeAsync(request, options)) are default methods, so existing third-party transports keep compiling.HttpRetryOptions,HttpInstrumentationOptions, andHttpRedirectOptionsgain builders (Builder<T>+newBuilder()) so a Java caller can set the seventh field without positionally re-supplying the six defaults before it.HttpRetryOptions.fromConfiguration(...)wires the previously-orphaned max-retry key; the JDK transport now defaults to a 10s connect timeout (it was unbounded, deviating from the documented default); both transports gain opt-inproxyFromEnvironment().Java interop
ETagandHttpRangeare demoted from@JvmInline value classto plain final classes: their@JvmInlinename mangling (parse-1jReFK8,ifMatch-a6HYibk, …) produced identifiers Java can't call, making the whole typed conditional-request / range surface unreachable. They keep value semantics and gainStringoverloads onRequestConditions.Builder.ifMatch/ifNoneMatch.CursorExtractor/LinkExtractor/PageNumberExtractor) instead of a rawkotlin.jvm.functions.Function1, which is opaque and awkward to implement from Java.IllegalStateException(naming the stage and pointing atreplace<T>()) instead of silently warning-and-replacing, so a mis-assembled pipeline fails at build time instead of dropping a step.Async
CompletableFuture<Response>.handleWith(handler)replaces the error-pronewhenComplete { r, e -> … r.use{} }pattern every async caller re-wrote: it applies the handler, closes the response on both paths, and unwrapsCompletionExceptionso the caller sees the real cause.AsyncHttpPipeline.sendAsync(request, handler)adds the typed overload.HttpClient.asAsync(executor)andHttpPipeline.toAsync(executor)now share one interrupting-future path, socancel(true)interrupts the blocking send on either bridge (asAsyncpreviously leaked the running request).Naming
HttpPipeline/AsyncHttpPipelinenow implement theHttpClient/AsyncHttpClientSPIs (an additive public-supertype change), so a fully-configured pipeline can drive pagination or nest as a transport — its steps run instead of the raw transport.ExecutionPipeline→RecoveryChain,RequestPipeline/ResponsePipeline→RequestRecoveryChain/ResponseRecoveryChain(and theirrequestChain/responseChainmembers), the recoveryRetryStep/ThrowOnHttpErrorStep→RetryRecovery/ThrowOnHttpErrorRecovery(ending the two duplicate simple-name collisions with the stage steps), andClientIdentityStepdrops its redundant publicapply(request)so there is a single transform verb.Breaking changes
Pre-1.0 alpha; source/binary breaks are expected. The notable ones:
url(String)no longer declaresMalformedURLException(Java callers that caught it must drop the catch).ETag/HttpRangeare no longer@JvmInline value classes (their mangled member names change).Function1.RecoveryChain/RetryRecovery/ThrowOnHttpErrorRecovery,requestChain/responseChain), andClientIdentityStep.apply(request)is removed.jsonBody(serde, value)is removed in favor of the coreRequestBody.create(value, serde)(it was a redundant pass-through with reversed argument order and a misleading name).API snapshots are regenerated accordingly.
Behavior changes (source/binary compatible)
JacksonSerde.from(mapper)now operates on a defensivemapper.copy()and registersTristateModuleby default (with aregisterTristateopt-out overload): a bare caller-supplied mapper silently corruptedTristatePATCH payloads (absent vs. explicit-null), and the call no longer mutates the caller's mapper.RequestOptions.maxRetrieswith a negative value now falls back to the configured default, matching how a negative configured value is already clamped.delete()clears a previously-set body, matchingget()/head().Testing
Full gated build green: every module's tests, ktlint, detekt,
allWarningsAsErrors, binary-compatibility (apiCheck), the aggregate 80% Kover floor, and the R8 shrink-survival guard. New behavior is covered by unit tests per area; thesdk-examplesample and its smoke test run against the new APIs.Correctness and hardening follow-ups
A close read of the DX surface above surfaced a set of correctness, resource-safety, security, and
cross-transport-consistency gaps in the new conveniences (and a few latent ones they newly exposed).
This section folds in the fixes. They are grouped by area; each is stated as the problem and the change.
The full gated build — every module's tests, ktlint, detekt,
allWarningsAsErrors,apiCheck, the 80%Kover floor, and the R8 shrink-survival guard — is green, and API snapshots are regenerated.
Security & logging
Location(andContent-Location) sat in the defaultinstrumentation allow-list, so their values — full URLs — were written to logs unredacted, bypassing
the
UrlRedactorthat redactsurl.full. Because logging runs inside the redirect loop, an OAuth302 Location: …?code=…, a pre-signed download URL, or an implicit-flow fragment token landed inplaintext. These headers are now routed through
UrlRedactor(newredactUrlValue, handling absoluteand relative values) before logging, so the query/fragment credentials are redacted while the host/path
stays visible for diagnostics.
Retry, recovery & cancellation
SocketTimeoutExceptionextendsInterruptedIOException, so both retry steps treated a plain read timeout as a cancellation and neverretried it (and, on the sync path, set the caller thread's interrupt flag). Socket timeouts are now
excluded from the interrupt carve-out and left to normal retry classification.
IOException.DefaultRetryStepwrapped every non-IOExceptionterminal failure in
IOException("HTTP pipeline failure"), so when a pipeline carryingthrowOnHttpError()was used as another pipeline's transport, the typedHttpExceptionwas destroyed asit crossed the outer retry step. Unchecked exceptions now propagate as-is (they do not violate the
@Throws(IOException)contract), keeping the typed exception catchable.DefaultAsyncRetryStepdid not check the returned future beforescheduling the next attempt, so a cancelled call kept issuing network requests. The retry loop and both
completion handlers now stop and close any in-flight response once the future is done.
RetryRecoverytreated anyresponse from a re-sent attempt as terminal, so a
503, 503, 200sequence returned the raw second503instead of retrying to the
200. Re-sent responses are now re-classified: a status inretryableStatusesis mapped to the matchingHttpException(body buffered into a bounded, replayablecopy, connection released), so retries continue and the terminal outcome is a typed exception.
retryableStatusescould only narrow.RetryRecoverygatedHttpExceptionretries on the built-inclassifier before consulting
retryableStatuses, so a configured status the classifier did not alreadydeem retryable (e.g.
425) was silently ignored.retryableStatusesis now authoritative for anHttpException.(
dispatcher().cancelAll(), an interceptor'scall.cancel()) surfaced as a retryableNetworkExceptionand was re-sent. Such cancellations now complete with a non-retryable
InterruptedIOException; acall-timeout is discriminated by exception type and stays retryable.
Resource safety
throwOnErrorerror bodies were single-use. The buffered exception body was consumed by the firstread, so
bodyAs(...)followed by abodySnapshot()saw an empty body. The error body is now bufferedinto a replayable in-memory copy through a shared helper (also used by the recovery-layer error mapper, so
the two cannot drift), and the buffer is acquired inside the body's
useblock so a provider-resolutionfailure still releases the connection.
ResponseBody.createcould fail a successful call on double-close. Itsclose()now guards against asecond close, honouring the documented idempotent-close contract even if the wrapped source throws.
CloseablePagesbuffers the page fetched by ahasNext()probeand now closes both the current and any prefetched page on
close(), so an emptiness check or a peekingwrapper no longer strands a connection.
asAsyncCoroutinesnow closes aResponsecomputed in the window where the returned future is cancelled.interruptibleFuturenow surfaces aRejectedExecutionExceptionfrom a shut-down executor through the returned future rather than throwing onthe caller's thread, and hands off
cancel(true)'s interrupt under a short guard so a stale interruptcannot land on a pooled thread after the task finishes.
AuthStepre-sent the request body on a 401 challengewithout checking replayability; a single-use body tripped its consume-once guard and surfaced as an
IOException. The replay is now gated on body replayability, matching the retry and redirect steps.Type-safe decoding
TypeRefnow rejects a captured unresolved type variable atconstruction instead of resolving it to
Object(which caused heap pollution and a lateClassCastException). The reifiedDeserializer/Response.deserializeextensions captureTas aTypeRef, sodeserialize<List<User>>()routes through the generic overloads instead of the erased rawclass.
Deserializergains aByteArray+TypeRefoverload for parity.nullJSON body NPE'd later. Jackson decode overloads that promise a non-null value nowthrow
DeserializationExceptionwhen the body is the JSON literalnull, at the boundary rather than atfirst use.
Response.deserializethrew outside the serde hierarchy. A null body now throwsSerdeExceptionrather than a raw
IllegalStateException.deserializeAs(InputStream, …)closed the caller's stream. It now drives a per-call parser withauto-close disabled, matching its contract and the sibling overloads.
jsonHandlerOrThrowmislabelled failures.decode()lets a genuine mid-streamIOExceptionpropagate instead of rebranding it as a codec failure; a non-2xx non-error status (e.g. a
304) isreported as a protocol condition with the status and
ETag/Locationpreserved, not as a deserializationfailure.
Cross-transport consistency
Content-Typebehaved differently per transport. The JDK transport now emitsContent-Typefrom thebody's media type when the caller set none (so the
RequestBody.create(value, serde)pattern carries aContent-Typeon both transports), and on OkHttp an explicit callerContent-Typeheader now wins overthe body's auto-stamped type (previously
BridgeInterceptoroverwrote it). The residual bodyless-DELETEContent-Length: 0difference thejava.net.httpAPI cannot avoid is documented, as are the differingdefault response-timeout budgets and the JDK's HTTPS-CONNECT Basic-proxy-auth limitation (with its
workaround).
Zero-config I/O under shrinking
sdk-io-okio3consumer keep-rules pinned onlyOkioIoProvider, not theServiceLoader-instantiated shim the zero-config path actually resolves, so ashrunk build lost its only provider. The shim and its constructor are now kept. Installing the real
provider after the shim was auto-resolved also no longer logs a spurious mixed-provider warning.
Provider & builder ergonomics
Io.installProvider/Io.providerare@JvmStatic(the documented Java spelling now compiles); thehot-path avoids a redundant volatile write; the resolution KDoc is corrected.
HttpPipelineBuilder/AsyncHttpPipelineBuildergain explicit flattening constructors that take apipeline, so
Builder(pipeline)no longer silently nests it as a raw transport;StagedSteps.reloadisall-or-nothing so a pillar collision cannot leave a half-configured builder; the pillar-vacancy check is
shared;
close()documents that it does not close the underlying transport;AsyncHttpPipelinegains asendAsync(request, options, handler)overload.Java interop & API surface
HttpClient.executedeclares@Throws(IOException)(the SDK'sNetworkExceptionis anIOException), soa Java transport can throw it and a Java caller can catch it at the call site; the misplaced
@Throwsonthe
asBlocking()factory moves onto the wrapper'sexecute.RequestOptionsoverloads so options are nolonger dropped; the paginators thread
RequestOptionsto every page fetch.ItemsExtractor;ETagandRequestConditionsreject RFC-illegal values;ResponseBody.string()honours the declared charset;Responseexposes the full status-range predicate family; the sixCommonMediaTypesconstants thatoverlap
MediaType's delegate to it;ClientIdentitySteppreserves multi-valued headers and skips blanktokens; a serde-encoded multipart part defaults its
Content-Type;HttpRedirectOptionstakesSetandmakes a single defensive copy.
Documentation
snippet did not compile); the pipeline-stage diagram includes the outermost stage; the
throwOnHttpErrorand per-call-timeout semantics are stated accurately; and
docs/references to the renamed recovery-stacktypes are updated.