diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md
new file mode 100644
index 00000000..9d58dc43
--- /dev/null
+++ b/.superpowers/sdd/progress.md
@@ -0,0 +1,67 @@
+# DX Improvements — Progress Ledger
+
+Branch: worktree-dx-improvements | Base: b015fd3
+Plan: docs/superpowers/plans/2026-07-03-dx-improvements.md
+
+## Work units (file-cohesive)
+- [x] U1 Response family (T1 Status preds, T2 isSuccessful+string/bytes, T3 throwOnError+bodyAs, T9 deserialize)
+- [x] U2 Request family + MediaType (T4, T5, T6, T7, T8-core)
+- [x] U3 Jackson serde (T10 Tristate fix, T8 jsonBody)
+- [x] U5 *Options builders + config + JDK timeout (T12, T13)
+- [x] U6 ServiceLoader IoProvider (T15)
+- [x] Pipeline steps (T16 throwOnHttpError, T17 standard, T22 pillar-fail, T25 HttpPipeline:HttpClient)
+- [ ] U8 Async + transports (T14 NetworkException, T18 handleWith, T19 cancel, T23 proxyEnv)
+- [x] U9 Serde TypeRef + pagination SAM (T20, T21)
+- [x] U10 Value-class demotion (T24 ETag/HttpRange/RequestConditions)
+- [x] U11 Per-request RequestOptions (T26)
+- [x] U12 Recovery-stack rename (T27, T28)
+- [x] U4 Docs (T11) — after ServiceLoader so samples are final
+- [ ] FINAL apiDump + full gated build + final review + PR
+
+## Completed
+- U1: complete (commits 43a101e..a51d5d1, gates green: 2002 tests, detekt, apiCheck, ktlint)
+- U2: complete (commits 0ac3035..e1283ee, 36 tests, gates green)
+- U3: complete (commits db870b5..c5b456d, 38 tests, gates green)
+
+## Wave-1 review findings (from wave1-review.md)
+- FIX (Important) bodyAs: close body in finally (ResponseExtensions.kt ~60-68)
+- FIX (Important) throwOnError: bound buffered body (cap ~1 MiB) + doc (ResponseExtensions.kt ~34-42)
+- FIX (Important) T3 wording: "replayable" -> "buffered in memory, readable after throw"
+- FIX (Minor) bodyAs: debug-log the swallowed exception (ref e)
+- FIX (Minor) delete(): clear stale body for consistency with get()/head()
+- DEFER to final: #6 jsonBody redundancy (plan-mandated, adjudicate), #8 create(value) nullability doc
+- U5: complete (commits 3a1482f..275fc33, 11 tests, gates green)
+- Wave-1 fixes: complete (commit c54e8cf; leak, OOM-cap, wording, log, delete-body — tests green)
+- U6: complete (commit 7e66659, ServiceLoader fallback, full sdk-core suite green)
+- U7a: complete (commits 8d54cee..c84cdc2; added Stage PRE_REDIRECT(50); NOTE: subagent briefly committed on MAIN then reverted it to b015fd3 — main verified clean, user kuri changes untouched)
+- U7b: complete (commits b9196cb..c7a8cb5, pillar hard-fail + HttpPipeline:HttpClient; cwd guard held, main clean)
+- U8a: complete (commits f3830bf..eba18e1, transports NetworkException + proxyFromEnvironment, 13 suites green)
+- U8b: complete (commits e39ae69..0b22857, handleWith + unified cancel; 2nd main-slip reverted cleanly, main verified intact)
+- U9: complete (commits 85f3307..7db7482, SAM extractors + TypeRef, gates green, path guard held)
+
+## Final-review items (defer)
+- jsonHandlerOrThrow leaves LIVE error body on throw; throwOnError BUFFERS — reconcile for consistency (buffer both, bounded 1 MiB)
+- jsonBody(serde,value) redundancy vs RequestBody.create(value,serde) — decide keep/drop
+- docs/pipelines.md stale "replace emits warning" line (U7b) — fix in U4 docs
+- run aggregate koverVerify (80% floor) at finalization
+- U10: complete (commit fcbe657, ETag/HttpRange demoted + String overloads, de-mangling proven)
+- U11: complete (commits 2f707ac..5f8e494, RequestOptions timeout+maxRetries wired, full gate green)
+- final-review: U11 negative per-call maxRetries used as-is (no clamp) — decide if OK; PipelineNext.options now public
+- U12: complete (commit 2d8df24, recovery stack -> RecoveryChain/RetryRecovery/ThrowOnHttpErrorRecovery, grep-clean, gates green)
+- U4: complete (commit d36b3dd, README+samples, sdk-example test+run green)
+- ALL 13 UNITS COMPLETE — starting finalization
+
+## Finalization
+- apiDump reconciled (virtualthreads straggler committed)
+- FULL GATED BUILD: BUILD SUCCESSFUL — all module tests, detekt, ktlint, allWarningsAsErrors, apiCheck, R8 shrink guard, kover 80% floor ALL GREEN
+- Final whole-branch review: running
+- TODO: triage final review + deferred items -> one fix wave -> PR
+- Final review fix wave: complete (02bec41,5d95eea,e752dbc,cfce016,3ef4028) — all 5 findings fixed, gate green
+- NOTE: intermediate commit cfce016 api-stale (same-file entanglement); branch HEAD green
+- Running final full gated build before push
+
+## DONE
+- Final full gated build: BUILD SUCCESSFUL (kover+R8+all gates)
+- Pushed worktree-dx-improvements -> origin/dx-improvements
+- PR #208 opened (base main), clean (no styleguide/.superpowers/plan), CI build running
+- 33 commits, +6277/-761 across 124 files
diff --git a/CLAUDE.md b/CLAUDE.md
index f3c3fff6..69172a95 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -73,8 +73,10 @@ modules), and concrete I/O plugs in via `IoProvider`.
Layered, from the bottom up:
1. **`io/` contracts** — `Source`/`Sink`, `BufferedSource`/`BufferedSink`, `Buffer`, `TeeSink`. All
- interfaces; no concrete I/O in `sdk-core`. `Io.installProvider(provider)` wires the single `IoProvider`
- seam once at startup; a missing provider throws `IllegalStateException` with the install instruction.
+ interfaces; no concrete I/O in `sdk-core`. `Io.provider` resolves the single `IoProvider` seam: an
+ explicit `Io.installProvider(provider)` always wins, otherwise it auto-discovers one on the classpath
+ via `ServiceLoader` (so the Okio adapter registers itself). Zero or multiple providers with no explicit
+ install throw `IllegalStateException` with an actionable message.
2. **HTTP models** — immutable `Request`/`Response`/`Headers`/`MediaType` etc., private constructor +
`Builder` + `newBuilder()`. `RequestBody.isReplayable()`/`toReplayable()`; `FileRequestBody` lets
transports dispatch `FileChannel.transferTo`.
@@ -86,8 +88,8 @@ Layered, from the bottom up:
- `http.pipeline` — stage-based runtime: `HttpPipelineBuilder` + `HttpStep` ordered by `Stage` with
pillar stages (exactly one REDIRECT / RETRY / AUTH / LOGGING / SERDE step per pipeline), plus the
async mirror (`AsyncHttpPipeline`, `AsyncHttpStep`) and sync→async bridges.
- - `pipeline` — recovery-aware primitives: `RequestPipeline`, `ResponsePipeline`, `ExecutionPipeline`,
- `ResponseOutcome`, with steps like `RetryStep` (backoff + `Retry-After`), `IdempotencyKeyStep`,
+ - `pipeline` — recovery-aware primitives: `RequestRecoveryChain`, `ResponseRecoveryChain`, `RecoveryChain`,
+ `ResponseOutcome`, with steps like `RetryRecovery` (backoff + `Retry-After`), `IdempotencyKeyStep`,
`ClientIdentityStep`.
See `docs/pipelines.md` before touching either.
6. **Transports** — `sdk-transport-okhttp` (Java 8) and `sdk-transport-jdkhttp` (Java 11). Both implement
@@ -134,8 +136,10 @@ Layered, from the bottom up:
- **Public API changes fail `apiCheck`.** Any visible signature change needs `./gradlew apiDump` and the
regenerated `api/*.api` files committed alongside the change. Never run `apiDump` to silence an
*unintentional* break.
-- **`Io.installProvider(...)` must run before any code touches `Io.provider`.** Tests install
- `OkioIoProvider` in `@BeforeTest`; production installs in the application startup path.
+- **`Io.provider` resolves a provider via `ServiceLoader` when none was explicitly installed**, so a
+ single adapter on the classpath (e.g. `sdk-io-okio3`) needs no bootstrap call. An explicit
+ `Io.installProvider(...)` still overrides auto-discovery and always wins; tests install `OkioIoProvider`
+ in `@BeforeTest` to stay deterministic. Zero or multiple providers with no explicit install fail loudly.
- **Coverage floor is aggregate 80% line coverage.** The `minBound(80)` rule lives on the root-aggregate
`:koverVerify`, which the root `check` task depends on, so a plain `./gradlew build` enforces it. New
under-tested code can trip the gate even when its own module builds clean — check `koverHtmlReport` to see
diff --git a/README.md b/README.md
index c9028c68..9befe5a2 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,12 @@ Current version `0.0.1-alpha.1`. The public API is stabilising and breaking chan
## Contents
[Quick start](#quick-start) ·
+[Installation](#installation) ·
[Design principles](#design-principles) ·
[Modules](#modules) ·
[Documentation](#documentation) ·
[Usage](#usage) ·
+[How do I…](#how-do-i) ·
[Pipeline stages](#pipeline-stages) ·
[Package map](#package-map-sdk-core) ·
[Shrinking with R8 / ProGuard](#shrinking-with-r8--proguard) ·
@@ -35,34 +37,98 @@ Current version `0.0.1-alpha.1`. The public API is stabilising and breaking chan
## Quick start
+**Minimal path — one factory, one send.**
+
+When `sdk-io-okio3` is on the classpath it registers itself automatically via `ServiceLoader`;
+no explicit `Io.installProvider(...)` call is needed unless you have multiple providers or need
+to override the default.
+
```kotlin
-Io.installProvider(OkioIoProvider) // once, at application startup
+val transport = OkHttpTransport.builder().build()
+val pipeline = HttpPipeline.of(transport)
-val transport = OkHttpTransport.builder()
- .connectTimeout(Duration.ofSeconds(5))
- .readTimeout(Duration.ofSeconds(30))
- .build()
+pipeline.send(Request.get("https://api.example.com/v1/resource")).use { response ->
+ response.throwOnError() // throws HttpException on 4xx / 5xx
+ println(response.body?.string())
+}
+```
-val pipeline = HttpPipelineBuilder(transport)
- .append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3)))
- .append(KeyCredentialAuthStep(KeyCredential("my-api-key")))
- .build()
+**With standard resilience** (redirect following, retry with backoff, instrumentation):
-val request = Request.builder()
- .method(Method.GET)
- .url("https://api.example.com/v1/resource")
- .build()
+```kotlin
+val pipeline = HttpPipeline.standard(transport)
+```
-pipeline.send(request).use { response ->
- if (response.status.isSuccess) {
- val bytes = response.body?.source()?.readByteArray()
- // process
- }
+**Typed round-trip** with `sdk-serde-jackson`:
+
+```kotlin
+val serde = JacksonSerde.withDefaults()
+
+val body = RequestBody.create(CreateUserRequest(name = "Ada", email = "ada@example.org"), serde)
+pipeline.send(Request.post("https://api.example.com/v1/users", body)).use { response ->
+ response.throwOnError()
+ val user = response.parsedWith(jsonHandler(serde, User::class.java)).value()
+ println(user)
}
```
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.
+## Installation
+
+Published to Maven Central under group `org.dexpace`, version `0.0.1-alpha.1`.
+
+**Gradle (Kotlin DSL):**
+
+```kotlin
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ // Core contracts — always required
+ implementation("org.dexpace:sdk-core:0.0.1-alpha.1")
+
+ // I/O adapter (auto-registers via ServiceLoader; exactly one is needed)
+ implementation("org.dexpace:sdk-io-okio3:0.0.1-alpha.1")
+
+ // Transport — pick one (or bring your own HttpClient)
+ implementation("org.dexpace:sdk-transport-okhttp:0.0.1-alpha.1")
+ // implementation("org.dexpace:sdk-transport-jdkhttp:0.0.1-alpha.1") // JDK 11+
+
+ // Serialization (optional — skip if you manage raw bytes yourself)
+ implementation("org.dexpace:sdk-serde-jackson:0.0.1-alpha.1")
+}
+```
+
+**Maven:**
+
+```xml
+
+ org.dexpace
+ sdk-core
+ 0.0.1-alpha.1
+
+
+ org.dexpace
+ sdk-io-okio3
+ 0.0.1-alpha.1
+
+
+ org.dexpace
+ sdk-transport-okhttp
+ 0.0.1-alpha.1
+
+
+ org.dexpace
+ sdk-serde-jackson
+ 0.0.1-alpha.1
+
+```
+
+Async runtime adapters (`sdk-async-coroutines`, `sdk-async-reactor`, `sdk-async-netty`,
+`sdk-async-virtualthreads`) are optional; add only the ones your project uses.
+
## Design principles
- The request/response model is async-first and immutable: private constructors, builders, `newBuilder()` copies, and Java-friendly factories (`@JvmOverloads`, `@JvmStatic`, `@JvmField` where applicable).
@@ -73,17 +139,17 @@ For a complete, runnable version of this wiring — an `IoProvider`, a transport
## Modules
-| 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` ser/de. | Java 8 |
+| Module | Maven coordinate | Purpose | JVM target |
+|---|---|---|---|
+| `sdk-core` | `org.dexpace: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` | `org.dexpace:sdk-io-okio3` | Okio 3.x implementation of `IoProvider`. Auto-registers via `ServiceLoader`. | Java 8 |
+| `sdk-async-coroutines` | `org.dexpace:sdk-async-coroutines` | Kotlin coroutines adapter: `suspend` extensions, `CoroutineScope.completableFutureOf`, MDC propagation. | Java 8 |
+| `sdk-async-reactor` | `org.dexpace:sdk-async-reactor` | Reactor `Mono` / `Flux` adapter, including SSE → `Flux` with backpressure. | Java 8 |
+| `sdk-async-netty` | `org.dexpace:sdk-async-netty` | Netty `io.netty.util.concurrent.Future` adapter with bidirectional cancellation. | Java 8 |
+| `sdk-async-virtualthreads` | `org.dexpace:sdk-async-virtualthreads` | JDK 21+ virtual-thread executor adapter (`AutoCloseable`). | Java 21 |
+| `sdk-transport-okhttp` | `org.dexpace:sdk-transport-okhttp` | OkHttp 5.x implementation of `HttpClient` + `AsyncHttpClient`. | Java 8 |
+| `sdk-transport-jdkhttp` | `org.dexpace:sdk-transport-jdkhttp` | `java.net.http.HttpClient` (JEP 321) implementation of `HttpClient` + `AsyncHttpClient`. | Java 11 |
+| `sdk-serde-jackson` | `org.dexpace:sdk-serde-jackson` | Jackson 2.18 implementation of `Serde` with SDK-correct defaults (`FAIL_ON_UNKNOWN_PROPERTIES=false`, `WRITE_DATES_AS_TIMESTAMPS=false`) + `Tristate` 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.
@@ -152,15 +218,13 @@ val pipeline = HttpPipelineBuilder(transport)
.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")))
+ .post(RequestBody.create("""{"key": "value"}""", CommonMediaTypes.APPLICATION_JSON))
.build()
pipeline.send(request).use { response ->
- if (response.status.isSuccess) {
- val bytes = response.body?.source()?.readByteArray()
+ if (response.isSuccessful) {
+ val bytes = response.body?.bytes()
// process
}
}
@@ -244,17 +308,94 @@ val preview = loggedResponse.snapshot(maxBytes = 8 * 1024)
val full = loggedResponse.source().readByteArray() // still available
```
+## How do I…
+
+Copy-paste snippets for the most common tasks. All snippets assume a built `pipeline` and,
+where serde is needed, a `JacksonSerde.withDefaults()` instance.
+
+### Stamp a static bearer token on every request
+
+```kotlin
+val pipeline = HttpPipelineBuilder(transport)
+ .append(KeyCredentialAuthStep(KeyCredential(apiKey = "my-token", prefix = "Bearer")))
+ .build()
+```
+
+### Throw typed exceptions on 4xx / 5xx
+
+Add `throwOnHttpError()` to the pipeline builder — it maps 4xx / 5xx error responses to typed
+`HttpException` subclasses (`BadRequestException`, `TooManyRequestsException`, etc.); a non-error
+1xx / 3xx (e.g. an unfollowed redirect) or 2xx response passes through unchanged:
+
+```kotlin
+val pipeline = HttpPipelineBuilder(transport)
+ .appendStandardResilience() // redirect + retry + instrumentation
+ .throwOnHttpError() // map 4xx/5xx to HttpException after retry exhaustion
+ .build()
+```
+
+Or call `response.throwOnError()` at the call site instead (see the quick start).
+
+### Override the timeout for a single request
+
+```kotlin
+val opts = RequestOptions.builder()
+ .timeout(Duration.ofSeconds(60))
+ .maxRetries(0) // disable retry for this call
+ .build()
+
+pipeline.send(Request.get("https://api.example.com/slow"), opts).use { response ->
+ // ...
+}
+```
+
+### Page through a cursor-based resource
+
+```kotlin
+// The strategy rewrites the request URL for each page, setting `cursorQueryParam` to the cursor
+// from the previous page; the extractor reads the page's items and next cursor in a single pass.
+val paginator = Paginator(
+ httpClient = pipeline,
+ initialRequest = Request.get("https://api.example.com/items"),
+ strategy = CursorPaginationStrategy(
+ extractor = { response -> CursorResult(parseItems(response), nextToken(response)) },
+ cursorQueryParam = "page_token",
+ ),
+)
+
+paginator.iterateAll().forEach { item -> println(item) }
+```
+
+The `HttpPipeline` implements `HttpClient`, so pass the pipeline directly wherever a paging
+strategy expects a transport — every page request then runs through the same resilience stack.
+
+### Send a JSON body
+
+```kotlin
+// RequestBody.create defaults the Content-Type to the serde's media type (application/json).
+val body = RequestBody.create(myPayload, serde)
+
+val request = Request.builder()
+ .url("https://api.example.com/v1/resource")
+ .post(body)
+ .addHeader(HttpHeaderName.ACCEPT, CommonMediaTypes.APPLICATION_JSON)
+ .build()
+```
+
## Pipeline stages
Steps 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)
+PRE_REDIRECT → 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)
```
+`PRE_REDIRECT` is the outermost stage: a step placed there (e.g. the one `throwOnHttpError()`
+installs) observes only the final response, after the redirect and retry loops have run.
+
See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
## Package map (`sdk-core`)
@@ -273,7 +414,7 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
| `auth` | `Credential` sealed hierarchy (`KeyCredential`, `NamedKeyCredential`, `BearerToken`), `BearerTokenProvider`, `AuthScheme`, per-operation `AuthRequirement` / `AuthDescriptor` with `AuthDescriptorResolver` precedence ladder, RFC 7235 challenge parser, `BasicChallengeHandler`, `DigestChallengeHandler`, `CompositeChallengeHandler`. |
| `pagination` | Unified paging surface: `Page` (exposes the raw per-page `Response`; `Closeable`) / `PageInfo`; `Paginator` / `AsyncPaginator` (strategy-driven, sync + async, each carrying a `maxPages` safety cap) exposing item-level (`iterateAll` / `streamAll`, which eager-close each page) and page-level views — sync `byPage` returns the auto-closing `CloseablePages` view (wrap in `use {}` / try-with-resources), async `forEachPageAsync` delivers a live page valid only during the consumer callback — over cursor / page-number / link-header `PaginationStrategy` implementations; `PagedIterable` (functional, transport-agnostic first/next-page fetchers); and the internal `PageWalker` driver shared by the sync paths. Token-style APIs use `CursorPaginationStrategy` with the query-param name set (e.g. `"page_token"`). |
| `operation` | `OperationParams` — SPI projecting an operation's typed inputs (path / query / header / body) into a `Request` and the context chain, via `toRequest(baseUrl)` / `toRequestContext(baseUrl, dispatch)`. |
-| `pipeline` | Recovery-aware primitives: `RequestPipeline`, `ResponsePipeline`, `ExecutionPipeline` over a sealed `ResponseOutcome`, with steps (`pipeline.step`, `pipeline.step.retry`) like `RetryStep`, `ResponseRecoveryStep`, `IdempotencyKeyStep`, `ClientIdentityStep`. |
+| `pipeline` | Recovery-aware primitives: `RequestRecoveryChain`, `ResponseRecoveryChain`, `RecoveryChain` over a sealed `ResponseOutcome`, with steps (`pipeline.step`, `pipeline.step.retry`) like `RetryRecovery`, `ResponseRecoveryStep`, `IdempotencyKeyStep`, `ClientIdentityStep`. |
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions, `Tristate` (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`. |
@@ -283,7 +424,8 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
| `generics` | `Builder` — 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")`.
+construct it with a `CursorExtractor` and the desired query-param name, e.g.
+`CursorPaginationStrategy(extractor, "page_token")`.
## Shrinking with R8 / ProGuard
diff --git a/docs/architecture.md b/docs/architecture.md
index d748b8a9..7ad0fa20 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -251,14 +251,19 @@ conditional on identity, so closing an earlier link never removes a live success
Two cooperating pipeline layers, both fully implemented — no placeholders.
+> **Orientation.** The stage-based `http.pipeline` (`HttpPipeline`) is the user-facing **dispatch**
+> pipeline; the recovery-aware `pipeline` (`RecoveryChain`) is the **recovery** layer that threads a
+> sealed `ResponseOutcome`.
+
#### Stage-based runtime (`org.dexpace.sdk.core.http.pipeline`)
`HttpPipelineBuilder` assembles ordered `HttpStep`s 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.
+terminal `SEND` stage is `HttpClient.execute` itself. Installing a *distinct* second step in a
+pillar throws `IllegalStateException` (re-installing the same instance is idempotent); use
+`replace()` to swap the occupant.
| Type | Role |
|-----------------------|---------------------------------------------------------------------------------|
@@ -284,9 +289,9 @@ response step.
| Type | Role |
|---------------------|----------------------------------------------------------------------------|
-| `RequestPipeline` | Folds a `Request` through a sequence of `RequestPipelineStep`s |
-| `ResponsePipeline` | Runs `ResponsePipelineStep`s on success and `ResponseRecoveryStep`s on every outcome |
-| `ExecutionPipeline` | Wires request pipeline → `HttpClient` → response pipeline |
+| `RequestRecoveryChain` | Folds a `Request` through a sequence of `RequestPipelineStep`s |
+| `ResponseRecoveryChain` | Runs `ResponsePipelineStep`s on success and `ResponseRecoveryStep`s on every outcome |
+| `RecoveryChain` | Wires request pipeline → `HttpClient` → response pipeline |
| `ResponseOutcome` | Sealed `Success(Response)` / `Failure(Throwable)` sum type |
##### Step System (`pipeline.step`)
@@ -304,7 +309,7 @@ Retry primitives live in `pipeline.step.retry`:
| Type | Role |
|--------------------|-----------------------------------------------------------------------|
-| `RetryStep` | Recovery step that re-invokes the transport with backoff + `Retry-After` honoring |
+| `RetryRecovery` | 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 |
@@ -442,7 +447,7 @@ Log correlation is wired through SLF4J MDC: `Span.makeCurrentWithLoggingContext(
└──────────┬────────────┘
│
┌──────────▼────────────┐
- │ RequestPipeline │
+ │ RequestRecoveryChain │
│ step1 → step2 → ... │ Add headers, auth, validation
└──────────┬────────────┘
│
@@ -466,7 +471,7 @@ Log correlation is wired through SLF4J MDC: `Span.makeCurrentWithLoggingContext(
│
▼
┌──────────────────────┐
- │ ResponsePipeline │ Post-processing steps
+ │ ResponseRecoveryChain │ Post-processing steps
└──────────┬───────────┘
│
┌──────────▼───────────┐
@@ -737,9 +742,9 @@ they should construct a fresh one.
| `http.pipeline` | HttpPipeline, HttpPipelineBuilder, HttpStep, Stage, AsyncHttpPipeline (+ `.steps`) |
| `http.sse` | ServerSentEvent, ServerSentEventReader, ServerSentEventListener |
| `auth` | Credential, KeyCredential, BearerToken, ChallengeHandler, Basic/Digest/CompositeChallengeHandler, AuthChallengeParser |
-| `pipeline` | RequestPipeline, ResponsePipeline, ExecutionPipeline, ResponseOutcome |
+| `pipeline` | RequestRecoveryChain, ResponseRecoveryChain, RecoveryChain, ResponseOutcome |
| `pipeline.step` | PipelineStep, RequestPipelineStep, ResponsePipelineStep, ResponseRecoveryStep, ClientIdentityStep, IdempotencyKeyStep |
-| `pipeline.step.retry`| RetryStep, RetrySettings, BackoffCalculator, RetryAfterParser |
+| `pipeline.step.retry`| RetryRecovery, RetrySettings, BackoffCalculator, RetryAfterParser |
| `pagination` | Page (Closeable; raw per-page Response), PageInfo, CloseablePages, Paginator, AsyncPaginator, PaginationStrategy, Cursor/PageNumber/LinkHeader strategies, PagedIterable, PageWalker (internal) |
| `client` | HttpClient, AsyncHttpClient |
| `serde` | Serde, Serializer, Deserializer, Tristate |
diff --git a/docs/http.md b/docs/http.md
index 25ac8ac1..7b601d63 100644
--- a/docs/http.md
+++ b/docs/http.md
@@ -387,11 +387,11 @@ if (!response.status.isSuccess) {
The factory throws `IllegalArgumentException` if called with a status outside 400..599 —
1xx/2xx/3xx outcomes are not exceptions and should not be funneled through this path.
-For the recovery-aware pipeline, `ThrowOnHttpErrorStep` packages this mapping as a
-`ResponsePipelineStep`: drop it into a `ResponsePipeline.responseSteps` list and it calls
-`fromResponse` on a 4xx/5xx response and throws the result. `ResponsePipeline` converts that
+For the recovery-aware pipeline, `ThrowOnHttpErrorRecovery` packages this mapping as a
+`ResponsePipelineStep`: drop it into a `ResponseRecoveryChain.responseSteps` list and it calls
+`fromResponse` on a 4xx/5xx response and throws the result. `ResponseRecoveryChain` converts that
throw into a `ResponseOutcome.Failure`, which then flows through the recovery chain (e.g.
-`RetryStep`) exactly like a transport failure — and because the thrown `HttpException` is
+`RetryRecovery`) exactly like a transport failure — and because the thrown `HttpException` is
`Retryable`, retry classification keys off it uniformly. The step is a building block; no
default pipeline in `sdk-core` assembles it for you.
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
index e83b1980..3809fabf 100644
--- a/docs/implementation-plan.md
+++ b/docs/implementation-plan.md
@@ -86,7 +86,7 @@ don't trip over each other.
| `sdk-core/client/HttpClient.kt` | WU-4 only | WU-3/5/6/7 must NOT touch this file. |
| `sdk-core/client/AsyncHttpClient.kt` | WU-4 only | Same. |
| `sdk-core/instrumentation/` | WU-7 only | WU-3/5/6 emit no tracer events in this pass — wired in a follow-up. |
-| `sdk-core/pipeline/ResponsePipeline.kt` and step contracts | WU-1 only | WU-3 reads the contract WU-1 produces but does not modify pipeline types. |
+| `sdk-core/pipeline/ResponseRecoveryChain.kt` and step contracts | WU-1 only | WU-3 reads the contract WU-1 produces but does not modify pipeline types. |
| `sdk-core/http/response/Status.kt` | WU-2 only | New exception package alongside, but Status itself only WU-2. |
| `sdk-transport-okhttp/`, `sdk-transport-jdkhttp/` | WU-4 only (lifecycle) | Other WUs don't touch transport modules. |
| `settings.gradle.kts` | WU-8 (sdk-serde-jackson) | WU-10's auth landed in `sdk-core`, so no second `settings.gradle.kts` edit materialized. |
@@ -96,9 +96,9 @@ don't trip over each other.
### WU-1: AfterError pipeline upgrade
**Status: shipped.** `ResponseOutcome` (sealed `Success`/`Failure`), `ResponseRecoveryStep`, the
-recovery-aware `ResponsePipeline`, and `ExecutionPipeline` are all in `sdk-core/.../pipeline`.
+recovery-aware `ResponseRecoveryChain`, and `RecoveryChain` are all in `sdk-core/.../pipeline`.
-**Goal.** Replace the empty `ResponsePipeline` placeholder with Airbyte-style recovery
+**Goal.** Replace the empty `ResponseRecoveryChain` placeholder with Airbyte-style recovery
semantics. Add a third step type that takes `Either` and can rescue or
rethrow. Funnel **all** exceptions through this path uniformly (don't repeat Airbyte's bug
where `BeforeRequest` throws bypass `AfterError`).
@@ -110,8 +110,8 @@ where `BeforeRequest` throws bypass `AfterError`).
- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseOutcome.kt` — sealed class `ResponseOutcome { data class Success(response); data class Failure(throwable) }`. Simpler than `Either<>`.
**Files (modify):**
-- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponsePipeline.kt` — promote from empty interface to a fold over `(ResponsePipelineStep+, ResponseRecoveryStep+)` lists. Document semantics.
-- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ExecutionPipeline.kt` — wire request → transport → outcome → recovery → response steps. Catch transport exceptions, wrap into `ResponseOutcome.Failure`, feed through the recovery chain.
+- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChain.kt` — promote from empty interface to a fold over `(ResponsePipelineStep+, ResponseRecoveryStep+)` lists. Document semantics.
+- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt` — wire request → transport → outcome → recovery → response steps. Catch transport exceptions, wrap into `ResponseOutcome.Failure`, feed through the recovery chain.
- `docs/pipelines.md` — update to reflect new architecture.
**Acceptance criteria:**
@@ -177,7 +177,7 @@ the original per-subclass table got wrong: **408 is retryable** (it has its own
### WU-3: Retry pipeline step
-**Status: shipped.** `RetrySettings`, `RetryStep`, `BackoffCalculator`, and `RetryAfterParser`
+**Status: shipped.** `RetrySettings`, `RetryRecovery`, `BackoffCalculator`, and `RetryAfterParser`
are all in `sdk-core/.../pipeline/step/retry`.
**Goal.** Build the best-in-class retry step combining Square's `Retry-After` /
@@ -187,7 +187,7 @@ are all in `sdk-core/.../pipeline/step/retry`.
**Files (create):**
- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetrySettings.kt` — immutable settings via Builder: `totalTimeout`, `initialDelay`, `delayMultiplier`, `maxDelay`, `maxAttempts`, `jitter` (fraction 0.0..1.0), `retryableStatuses: Set`, `retryableMethods: Set`.
-- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStep.kt` — implements both `ResponseRecoveryStep` (decide retry on failure) and a `RequestPipelineStep` (records attempt start). Uses `ScheduledExecutorService` for delay (never `Thread.sleep`).
+- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery.kt` — implements both `ResponseRecoveryStep` (decide retry on failure) and a `RequestPipelineStep` (records attempt start). Uses `ScheduledExecutorService` for delay (never `Thread.sleep`).
- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/BackoffCalculator.kt` — exponential backoff + symmetric jitter; deadline-shrinking cap (per gax).
- `sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryAfterParser.kt` — `Retry-After` (numeric seconds OR HTTP date) + `X-RateLimit-Reset` (Unix epoch) parsing. Precedence: `Retry-After` numeric > `Retry-After` HTTP-date > `X-RateLimit-Reset` > exponential fallback.
diff --git a/docs/pipelines.md b/docs/pipelines.md
index 4b223147..3d7ecc8d 100644
--- a/docs/pipelines.md
+++ b/docs/pipelines.md
@@ -3,6 +3,12 @@
This document covers the design, architecture, and usage of the SDK's pipeline system for
composable request/response processing.
+> **Orientation — two layers, two names.** This document describes the recovery-aware `pipeline`
+> package: `RecoveryChain` orchestrating `RequestRecoveryChain` / `ResponseRecoveryChain` over a
+> sealed `ResponseOutcome` — the resilience/recovery layer. The user-facing **dispatch** pipeline
+> is the separate stage-based `http.pipeline` package (`HttpPipeline` / `HttpStep` / `Stage`); see
+> [Async Dispatch](#async-dispatch) and `docs/architecture.md`.
+
## Table of Contents
- [Overview](#overview)
@@ -10,9 +16,9 @@ composable request/response processing.
- [Pipeline Types](#pipeline-types)
- [Step System](#step-system)
- [Context Flow](#context-flow)
-- [RequestPipeline](#requestpipeline)
-- [ResponsePipeline](#responsepipeline)
-- [ExecutionPipeline](#executionpipeline)
+- [RequestRecoveryChain](#requestrecoverychain)
+- [ResponseRecoveryChain](#responserecoverychain)
+- [RecoveryChain](#recoverychain)
- [Retry](#retry)
- [Async Dispatch](#async-dispatch)
- [Design Decisions](#design-decisions)
@@ -47,9 +53,9 @@ This enables:
| File | Package | Purpose |
|----------------------------|-----------------------|------------------------------------------------------------------|
-| `RequestPipeline.kt` | `pipeline` | Sequential request transformation |
-| `ResponsePipeline.kt` | `pipeline` | Recovery-aware response fold (response + recovery steps) |
-| `ExecutionPipeline.kt` | `pipeline` | Top-level request → transport → recovery → response orchestrator |
+| `RequestRecoveryChain.kt` | `pipeline` | Sequential request transformation |
+| `ResponseRecoveryChain.kt` | `pipeline` | Recovery-aware response fold (response + recovery steps) |
+| `RecoveryChain.kt` | `pipeline` | Top-level request → transport → recovery → response orchestrator |
| `ResponseOutcome.kt` | `pipeline` | Sealed `Success(Response)` / `Failure(Throwable)` sum type |
| `PipelineStep.kt` | `pipeline.step` | Generic `PipelineStep` functional interface |
| `RequestPipelineStep.kt` | `pipeline.step` | `Request → Request` specialization |
@@ -57,8 +63,8 @@ This enables:
| `ResponseRecoveryStep.kt` | `pipeline.step` | `ResponseOutcome → ResponseOutcome` recovery hook |
| `IdempotencyKeyStep.kt` | `pipeline.step` | `RequestPipelineStep` that injects an idempotency key |
| `ClientIdentityStep.kt` | `pipeline.step` | `RequestPipelineStep` that injects client identity headers |
-| `RetryStep.kt` | `pipeline.step.retry` | `ResponseRecoveryStep`: backoff retry on retryable failures |
-| `RetrySettings.kt` | `pipeline.step.retry` | Immutable retry configuration for `RetryStep` |
+| `RetryRecovery.kt` | `pipeline.step.retry` | `ResponseRecoveryStep`: backoff retry on retryable failures |
+| `RetrySettings.kt` | `pipeline.step.retry` | Immutable retry configuration for `RetryRecovery` |
---
@@ -69,7 +75,7 @@ This enables:
The SDK defines three pipeline types, each handling a different phase of the HTTP lifecycle:
```
- RequestPipeline
+ RequestRecoveryChain
(transform request — BeforeRequest)
│
▼
@@ -77,24 +83,24 @@ The SDK defines three pipeline types, each handling a different phase of the HTT
(transport — produces ResponseOutcome)
│
▼
- ResponsePipeline
+ ResponseRecoveryChain
(response steps + recovery chain
— AfterSuccess + AfterError)
- Orchestrated by ExecutionPipeline
+ Orchestrated by RecoveryChain
(catches all exceptions, threads through recovery)
```
| Pipeline | Input | Output | Phase |
|---------------------|-------------------|-------------------|--------------------------------------------------------|
-| `RequestPipeline` | `Request` | `Request` | Pre-execution: transform the request |
-| `ExecutionPipeline` | `Request` | `Response` | Top-level orchestrator: request + transport + recovery |
-| `ResponsePipeline` | `ResponseOutcome` | `ResponseOutcome` | Post-execution: response transforms + recovery chain |
+| `RequestRecoveryChain` | `Request` | `Request` | Pre-execution: transform the request |
+| `RecoveryChain` | `Request` | `Response` | Top-level orchestrator: request + transport + recovery |
+| `ResponseRecoveryChain` | `ResponseOutcome` | `ResponseOutcome` | Post-execution: response transforms + recovery chain |
### Step System
Two distinct step shapes are wired into the pipeline. The first is the generic transformer
-used by `RequestPipeline` and `ResponsePipeline`:
+used by `RequestRecoveryChain` and `ResponseRecoveryChain`:
```kotlin
public fun interface PipelineStep {
@@ -109,7 +115,7 @@ public fun interface RequestPipelineStep : PipelineStep
public fun interface ResponsePipelineStep : PipelineStep
```
-The second shape is the recovery hook used by `ResponsePipeline`'s recovery chain — it takes
+The second shape is the recovery hook used by `ResponseRecoveryChain`'s recovery chain — it takes
the full outcome rather than a bare value so steps can inspect failures:
```kotlin
@@ -118,7 +124,7 @@ public fun interface ResponseRecoveryStep {
}
```
-Retry is implemented as a `ResponseRecoveryStep` (`RetryStep`) rather than a property of any
+Retry is implemented as a `ResponseRecoveryStep` (`RetryRecovery`) rather than a property of any
single step, so it composes uniformly with the rest of the recovery chain. See
[Retry](#retry).
@@ -146,17 +152,17 @@ known.
---
-## RequestPipeline
+## RequestRecoveryChain
-The `RequestPipeline` processes a `Request` through an ordered list of
+The `RequestRecoveryChain` processes a `Request` through an ordered list of
`RequestPipelineStep`s. Each step receives the current request and the dispatch context,
and returns a (potentially modified) request.
```kotlin
-public class RequestPipeline(
+public class RequestRecoveryChain(
public val steps: List = emptyList(),
) {
- public fun execute(request: Request, context: DispatchContext): Request
+ public fun recover(request: Request, context: DispatchContext): Request
}
```
@@ -165,7 +171,7 @@ Empty pipelines return the input request unchanged. Individual steps are typical
courtesy of `fun interface RequestPipelineStep`:
```kotlin
-val pipeline = RequestPipeline(
+val pipeline = RequestRecoveryChain(
steps = listOf(
RequestPipelineStep { request, context ->
request.newBuilder()
@@ -176,8 +182,8 @@ val pipeline = RequestPipeline(
)
```
-If a step throws, `RequestPipeline.execute` propagates the throwable unchanged. The
-surrounding `ExecutionPipeline` is responsible for translating that throwable into a
+If a step throws, `RequestRecoveryChain.recover` propagates the throwable unchanged. The
+surrounding `RecoveryChain` is responsible for translating that throwable into a
`ResponseOutcome.Failure` so it can be observed by recovery steps (see below).
### Common request steps
@@ -193,14 +199,14 @@ surrounding `ExecutionPipeline` is responsible for translating that throwable in
---
-## ResponsePipeline
+## ResponseRecoveryChain
-The `ResponsePipeline` is the recovery-aware post-execution counterpart to `RequestPipeline`.
+The `ResponseRecoveryChain` is the recovery-aware post-execution counterpart to `RequestRecoveryChain`.
It folds a `ResponseOutcome` (a sealed sum of `Success(Response)` and `Failure(Throwable)`)
through two ordered step lists:
```kotlin
-public class ResponsePipeline(
+public class ResponseRecoveryChain(
responseSteps: List = emptyList(),
recoverySteps: List = emptyList(),
)
@@ -235,7 +241,7 @@ to surface a `Response` or rethrow.
#### Close ownership: discarding a `Success` response
When a step is handed a `ResponseOutcome.Success`, the wrapped `Response` holds an open
-transport connection / body stream that must be closed exactly once. The `ResponsePipeline`
+transport connection / body stream that must be closed exactly once. The `ResponseRecoveryChain`
takes that responsibility on **only one path: when a step throws while holding the response.**
Both the success-path (`applyResponseSteps`) and the recovery chain (`invokeRecovery`)
close-before-propagate — they close the in-hand response and attach any close error to the
@@ -286,25 +292,25 @@ single value.
| Step | Purpose |
|---------------------|------------------------------------------------------------------------|
-| `RetryStep` | Re-invoke transport on retryable failures with exponential backoff |
+| `RetryRecovery` | Re-invoke transport on retryable failures with exponential backoff |
| Status-to-exception | Map 4xx/5xx responses (or transport `IOException`) to typed exceptions |
| Auth-401 eviction | Evict cached OAuth token on `UnauthorizedException` and retry once |
| Circuit breaker | Open the breaker on consecutive failures; fast-fail for the open phase |
---
-## ExecutionPipeline
+## RecoveryChain
-The `ExecutionPipeline` is the top-level entry point that ties `RequestPipeline`, the
-`HttpClient` transport, and the recovery-aware `ResponsePipeline` together. SDK consumers
-call `executionPipeline.execute(request, context)` and receive a `Response` (or rethrow the
+The `RecoveryChain` is the top-level entry point that ties `RequestRecoveryChain`, the
+`HttpClient` transport, and the recovery-aware `ResponseRecoveryChain` together. SDK consumers
+call `recoveryChain.recover(request, context)` and receive a `Response` (or rethrow the
terminal failure).
```kotlin
-public class ExecutionPipeline(
+public class RecoveryChain(
public val httpClient: HttpClient,
- public val requestPipeline: RequestPipeline = RequestPipeline(),
- public val responsePipeline: ResponsePipeline = ResponsePipeline(),
+ public val requestChain: RequestRecoveryChain = RequestRecoveryChain(),
+ public val responseChain: ResponseRecoveryChain = ResponseRecoveryChain(),
)
```
@@ -312,12 +318,12 @@ public class ExecutionPipeline(
```
┌─────────────────────────────────────────┐
- │ ExecutionPipeline │
+ │ RecoveryChain │
└─────────────────────────────────────────┘
│
▼
┌────────────────┐
- │ RequestPipeline│ (BeforeRequest)
+ │ RequestRecoveryChain│ (BeforeRequest)
└────────┬───────┘
│ throw
│ ──────────────┐
@@ -334,7 +340,7 @@ public class ExecutionPipeline(
└────────┬────────┘
▼
┌────────────────────────────┐
- │ ResponsePipeline │
+ │ ResponseRecoveryChain │
│ │
│ [responseSteps (Success)] │ (AfterSuccess)
│ │ │
@@ -356,7 +362,7 @@ the recovery chain runs. Recovery steps observe the failure regardless of where
pipeline it originated — this fixes Airbyte's design defect where a `BeforeRequest` exception
bypassed `AfterError` entirely (`utils/Hook.java`).
-`execute` rethrows the terminal `Failure.error` unchanged when no recovery step rescued it.
+`recover` rethrows the terminal `Failure.error` unchanged when no recovery step rescued it.
Wrapping into typed SDK exceptions is the recovery chain's job — the typed `HttpException`
hierarchy and the auth steps map raw failures into domain exceptions there.
@@ -364,15 +370,15 @@ hierarchy and the auth steps map raw failures into domain exceptions there.
## Retry
-Retry is a `ResponseRecoveryStep` — `RetryStep` — wired into a `ResponsePipeline`'s
+Retry is a `ResponseRecoveryStep` — `RetryRecovery` — wired into a `ResponseRecoveryChain`'s
`recoverySteps`. It re-executes a failed request with exponential backoff, server-driven
pacing (`Retry-After` / `X-RateLimit-Reset`), and a total-timeout budget that shrinks the
per-attempt deadline as attempts accrue.
-A `RetryStep` is constructed against a captured transport and a single request template:
+A `RetryRecovery` is constructed against a captured transport and a single request template:
```kotlin
-public class RetryStep(
+public class RetryRecovery(
public val httpClient: HttpClient,
public val settings: RetrySettings,
public val request: Request,
@@ -485,12 +491,12 @@ with state, dependencies, and configuration.
Steps operate on immutable `Request` and `Response` objects — each transforming step produces
a new instance via `newBuilder().build()` rather than mutating in place. The pipelines
-themselves are immutable after construction: `RequestPipeline` wraps its step list in an
-unmodifiable view, `ResponsePipeline` does the same for both step lists, and `ExecutionPipeline`
+themselves are immutable after construction: `RequestRecoveryChain` wraps its step list in an
+unmodifiable view, `ResponseRecoveryChain` does the same for both step lists, and `RecoveryChain`
holds final references to its components. Instances are therefore safe to share across threads
provided the steps they hold are themselves thread-safe.
-This is what makes retry tractable. Because a step never destroys its input, `RetryStep` can
+This is what makes retry tractable. Because a step never destroys its input, `RetryRecovery` can
re-send the original `Request` template verbatim — the only safety question is idempotency
(method + body replayability), not state corruption from a partially-applied step. Recovery is
modelled as a fold over `ResponseOutcome` rather than rollback over mutated state: a failure
@@ -504,14 +510,14 @@ order they execute. There is no automatic dependency resolution or topological s
Consuming libraries are responsible for ensuring correct ordering. Common patterns:
```
-RequestPipeline.steps (BeforeRequest):
+RequestRecoveryChain.steps (BeforeRequest):
1. Validation steps (fail fast on bad input)
2. Header injection (User-Agent, Accept, Content-Type)
3. Authentication (Authorization header)
4. Logging (log the final request)
```
-Retry is **not** a request step — it lives in `ResponsePipeline.recoverySteps` so it can observe
+Retry is **not** a request step — it lives in `ResponseRecoveryChain.recoverySteps` so it can observe
the transport outcome and re-issue the request. Order recovery steps so retry runs before any
status-to-exception mapping you do not want a transient failure to surface prematurely.
@@ -544,8 +550,10 @@ hop itself rather than a configurable pillar. The reasons:
surgical `insertAfter` / `insertBefore` / `replace` edits operate against this declared order.
- **Pillar-uniqueness invariants.** Redirect, retry, auth, logging, and serde are concerns you
want *exactly one* of — two retry layers or two auth layers is almost always a bug. A pillar
- stage enforces that: installing a second step in a pillar replaces the first and emits a
- `pipeline.pillar.replaced` SLF4J warning (`HttpPipelineBuilder`). The shipped pillar steps go
+ stage enforces that: installing a *distinct* second step in a pillar fails fast with an
+ `IllegalStateException` rather than silently overwriting the first — re-installing the *same*
+ instance is idempotent, and to swap the existing step you call `replace()`
+ (`HttpPipelineBuilder` / `StagedSteps`). The shipped pillar steps go
further and lock their slot at the type level — `RedirectStep`, `RetryStep`, `AuthStep`, and
`InstrumentationStep` each declare `final override val stage`, so a subclass cannot relocate
itself out of its pillar. Nested decorators cannot express "there is exactly one auth layer";
@@ -588,9 +596,9 @@ val authStep = RequestPipelineStep { request, context ->
.build()
}
-val pipeline = RequestPipeline(steps = listOf(loggingStep, authStep))
+val pipeline = RequestRecoveryChain(steps = listOf(loggingStep, authStep))
-val finalRequest = pipeline.execute(request, DispatchContext.default())
+val finalRequest = pipeline.recover(request, DispatchContext.default())
```
### Configuring a retry step
@@ -601,9 +609,9 @@ val settings = RetrySettings.builder()
.initialDelay(Duration.ofMillis(250))
.build()
-val retryStep = RetryStep(httpClient = transport, settings = settings, request = request)
+val retryStep = RetryRecovery(httpClient = transport, settings = settings, request = request)
-// Wired into a ResponsePipeline's recovery chain, or invoked directly:
+// Wired into a ResponseRecoveryChain's recovery chain, or invoked directly:
val response = retryStep.attempt()
```
@@ -618,16 +626,16 @@ val mapToTypedException = ResponseRecoveryStep { outcome ->
}
}
-val pipeline = ExecutionPipeline(
+val pipeline = RecoveryChain(
httpClient = transport,
- requestPipeline = RequestPipeline(listOf(authStep, userAgentStep)),
- responsePipeline = ResponsePipeline(
+ requestChain = RequestRecoveryChain(listOf(authStep, userAgentStep)),
+ responseChain = ResponseRecoveryChain(
responseSteps = listOf(decodingStep),
recoverySteps = listOf(retryStep, mapToTypedException),
),
)
-val response = pipeline.execute(request, DispatchContext.default())
+val response = pipeline.recover(request, DispatchContext.default())
```
If any phase of the pipeline raises an exception, the recovery chain observes it through a
@@ -640,9 +648,9 @@ unwrapped: `Success` returns the `Response`; `Failure` rethrows.
| File | Visibility | Description |
|---------------------------------|------------|----------------------------------------------------------------------------|
-| `RequestPipeline.kt` | public | Sequential request transformation |
-| `ResponsePipeline.kt` | public | Recovery-aware response fold (response + recovery steps) |
-| `ExecutionPipeline.kt` | public | Top-level orchestrator: request → transport → recovery → response |
+| `RequestRecoveryChain.kt` | public | Sequential request transformation |
+| `ResponseRecoveryChain.kt` | public | Recovery-aware response fold (response + recovery steps) |
+| `RecoveryChain.kt` | public | Top-level orchestrator: request → transport → recovery → response |
| `ResponseOutcome.kt` | public | Sealed `Success(Response)` / `Failure(Throwable)` sum type |
| `step/PipelineStep.kt` | public | Generic `PipelineStep` functional interface |
| `step/RequestPipelineStep.kt` | public | `Request → Request` specialization |
@@ -650,5 +658,5 @@ unwrapped: `Success` returns the `Response`; `Failure` rethrows.
| `step/ResponseRecoveryStep.kt` | public | `ResponseOutcome → ResponseOutcome` recovery hook |
| `step/IdempotencyKeyStep.kt` | public | `RequestPipelineStep` that injects an idempotency key |
| `step/ClientIdentityStep.kt` | public | `RequestPipelineStep` that injects client identity headers |
-| `step/retry/RetryStep.kt` | public | `ResponseRecoveryStep`: backoff retry on retryable failures |
-| `step/retry/RetrySettings.kt` | public | Immutable retry configuration for `RetryStep` |
+| `step/retry/RetryRecovery.kt` | public | `ResponseRecoveryStep`: backoff retry on retryable failures |
+| `step/retry/RetrySettings.kt` | public | Immutable retry configuration for `RetryRecovery` |
diff --git a/docs/refs-comparison.md b/docs/refs-comparison.md
index 6622722e..02a4f63d 100644
--- a/docs/refs-comparison.md
+++ b/docs/refs-comparison.md
@@ -49,7 +49,7 @@ Source agent reports are not committed; this document is the synthesized conclus
3. **The pipeline architecture absorbed Airbyte's `Hook` taxonomy**
(`SdkInit` / `BeforeRequest` / `AfterSuccess` / `AfterError`) — the cleanest middleware
shape we've seen. It maps onto `RequestPipelineStep` / `ResponsePipelineStep` plus a
- recovery-aware `ResponsePipeline` that folds a sealed `ResponseOutcome`
+ recovery-aware `ResponseRecoveryChain` that folds a sealed `ResponseOutcome`
(`Success(Response)` / `Failure(Throwable)`) rather than a bare response.
4. **For code generation, build our own.** A KotlinPoet/JavaPoet-based emitter sitting on
swagger-parser, distributed as a Gradle plugin, targeting our `sdk-core` runtime. None
@@ -110,8 +110,8 @@ Our `IoProvider` seam + `Source`/`Sink` contracts is the architecturally cleanes
| SDK | Architecture | Verdict |
|---|---|---|
-| Ours | `RequestPipeline` / recovery-aware `ResponsePipeline` fold over a sealed `ResponseOutcome`; `PipelineStep` `fun interface`s | Right shape; recovery semantics in place |
-| Expedia | `ExecutionPipeline` = `Request → Request` + `Response → Response` folds | Can't intercept transport, can't loop, can't proceed |
+| Ours | `RequestRecoveryChain` / recovery-aware `ResponseRecoveryChain` fold over a sealed `ResponseOutcome`; `PipelineStep` `fun interface`s | Right shape; recovery semantics in place |
+| Expedia | `RecoveryChain` = `Request → Request` + `Response → Response` folds | Can't intercept transport, can't loop, can't proceed |
| Square | One OkHttp `Interceptor` (the retry one) | No SDK-level pipeline |
| Airbyte | **Hook taxonomy: `SdkInit`, `BeforeRequest`, `AfterSuccess`, `AfterError`** | Best design in the cohort |
| gax | Per-call-shape decorator chain (`Callables.retrying(...)`, `TracedUnaryCallable`, ...) | Powerful but N parallel hierarchies for N call shapes |
@@ -122,7 +122,7 @@ How Airbyte's hook taxonomy (`utils/Hook.java` in their repo) maps to our types,
- `AfterSuccess` ≡ `ResponsePipelineStep` (runs on the success path only)
- `AfterError` ≡ `ResponseRecoveryStep`, taking a sealed `ResponseOutcome` (`Success(Response)` / `Failure(Throwable)`) instead of `Either`
-`ResponsePipeline` folds the outcome through the response steps (success path) and then through
+`ResponseRecoveryChain` folds the outcome through the response steps (success path) and then through
every recovery step, so recovery always observes the terminal outcome — including failures
thrown by a response step. This generalizes Airbyte's `Hooks.afterError(...)`: a recovery step
may rescue a failure into a success, replace the throwable, or pass through.
@@ -133,7 +133,7 @@ Two design choices stuck:
### Retry & Backoff
-Retry now ships as a pipeline step (`RetryStep` over `RetrySettings` + `BackoffCalculator` +
+Retry now ships as a pipeline step (`RetryRecovery` over `RetrySettings` + `BackoffCalculator` +
`RetryAfterParser`, plus the stage-based `DefaultRetryStep`). The table below records which
reference SDK each behavior was modeled on:
@@ -328,7 +328,7 @@ adapters where noted):
- **Retry pipeline step.** `Retry-After` + `X-RateLimit-Reset` parsing, backoff with jitter, idempotency-aware (HTTP method + replayable body), `ScheduledExecutorService`-based delay. [`pipeline/step/retry/`, `http/pipeline/steps/DefaultRetryStep.kt`]
- **Typed exception hierarchy.** `HttpException` base + status-code subclasses; `retryable` derived from `RetryUtils.isRetryable(status.code)`. [`http/response/exception/`]
-- **Recovery step.** Recovery-aware `ResponsePipeline` folding a sealed `ResponseOutcome` (`Success` / `Failure`); `ResponseRecoveryStep` is the `AfterError` analog. [`pipeline/`]
+- **Recovery step.** Recovery-aware `ResponseRecoveryChain` folding a sealed `ResponseOutcome` (`Success` / `Failure`); `ResponseRecoveryStep` is the `AfterError` analog. [`pipeline/`]
- **`HttpClient.close()` / lifecycle.** `AutoCloseable` on both SPIs and both transports; SDK-managed clients close, BYO clients don't. [`client/`]
- **Idempotency-key step.** Auto-injects `Idempotency-Key: UUID.randomUUID()` for `POST`/`PUT`/`PATCH`; caller-set header wins; pluggable key strategy. [`pipeline/step/IdempotencyKeyStep.kt`]
- **Auth.** `Credential` family + RFC 7235 challenge parsing + Basic/Digest/Composite `ChallengeHandler`s + `AuthStep` pillar. [`auth/`, `http/pipeline/steps/`]
diff --git a/sdk-async-coroutines/api/sdk-async-coroutines.api b/sdk-async-coroutines/api/sdk-async-coroutines.api
index 80130f49..5aa52a9c 100644
--- a/sdk-async-coroutines/api/sdk-async-coroutines.api
+++ b/sdk-async-coroutines/api/sdk-async-coroutines.api
@@ -3,6 +3,8 @@ public final class org/dexpace/sdk/async/coroutines/Coroutines {
public static final fun completableFutureOf (Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)Ljava/util/concurrent/CompletableFuture;
public static synthetic fun completableFutureOf$default (Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ljava/util/concurrent/CompletableFuture;
public static final fun execute (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public static final fun execute (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static final fun send (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;Lorg/dexpace/sdk/core/http/request/Request;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public static final fun send (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
diff --git a/sdk-async-coroutines/src/main/kotlin/org/dexpace/sdk/async/coroutines/Coroutines.kt b/sdk-async-coroutines/src/main/kotlin/org/dexpace/sdk/async/coroutines/Coroutines.kt
index 0fd3193f..9636e8ed 100644
--- a/sdk-async-coroutines/src/main/kotlin/org/dexpace/sdk/async/coroutines/Coroutines.kt
+++ b/sdk-async-coroutines/src/main/kotlin/org/dexpace/sdk/async/coroutines/Coroutines.kt
@@ -19,9 +19,11 @@ import org.dexpace.sdk.core.client.AsyncHttpClient
import org.dexpace.sdk.core.client.HttpClient
import org.dexpace.sdk.core.http.pipeline.AsyncHttpPipeline
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.instrumentation.ClientLogger
import java.util.concurrent.CompletableFuture
+import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
@@ -31,19 +33,41 @@ private val log = ClientLogger("org.dexpace.sdk.async.coroutines.Coroutines")
* Suspend-friendly facade over [AsyncHttpClient]. Returns the [Response] directly; the underlying
* `executeAsync(...)` future is awaited via `kotlinx-coroutines-jdk8`'s `CompletableFuture.await()`.
*
- * Cancellation: cancelling the enclosing coroutine cancels the awaited future
- * (`CompletableFuture.cancel(true)`). Whether the in-flight transport is interrupted is up to
- * the [AsyncHttpClient] implementation — see [AsyncHttpClient.executeAsync]'s cancellation
- * contract.
+ * Cancellation: cancelling the enclosing coroutine cancels the awaited future via
+ * `CompletableFuture.cancel(false)` — `kotlinx-coroutines-jdk8`'s `await()` cancels *without*
+ * requesting interruption. A transport whose cancellation hook only fires on `cancel(true)` (e.g.
+ * the executor-bridged blocking clients built on sdk-core's `interruptibleFuture`) therefore keeps
+ * running the in-flight call to completion; native async transports that abort on a plain
+ * `cancel(false)` release it eagerly. See [AsyncHttpClient.executeAsync]'s cancellation contract.
*/
public suspend fun AsyncHttpClient.execute(request: Request): Response = executeAsync(request).await()
+/**
+ * Suspend-friendly facade over [AsyncHttpClient] with per-call [options]. Threads [options] into
+ * `executeAsync(request, options)` so per-request overrides (timeout, retry budget, tags) survive
+ * the coroutine bridge instead of being dropped. Otherwise identical to the no-options [execute].
+ */
+public suspend fun AsyncHttpClient.execute(
+ request: Request,
+ options: RequestOptions,
+): Response = executeAsync(request, options).await()
+
/**
* Suspend-friendly facade over [AsyncHttpPipeline.sendAsync]. Mirrors [execute] above but for
* the pipeline entry point.
*/
public suspend fun AsyncHttpPipeline.send(request: Request): Response = sendAsync(request).await()
+/**
+ * Suspend-friendly facade over [AsyncHttpPipeline.sendAsync] with per-call [options]. Threads
+ * [options] into `sendAsync(request, options)` so per-request overrides survive the coroutine
+ * bridge. Otherwise identical to the no-options [send].
+ */
+public suspend fun AsyncHttpPipeline.send(
+ request: Request,
+ options: RequestOptions,
+): Response = sendAsync(request, options).await()
+
/**
* Builds a [CompletableFuture] from a suspending block inside [scope]. Thin re-export of
* `kotlinx-coroutines-jdk8`'s `scope.future { ... }` — kept here so callers depending on
@@ -85,7 +109,64 @@ public fun HttpClient.asAsyncCoroutines(scope: CoroutineScope): AsyncHttpClient
.field("adapter.type", "coroutines")
.field("scope.coroutineContext", scope.coroutineContext.toString())
.log()
- return AsyncHttpClient { request ->
- scope.future(MDCContext()) { runInterruptible(Dispatchers.IO) { execute(request) } }
+ val client = this
+ return object : AsyncHttpClient {
+ override fun executeAsync(request: Request): CompletableFuture =
+ executeAsync(request, RequestOptions.EMPTY)
+
+ override fun executeAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture {
+ // `scope.future { }` propagates cancellation of the returned CompletableFuture into the
+ // coroutine (cancel() → Job cancel → runInterruptible interrupts the blocking call,
+ // regardless of the mayInterruptIfRunning flag), but kotlinx DISCARDS the coroutine's
+ // result once the Job is already cancelled. A
+ // Response computed in the cancellation race window would therefore be dropped without
+ // being closed, leaking its connection — unlike sdk-core's `interruptibleFuture`, which
+ // closes a lost value. Guard with a lock-free handshake so whoever loses the race — the
+ // coroutine producing a value the future will not deliver, or the future terminating
+ // before that value is produced — closes the Response exactly once.
+ val handoff = AtomicReference(null)
+ val future =
+ scope.future(MDCContext()) {
+ val response = runInterruptible(Dispatchers.IO) { client.execute(request, options) }
+ // Register the response. If the future already terminated (cancelled / failed)
+ // before we got here, its value is discarded, so close the orphan ourselves.
+ if (handoff.getAndSet(response) === DISCARDED) {
+ closeQuietly(response)
+ }
+ response
+ }
+ future.whenComplete { result, _ ->
+ // A null result means the future did NOT hand a Response to the caller (cancelled or
+ // exceptional). If the coroutine already registered one, it is orphaned — close it;
+ // otherwise publish DISCARDED so the coroutine closes the Response it is about to
+ // produce. `result != null` is the normal-delivery path: the caller owns the value.
+ if (result == null) {
+ val prev = handoff.getAndSet(DISCARDED)
+ if (prev is Response) closeQuietly(prev)
+ }
+ }
+ return future
+ }
+ }
+}
+
+/**
+ * Sentinel published to the [asAsyncCoroutines] handshake when the returned future terminates
+ * without delivering a value, signalling the coroutine to close any Response it produces.
+ */
+private val DISCARDED: Any = Any()
+
+/**
+ * Best-effort close on a discard path — the Response lost the completion race, so a failure to
+ * close it has nothing actionable to surface.
+ */
+private fun closeQuietly(response: Response) {
+ try {
+ response.close()
+ } catch (ignored: Throwable) {
+ // Intentionally ignored: the response is already being discarded.
}
}
diff --git a/sdk-async-coroutines/src/test/kotlin/org/dexpace/sdk/async/coroutines/CoroutinesTest.kt b/sdk-async-coroutines/src/test/kotlin/org/dexpace/sdk/async/coroutines/CoroutinesTest.kt
index 4f1fbf52..ee066200 100644
--- a/sdk-async-coroutines/src/test/kotlin/org/dexpace/sdk/async/coroutines/CoroutinesTest.kt
+++ b/sdk-async-coroutines/src/test/kotlin/org/dexpace/sdk/async/coroutines/CoroutinesTest.kt
@@ -24,6 +24,7 @@ import org.dexpace.sdk.core.http.common.Protocol
import org.dexpace.sdk.core.http.pipeline.AsyncHttpPipelineBuilder
import org.dexpace.sdk.core.http.request.Method
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.http.response.Status
import org.slf4j.MDC
@@ -69,6 +70,27 @@ class CoroutinesTest {
assertEquals(204, response.status.code)
}
+ @Test
+ fun `asAsyncCoroutines forwards per-call options to the sync client`() =
+ runBlocking {
+ val seen = AtomicReference()
+ val syncClient =
+ object : HttpClient {
+ override fun execute(request: Request): Response = execute(request, RequestOptions.EMPTY)
+
+ override fun execute(
+ request: Request,
+ options: RequestOptions,
+ ): Response {
+ seen.set(options)
+ return mockResponse(request, 200)
+ }
+ }
+ val options = RequestOptions.builder().maxRetries(0).tag("k", "v").build()
+ syncClient.asAsyncCoroutines(this).executeAsync(getRequest(), options).await()
+ assertEquals(options, seen.get(), "per-call options must reach the wrapped sync client, not be dropped")
+ }
+
@Test
fun `suspend execute surfaces the unwrapped exception`() =
runBlocking {
diff --git a/sdk-async-netty/api/sdk-async-netty.api b/sdk-async-netty/api/sdk-async-netty.api
index 41ba9409..41743d9e 100644
--- a/sdk-async-netty/api/sdk-async-netty.api
+++ b/sdk-async-netty/api/sdk-async-netty.api
@@ -1,5 +1,7 @@
public final class org/dexpace/sdk/async/netty/NettyAdapters {
public static final fun executeNetty (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lio/netty/util/concurrent/EventExecutor;)Lio/netty/util/concurrent/Future;
+ public static final fun executeNetty (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;Lio/netty/util/concurrent/EventExecutor;)Lio/netty/util/concurrent/Future;
public static final fun sendNetty (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;Lorg/dexpace/sdk/core/http/request/Request;Lio/netty/util/concurrent/EventExecutor;)Lio/netty/util/concurrent/Future;
+ public static final fun sendNetty (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;Lio/netty/util/concurrent/EventExecutor;)Lio/netty/util/concurrent/Future;
}
diff --git a/sdk-async-netty/src/main/kotlin/org/dexpace/sdk/async/netty/Netty.kt b/sdk-async-netty/src/main/kotlin/org/dexpace/sdk/async/netty/Netty.kt
index e820f4cb..fdff0cdb 100644
--- a/sdk-async-netty/src/main/kotlin/org/dexpace/sdk/async/netty/Netty.kt
+++ b/sdk-async-netty/src/main/kotlin/org/dexpace/sdk/async/netty/Netty.kt
@@ -14,6 +14,7 @@ import io.netty.util.concurrent.Future
import org.dexpace.sdk.core.client.AsyncHttpClient
import org.dexpace.sdk.core.http.pipeline.AsyncHttpPipeline
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.instrumentation.ClientLogger
import org.dexpace.sdk.core.instrumentation.MdcSnapshot
@@ -48,12 +49,34 @@ public fun AsyncHttpClient.executeNetty(
executor: EventExecutor,
): Future = executeAsync(request).bridgeToNetty(executor)
+/**
+ * Per-call-options Netty future facade — see [executeNetty]. Threads [options] into
+ * `executeAsync(request, options)` so per-request overrides (timeout, retry budget, tags) are not
+ * dropped at the Netty boundary. Same cancellation and MDC-propagation semantics as the no-options
+ * [executeNetty].
+ */
+public fun AsyncHttpClient.executeNetty(
+ request: Request,
+ options: RequestOptions,
+ executor: EventExecutor,
+): Future = executeAsync(request, options).bridgeToNetty(executor)
+
/** Pipeline-level Netty future facade — see [executeNetty]. */
public fun AsyncHttpPipeline.sendNetty(
request: Request,
executor: EventExecutor,
): Future = sendAsync(request).bridgeToNetty(executor)
+/**
+ * Per-call-options pipeline Netty future facade — see [sendNetty]. Threads [options] into
+ * `sendAsync(request, options)` so per-request overrides survive the Netty boundary.
+ */
+public fun AsyncHttpPipeline.sendNetty(
+ request: Request,
+ options: RequestOptions,
+ executor: EventExecutor,
+): Future = sendAsync(request, options).bridgeToNetty(executor)
+
/**
* Shared bridge: mirror this [CompletableFuture]'s completion onto a Netty [Promise] created
* by [executor]. The promise's cancellation propagates back to this source future.
diff --git a/sdk-async-reactor/api/sdk-async-reactor.api b/sdk-async-reactor/api/sdk-async-reactor.api
index 274f2452..ae818874 100644
--- a/sdk-async-reactor/api/sdk-async-reactor.api
+++ b/sdk-async-reactor/api/sdk-async-reactor.api
@@ -1,7 +1,9 @@
public final class org/dexpace/sdk/async/reactor/ReactorAdapters {
public static final fun executeMono (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;)Lreactor/core/publisher/Mono;
+ public static final fun executeMono (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Lreactor/core/publisher/Mono;
public static final fun readServerSentEventsAsFlux (Lorg/dexpace/sdk/core/io/BufferedSource;)Lreactor/core/publisher/Flux;
public static final fun sendMono (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;Lorg/dexpace/sdk/core/http/request/Request;)Lreactor/core/publisher/Mono;
+ public static final fun sendMono (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Lreactor/core/publisher/Mono;
public static final fun toFlux (Lorg/dexpace/sdk/core/http/sse/ServerSentEventReader;)Lreactor/core/publisher/Flux;
}
diff --git a/sdk-async-reactor/src/main/kotlin/org/dexpace/sdk/async/reactor/Reactor.kt b/sdk-async-reactor/src/main/kotlin/org/dexpace/sdk/async/reactor/Reactor.kt
index fe9db629..9bd772cd 100644
--- a/sdk-async-reactor/src/main/kotlin/org/dexpace/sdk/async/reactor/Reactor.kt
+++ b/sdk-async-reactor/src/main/kotlin/org/dexpace/sdk/async/reactor/Reactor.kt
@@ -12,6 +12,7 @@ package org.dexpace.sdk.async.reactor
import org.dexpace.sdk.core.client.AsyncHttpClient
import org.dexpace.sdk.core.http.pipeline.AsyncHttpPipeline
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.http.sse.ServerSentEvent
import org.dexpace.sdk.core.http.sse.ServerSentEventReader
@@ -46,6 +47,17 @@ private val log = ClientLogger("org.dexpace.sdk.async.reactor.Reactor")
*/
public fun AsyncHttpClient.executeMono(request: Request): Mono = deferMono { executeAsync(request) }
+/**
+ * Per-call-options [Mono] facade — see [executeMono]. Threads [options] into
+ * `executeAsync(request, options)` so per-request overrides (timeout, retry budget, tags) are not
+ * dropped at the Reactor boundary. Same cold-publisher and per-subscription MDC semantics as the
+ * no-options [executeMono].
+ */
+public fun AsyncHttpClient.executeMono(
+ request: Request,
+ options: RequestOptions,
+): Mono = deferMono { executeAsync(request, options) }
+
/**
* Pipeline-level [Mono] facade — see [executeMono].
*
@@ -62,6 +74,15 @@ public fun AsyncHttpClient.executeMono(request: Request): Mono = defer
*/
public fun AsyncHttpPipeline.sendMono(request: Request): Mono = deferMono { sendAsync(request) }
+/**
+ * Per-call-options pipeline [Mono] facade — see [sendMono]. Threads [options] into
+ * `sendAsync(request, options)` so per-request overrides survive the Reactor boundary.
+ */
+public fun AsyncHttpPipeline.sendMono(
+ request: Request,
+ options: RequestOptions,
+): Mono = deferMono { sendAsync(request, options) }
+
/**
* Bridges a [CompletableFuture]-returning [supplier] to a cold [Mono]. Each subscription runs
* the supplier afresh under [Mono.defer], captures the subscriber's MDC, and reinstates it for
diff --git a/sdk-async-virtualthreads/api/sdk-async-virtualthreads.api b/sdk-async-virtualthreads/api/sdk-async-virtualthreads.api
index aab3c178..0c7bb463 100644
--- a/sdk-async-virtualthreads/api/sdk-async-virtualthreads.api
+++ b/sdk-async-virtualthreads/api/sdk-async-virtualthreads.api
@@ -5,5 +5,6 @@ public final class org/dexpace/sdk/async/virtualthreads/VirtualThreadAdapters {
public final class org/dexpace/sdk/async/virtualthreads/VirtualThreadAsyncHttpClient : java/lang/AutoCloseable, org/dexpace/sdk/core/client/AsyncHttpClient {
public fun close ()V
public fun executeAsync (Lorg/dexpace/sdk/core/http/request/Request;)Ljava/util/concurrent/CompletableFuture;
+ public fun executeAsync (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Ljava/util/concurrent/CompletableFuture;
}
diff --git a/sdk-async-virtualthreads/src/main/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreads.kt b/sdk-async-virtualthreads/src/main/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreads.kt
index 6a1fc6ae..89139204 100644
--- a/sdk-async-virtualthreads/src/main/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreads.kt
+++ b/sdk-async-virtualthreads/src/main/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreads.kt
@@ -13,6 +13,7 @@ import org.dexpace.sdk.core.client.AsyncHttpClient
import org.dexpace.sdk.core.client.HttpClient
import org.dexpace.sdk.core.client.asAsync
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.instrumentation.ClientLogger
import java.util.concurrent.CompletableFuture
@@ -61,8 +62,13 @@ public fun HttpClient.asAsyncVirtualThreads(): VirtualThreadAsyncHttpClient {
* client contract (so callers can pass it to a pipeline) and [AutoCloseable] so the executor
* is released on shutdown.
*
- * Closing the executor does NOT cancel in-flight requests — virtual threads are
- * non-interruptible by default; the executor's `close()` waits for tasks to finish.
+ * Closing the executor does NOT cancel in-flight requests: `ExecutorService.close()` performs a
+ * graceful shutdown — `shutdown()` followed by `awaitTermination` — so it stops accepting new tasks
+ * and then *waits* for the running ones to finish rather than interrupting them (it only escalates
+ * to `shutdownNow()` if the thread calling `close()` is itself interrupted). Virtual threads honour
+ * `Thread.interrupt()` exactly like platform threads; the in-flight tasks are simply never
+ * interrupted here because this path does not call `shutdownNow()`. To abort a blocking call via
+ * interruption, wrap the client in coroutines and use `runInterruptible` instead.
*
* [close] is idempotent: only the first call shuts the executor down and emits the
* `executor.closed` lifecycle event; subsequent calls are a true no-op (no log, no second
@@ -76,6 +82,11 @@ public class VirtualThreadAsyncHttpClient internal constructor(
override fun executeAsync(request: Request): CompletableFuture = delegate.executeAsync(request)
+ override fun executeAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture = delegate.executeAsync(request, options)
+
/**
* Shuts down the virtual-thread executor and waits for in-flight tasks to complete.
* Idempotent: a second (or later) call returns immediately without touching the executor
diff --git a/sdk-async-virtualthreads/src/test/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreadsTest.kt b/sdk-async-virtualthreads/src/test/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreadsTest.kt
index 4e52af9f..06beb9dc 100644
--- a/sdk-async-virtualthreads/src/test/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreadsTest.kt
+++ b/sdk-async-virtualthreads/src/test/kotlin/org/dexpace/sdk/async/virtualthreads/VirtualThreadsTest.kt
@@ -12,6 +12,7 @@ import org.dexpace.sdk.core.client.HttpClient
import org.dexpace.sdk.core.http.common.Protocol
import org.dexpace.sdk.core.http.request.Method
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.http.response.Status
import org.slf4j.MDC
@@ -71,6 +72,29 @@ class VirtualThreadsTest {
assertTrue(thrown != null, "expected closed virtual-thread executor to reject new tasks")
}
+ @Test
+ fun `executeAsync forwards per-call options to the delegate`() {
+ val seen = AtomicReference()
+ val delegate =
+ object : AsyncHttpClient {
+ override fun executeAsync(request: Request): CompletableFuture =
+ executeAsync(request, RequestOptions.EMPTY)
+
+ override fun executeAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture {
+ seen.set(options)
+ return CompletableFuture.completedFuture(mockResponse(request, 200))
+ }
+ }
+ VirtualThreadAsyncHttpClient(delegate, Executors.newVirtualThreadPerTaskExecutor()).use { vt ->
+ val options = RequestOptions.builder().maxRetries(0).tag("k", "v").build()
+ vt.executeAsync(getRequest(), options).get(FAILSAFE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ assertEquals(options, seen.get(), "per-call options must reach the wrapped delegate, not be dropped")
+ }
+ }
+
@Test
fun `close is idempotent — a second close does not touch the executor again`() {
val closeCount = AtomicInteger(0)
diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api
index 49f62f44..b69a9d87 100644
--- a/sdk-core/api/sdk-core.api
+++ b/sdk-core/api/sdk-core.api
@@ -255,10 +255,12 @@ public final class org/dexpace/sdk/core/auth/NamedKeyCredential : org/dexpace/sd
public abstract interface class org/dexpace/sdk/core/client/AsyncHttpClient : java/lang/AutoCloseable {
public fun close ()V
public abstract fun executeAsync (Lorg/dexpace/sdk/core/http/request/Request;)Ljava/util/concurrent/CompletableFuture;
+ public fun executeAsync (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Ljava/util/concurrent/CompletableFuture;
}
public final class org/dexpace/sdk/core/client/AsyncHttpClient$DefaultImpls {
public static fun close (Lorg/dexpace/sdk/core/client/AsyncHttpClient;)V
+ public static fun executeAsync (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Ljava/util/concurrent/CompletableFuture;
}
public final class org/dexpace/sdk/core/client/AsyncHttpClients {
@@ -269,10 +271,12 @@ public final class org/dexpace/sdk/core/client/AsyncHttpClients {
public abstract interface class org/dexpace/sdk/core/client/HttpClient : java/lang/AutoCloseable {
public fun close ()V
public abstract fun execute (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/response/Response;
+ public fun execute (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Lorg/dexpace/sdk/core/http/response/Response;
}
public final class org/dexpace/sdk/core/client/HttpClient$DefaultImpls {
public static fun close (Lorg/dexpace/sdk/core/client/HttpClient;)V
+ public static fun execute (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Lorg/dexpace/sdk/core/http/response/Response;
}
public final class org/dexpace/sdk/core/config/Configuration {
@@ -352,32 +356,26 @@ public final class org/dexpace/sdk/core/http/common/CommonMediaTypes {
}
public final class org/dexpace/sdk/core/http/common/ETag {
+ public static final field ANY Lorg/dexpace/sdk/core/http/common/ETag;
public static final field Companion Lorg/dexpace/sdk/core/http/common/ETag$Companion;
- public static final synthetic fun box-impl (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/ETag;
+ public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun equals (Ljava/lang/Object;)Z
- public static fun equals-impl (Ljava/lang/String;Ljava/lang/Object;)Z
- public static final fun equals-impl0 (Ljava/lang/String;Ljava/lang/String;)Z
- public static final fun getANY-Ibeg9ak ()Ljava/lang/String;
- public static final fun getOpaque-impl (Ljava/lang/String;)Ljava/lang/String;
+ public final fun getOpaque ()Ljava/lang/String;
public fun hashCode ()I
- public static fun hashCode-impl (Ljava/lang/String;)I
- public static final fun isAny-impl (Ljava/lang/String;)Z
- public static final fun isStrong-impl (Ljava/lang/String;)Z
- public static final fun isWeak-impl (Ljava/lang/String;)Z
- public static final fun parse-h2-RfS0 (Ljava/lang/String;)Ljava/lang/String;
- public static final fun strong-ag3KNXs (Ljava/lang/String;)Ljava/lang/String;
- public static final fun toHeaderValue-impl (Ljava/lang/String;)Ljava/lang/String;
+ public final fun isAny ()Z
+ public final fun isStrong ()Z
+ public final fun isWeak ()Z
+ public static final fun parse (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/ETag;
+ public static final fun strong (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/ETag;
+ public final fun toHeaderValue ()Ljava/lang/String;
public fun toString ()Ljava/lang/String;
- public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String;
- public final synthetic fun unbox-impl ()Ljava/lang/String;
- public static final fun weak-ag3KNXs (Ljava/lang/String;)Ljava/lang/String;
+ public static final fun weak (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/ETag;
}
public final class org/dexpace/sdk/core/http/common/ETag$Companion {
- public final fun getANY-Ibeg9ak ()Ljava/lang/String;
- public final fun parse-h2-RfS0 (Ljava/lang/String;)Ljava/lang/String;
- public final fun strong-ag3KNXs (Ljava/lang/String;)Ljava/lang/String;
- public final fun weak-ag3KNXs (Ljava/lang/String;)Ljava/lang/String;
+ public final fun parse (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/ETag;
+ public final fun strong (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/ETag;
+ public final fun weak (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/ETag;
}
public final class org/dexpace/sdk/core/http/common/Headers {
@@ -513,31 +511,32 @@ public final class org/dexpace/sdk/core/http/common/HttpHeaderName$Companion {
public final class org/dexpace/sdk/core/http/common/HttpRange {
public static final field Companion Lorg/dexpace/sdk/core/http/common/HttpRange$Companion;
- public static final synthetic fun box-impl (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/HttpRange;
- public static final fun bytes-Oepi37k (JJ)Ljava/lang/String;
+ public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public static final fun bytes (JJ)Lorg/dexpace/sdk/core/http/common/HttpRange;
public fun equals (Ljava/lang/Object;)Z
- public static fun equals-impl (Ljava/lang/String;Ljava/lang/Object;)Z
- public static final fun equals-impl0 (Ljava/lang/String;Ljava/lang/String;)Z
- public static final fun from-1jReFK8 (J)Ljava/lang/String;
+ public static final fun from (J)Lorg/dexpace/sdk/core/http/common/HttpRange;
public fun hashCode ()I
- public static fun hashCode-impl (Ljava/lang/String;)I
- public static final fun parse-1jReFK8 (Ljava/lang/String;)Ljava/lang/String;
- public static final fun suffix-1jReFK8 (J)Ljava/lang/String;
- public static final fun toHeaderValue-impl (Ljava/lang/String;)Ljava/lang/String;
+ public static final fun parse (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/HttpRange;
+ public static final fun suffix (J)Lorg/dexpace/sdk/core/http/common/HttpRange;
+ public final fun toHeaderValue ()Ljava/lang/String;
public fun toString ()Ljava/lang/String;
- public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String;
- public final synthetic fun unbox-impl ()Ljava/lang/String;
}
public final class org/dexpace/sdk/core/http/common/HttpRange$Companion {
- public final fun bytes-Oepi37k (JJ)Ljava/lang/String;
- public final fun from-1jReFK8 (J)Ljava/lang/String;
- public final fun parse-1jReFK8 (Ljava/lang/String;)Ljava/lang/String;
- public final fun suffix-1jReFK8 (J)Ljava/lang/String;
+ public final fun bytes (JJ)Lorg/dexpace/sdk/core/http/common/HttpRange;
+ public final fun from (J)Lorg/dexpace/sdk/core/http/common/HttpRange;
+ public final fun parse (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/HttpRange;
+ public final fun suffix (J)Lorg/dexpace/sdk/core/http/common/HttpRange;
}
public final class org/dexpace/sdk/core/http/common/MediaType {
+ public static final field APPLICATION_FORM_URLENCODED Lorg/dexpace/sdk/core/http/common/MediaType;
+ public static final field APPLICATION_JSON Lorg/dexpace/sdk/core/http/common/MediaType;
+ public static final field APPLICATION_OCTET_STREAM Lorg/dexpace/sdk/core/http/common/MediaType;
+ public static final field APPLICATION_XML Lorg/dexpace/sdk/core/http/common/MediaType;
public static final field Companion Lorg/dexpace/sdk/core/http/common/MediaType$Companion;
+ public static final field TEXT_HTML Lorg/dexpace/sdk/core/http/common/MediaType;
+ public static final field TEXT_PLAIN Lorg/dexpace/sdk/core/http/common/MediaType;
public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
@@ -635,9 +634,11 @@ public final class org/dexpace/sdk/core/http/common/RequestConditions$Builder {
public fun ()V
public fun (Lorg/dexpace/sdk/core/http/common/RequestConditions;)V
public final fun build ()Lorg/dexpace/sdk/core/http/common/RequestConditions;
- public final fun ifMatch-a6HYibk (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
+ public final fun ifMatch (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
+ public final fun ifMatch (Lorg/dexpace/sdk/core/http/common/ETag;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
public final fun ifModifiedSince (Ljava/time/Instant;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
- public final fun ifNoneMatch-a6HYibk (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
+ public final fun ifNoneMatch (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
+ public final fun ifNoneMatch (Lorg/dexpace/sdk/core/http/common/ETag;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
public final fun ifUnmodifiedSince (Ljava/time/Instant;)Lorg/dexpace/sdk/core/http/common/RequestConditions$Builder;
}
@@ -728,23 +729,33 @@ public final class org/dexpace/sdk/core/http/context/RequestContext : org/dexpac
public fun toString ()Ljava/lang/String;
}
-public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline {
+public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline : org/dexpace/sdk/core/client/AsyncHttpClient {
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline$Companion;
+ public fun close ()V
+ public fun executeAsync (Lorg/dexpace/sdk/core/http/request/Request;)Ljava/util/concurrent/CompletableFuture;
+ public fun executeAsync (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Ljava/util/concurrent/CompletableFuture;
public final fun getHttpClient ()Lorg/dexpace/sdk/core/client/AsyncHttpClient;
public final fun getSteps ()Ljava/util/List;
public static final fun of (Lorg/dexpace/sdk/core/client/AsyncHttpClient;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;
public final fun sendAsync (Lorg/dexpace/sdk/core/http/request/Request;)Ljava/util/concurrent/CompletableFuture;
+ public final fun sendAsync (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Ljava/util/concurrent/CompletableFuture;
+ public final fun sendAsync (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;Lorg/dexpace/sdk/core/http/response/ResponseHandler;)Ljava/util/concurrent/CompletableFuture;
+ public final fun sendAsync (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/ResponseHandler;)Ljava/util/concurrent/CompletableFuture;
+ public static final fun standard (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Ljava/util/concurrent/ScheduledExecutorService;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;
}
public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline$Companion {
public final fun of (Lorg/dexpace/sdk/core/client/AsyncHttpClient;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;
+ public final fun standard (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Ljava/util/concurrent/ScheduledExecutorService;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;
}
public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder {
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder$Companion;
public fun (Lorg/dexpace/sdk/core/client/AsyncHttpClient;)V
+ public fun (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;)V
public final fun append (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpStep;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
public final fun appendAll (Ljava/lang/Iterable;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
+ public final fun appendStandardResilience (Ljava/util/concurrent/ScheduledExecutorService;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
public final fun build ()Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;
public static final fun from (Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
public final fun insertAfter (Ljava/lang/Class;Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpStep;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
@@ -753,6 +764,7 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder {
public final fun prependAll (Ljava/lang/Iterable;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
public final fun remove (Ljava/lang/Class;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
public final fun replace (Ljava/lang/Class;Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpStep;)Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
+ public final fun throwOnHttpError ()Lorg/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder;
}
public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder$Companion {
@@ -766,20 +778,31 @@ public abstract interface class org/dexpace/sdk/core/http/pipeline/AsyncHttpStep
public final class org/dexpace/sdk/core/http/pipeline/AsyncPipelineNext {
public final fun copy ()Lorg/dexpace/sdk/core/http/pipeline/AsyncPipelineNext;
+ public final fun getOptions ()Lorg/dexpace/sdk/core/http/request/RequestOptions;
public final fun processAsync ()Ljava/util/concurrent/CompletableFuture;
public final fun processAsync (Lorg/dexpace/sdk/core/http/request/Request;)Ljava/util/concurrent/CompletableFuture;
}
-public final class org/dexpace/sdk/core/http/pipeline/HttpPipeline {
+public final class org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlers {
+ public static final fun handleWith (Ljava/util/concurrent/CompletableFuture;Lorg/dexpace/sdk/core/http/response/ResponseHandler;)Ljava/util/concurrent/CompletableFuture;
+}
+
+public final class org/dexpace/sdk/core/http/pipeline/HttpPipeline : org/dexpace/sdk/core/client/HttpClient {
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline$Companion;
+ public fun close ()V
+ public fun execute (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/response/Response;
+ public fun execute (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Lorg/dexpace/sdk/core/http/response/Response;
public final fun getHttpClient ()Lorg/dexpace/sdk/core/client/HttpClient;
public final fun getSteps ()Ljava/util/List;
public static final fun of (Lorg/dexpace/sdk/core/client/HttpClient;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline;
public final fun send (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/response/Response;
+ public final fun send (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/request/RequestOptions;)Lorg/dexpace/sdk/core/http/response/Response;
+ public static final fun standard (Lorg/dexpace/sdk/core/client/HttpClient;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline;
}
public final class org/dexpace/sdk/core/http/pipeline/HttpPipeline$Companion {
public final fun of (Lorg/dexpace/sdk/core/client/HttpClient;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline;
+ public final fun standard (Lorg/dexpace/sdk/core/client/HttpClient;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline;
}
public final class org/dexpace/sdk/core/http/pipeline/HttpPipelineBridges {
@@ -790,8 +813,10 @@ public final class org/dexpace/sdk/core/http/pipeline/HttpPipelineBridges {
public final class org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder {
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder$Companion;
public fun (Lorg/dexpace/sdk/core/client/HttpClient;)V
+ public fun (Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline;)V
public final fun append (Lorg/dexpace/sdk/core/http/pipeline/HttpStep;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
public final fun appendAll (Ljava/lang/Iterable;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
+ public final fun appendStandardResilience ()Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
public final fun build ()Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline;
public static final fun from (Lorg/dexpace/sdk/core/http/pipeline/HttpPipeline;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
public final fun insertAfter (Ljava/lang/Class;Lorg/dexpace/sdk/core/http/pipeline/HttpStep;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
@@ -800,6 +825,7 @@ public final class org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder {
public final fun prependAll (Ljava/lang/Iterable;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
public final fun remove (Ljava/lang/Class;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
public final fun replace (Ljava/lang/Class;Lorg/dexpace/sdk/core/http/pipeline/HttpStep;)Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
+ public final fun throwOnHttpError ()Lorg/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder;
}
public final class org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder$Companion {
@@ -813,6 +839,7 @@ public abstract interface class org/dexpace/sdk/core/http/pipeline/HttpStep {
public final class org/dexpace/sdk/core/http/pipeline/PipelineNext {
public final fun copy ()Lorg/dexpace/sdk/core/http/pipeline/PipelineNext;
+ public final fun getOptions ()Lorg/dexpace/sdk/core/http/request/RequestOptions;
public final fun process ()Lorg/dexpace/sdk/core/http/response/Response;
public final fun process (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/response/Response;
}
@@ -827,6 +854,7 @@ public final class org/dexpace/sdk/core/http/pipeline/Stage : java/lang/Enum {
public static final field POST_SERDE Lorg/dexpace/sdk/core/http/pipeline/Stage;
public static final field PRE_AUTH Lorg/dexpace/sdk/core/http/pipeline/Stage;
public static final field PRE_LOGGING Lorg/dexpace/sdk/core/http/pipeline/Stage;
+ public static final field PRE_REDIRECT Lorg/dexpace/sdk/core/http/pipeline/Stage;
public static final field PRE_SEND Lorg/dexpace/sdk/core/http/pipeline/Stage;
public static final field PRE_SERDE Lorg/dexpace/sdk/core/http/pipeline/Stage;
public static final field REDIRECT Lorg/dexpace/sdk/core/http/pipeline/Stage;
@@ -864,6 +892,12 @@ public abstract class org/dexpace/sdk/core/http/pipeline/steps/AsyncRetryStep :
public final fun getStage ()Lorg/dexpace/sdk/core/http/pipeline/Stage;
}
+public final class org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStep : org/dexpace/sdk/core/http/pipeline/AsyncHttpStep {
+ public fun ()V
+ public fun getStage ()Lorg/dexpace/sdk/core/http/pipeline/Stage;
+ public fun processAsync (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/pipeline/AsyncPipelineNext;)Ljava/util/concurrent/CompletableFuture;
+}
+
public abstract class org/dexpace/sdk/core/http/pipeline/steps/AuthStep : org/dexpace/sdk/core/http/pipeline/HttpStep {
public fun ()V
protected abstract fun authorizeRequest (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/request/Request;
@@ -955,6 +989,7 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationO
public fun (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;Ljava/util/Set;Ljava/util/Set;ZLorg/dexpace/sdk/core/instrumentation/Tracer;Lorg/dexpace/sdk/core/instrumentation/metrics/Meter;)V
public fun (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;Ljava/util/Set;Ljava/util/Set;ZLorg/dexpace/sdk/core/instrumentation/Tracer;Lorg/dexpace/sdk/core/instrumentation/metrics/Meter;I)V
public synthetic fun (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;Ljava/util/Set;Ljava/util/Set;ZLorg/dexpace/sdk/core/instrumentation/Tracer;Lorg/dexpace/sdk/core/instrumentation/metrics/Meter;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public static final fun builder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
public final fun getAllowedHeaderNames ()Ljava/util/Set;
public final fun getAllowedQueryParamNames ()Ljava/util/Set;
public final fun getBodyPreviewMaxBytes ()I
@@ -962,9 +997,24 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationO
public final fun getMeter ()Lorg/dexpace/sdk/core/instrumentation/metrics/Meter;
public final fun getTracer ()Lorg/dexpace/sdk/core/instrumentation/Tracer;
public final fun isRedactedHeaderNamesLoggingEnabled ()Z
+ public final fun newBuilder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
+}
+
+public final class org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder : org/dexpace/sdk/core/generics/Builder {
+ public fun ()V
+ public final fun allowedHeaderNames (Ljava/util/Set;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
+ public final fun allowedQueryParamNames (Ljava/util/Set;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
+ public final fun bodyPreviewMaxBytes (I)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
+ public synthetic fun build ()Ljava/lang/Object;
+ public fun build ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions;
+ public final fun isRedactedHeaderNamesLoggingEnabled (Z)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
+ public final fun logLevel (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
+ public final fun meter (Lorg/dexpace/sdk/core/instrumentation/metrics/Meter;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
+ public final fun tracer (Lorg/dexpace/sdk/core/instrumentation/Tracer;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
}
public final class org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Companion {
+ public final fun builder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions$Builder;
}
public final class org/dexpace/sdk/core/http/pipeline/steps/HttpLogLevel : java/lang/Enum {
@@ -1001,20 +1051,39 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectConditio
}
public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions {
+ public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Companion;
public fun ()V
public fun (I)V
- public fun (ILjava/util/EnumSet;)V
- public fun (ILjava/util/EnumSet;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)V
- public fun (ILjava/util/EnumSet;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Z)V
- public fun (ILjava/util/EnumSet;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;ZZ)V
- public fun (ILjava/util/EnumSet;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;ZZLorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectPredicate;)V
- public synthetic fun (ILjava/util/EnumSet;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;ZZLorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectPredicate;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (ILjava/util/Set;)V
+ public fun (ILjava/util/Set;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)V
+ public fun (ILjava/util/Set;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Z)V
+ public fun (ILjava/util/Set;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;ZZ)V
+ public fun (ILjava/util/Set;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;ZZLorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectPredicate;)V
+ public synthetic fun (ILjava/util/Set;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;ZZLorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectPredicate;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public static final fun builder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
public final fun getAllowSchemeDowngrade ()Z
public final fun getAllowedMethods ()Ljava/util/Set;
public final fun getFollow303 ()Z
public final fun getLocationHeader ()Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
public final fun getMaxHops ()I
public final fun getShouldRedirect ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectPredicate;
+ public final fun newBuilder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
+}
+
+public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder : org/dexpace/sdk/core/generics/Builder {
+ public fun ()V
+ public final fun allowSchemeDowngrade (Z)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
+ public final fun allowedMethods (Ljava/util/Set;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
+ public synthetic fun build ()Ljava/lang/Object;
+ public fun build ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions;
+ public final fun follow303 (Z)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
+ public final fun locationHeader (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
+ public final fun maxHops (I)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
+ public final fun shouldRedirect (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectPredicate;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
+}
+
+public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Companion {
+ public final fun builder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions$Builder;
}
public abstract interface class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectPredicate {
@@ -1059,7 +1128,9 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions {
public fun (ILjava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;Ljava/util/List;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;)V
public fun (ILjava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;Ljava/util/List;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryDelayProvider;)V
public synthetic fun (ILjava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;Ljava/util/List;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryDelayProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public static final fun builder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
public static final fun fixed (ILjava/time/Duration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions;
+ public static final fun fromConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions;
public final fun getBaseDelay ()Ljava/time/Duration;
public final fun getDelayFromCondition ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryDelayProvider;
public final fun getFixedDelay ()Ljava/time/Duration;
@@ -1068,10 +1139,27 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions {
public final fun getRetryAfterHeaders ()Ljava/util/List;
public final fun getShouldRetryCondition ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;
public final fun getShouldRetryException ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;
+ public final fun newBuilder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+}
+
+public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder : org/dexpace/sdk/core/generics/Builder {
+ public fun ()V
+ public final fun baseDelay (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+ public synthetic fun build ()Ljava/lang/Object;
+ public fun build ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions;
+ public final fun delayFromCondition (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryDelayProvider;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+ public final fun fixedDelay (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+ public final fun maxDelay (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+ public final fun maxRetries (I)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+ public final fun retryAfterHeaders (Ljava/util/List;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+ public final fun shouldRetryCondition (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
+ public final fun shouldRetryException (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
}
public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Companion {
+ public final fun builder ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions$Builder;
public final fun fixed (ILjava/time/Duration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions;
+ public final fun fromConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions;
}
public abstract class org/dexpace/sdk/core/http/pipeline/steps/InstrumentationStep : org/dexpace/sdk/core/http/pipeline/HttpStep {
@@ -1117,6 +1205,12 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/SetDateStep : org/de
public fun process (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/pipeline/PipelineNext;)Lorg/dexpace/sdk/core/http/response/Response;
}
+public final class org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStep : org/dexpace/sdk/core/http/pipeline/HttpStep {
+ public fun ()V
+ public fun getStage ()Lorg/dexpace/sdk/core/http/pipeline/Stage;
+ public fun process (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/pipeline/PipelineNext;)Lorg/dexpace/sdk/core/http/response/Response;
+}
+
public final class org/dexpace/sdk/core/http/request/FileRequestBody : org/dexpace/sdk/core/http/request/RequestBody {
public fun (Ljava/nio/file/Path;)V
public fun (Ljava/nio/file/Path;Lorg/dexpace/sdk/core/http/common/MediaType;)V
@@ -1230,18 +1324,30 @@ public final class org/dexpace/sdk/core/http/request/Request {
public final fun component2 ()Ljava/net/URL;
public final fun component3 ()Lorg/dexpace/sdk/core/http/common/Headers;
public final fun component4 ()Lorg/dexpace/sdk/core/http/request/RequestBody;
+ public static final fun delete (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
public fun equals (Ljava/lang/Object;)Z
+ public static final fun get (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
public final fun getBody ()Lorg/dexpace/sdk/core/http/request/RequestBody;
public final fun getHeaders ()Lorg/dexpace/sdk/core/http/common/Headers;
public final fun getMethod ()Lorg/dexpace/sdk/core/http/request/Method;
public final fun getUrl ()Ljava/net/URL;
public fun hashCode ()I
+ public static final fun head (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
public final fun newBuilder ()Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public static final fun patch (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request;
+ public static final fun post (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request;
+ public static final fun put (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request;
public fun toString ()Ljava/lang/String;
}
public final class org/dexpace/sdk/core/http/request/Request$Companion {
public final fun builder ()Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun delete (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
+ public final fun get (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
+ public final fun head (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
+ public final fun patch (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request;
+ public final fun post (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request;
+ public final fun put (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request;
}
public final class org/dexpace/sdk/core/http/request/Request$RequestBuilder : org/dexpace/sdk/core/generics/Builder {
@@ -1249,16 +1355,28 @@ public final class org/dexpace/sdk/core/http/request/Request$RequestBuilder : or
public fun (Lorg/dexpace/sdk/core/http/request/Request;)V
public final fun addHeader (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun addHeader (Ljava/lang/String;Ljava/util/List;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun addHeader (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun addHeader (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Ljava/util/List;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun addHeader (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun body (Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public synthetic fun build ()Ljava/lang/Object;
public fun build ()Lorg/dexpace/sdk/core/http/request/Request;
+ public final fun delete ()Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun get ()Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun head ()Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun headers (Lorg/dexpace/sdk/core/http/common/Headers;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun method (Lorg/dexpace/sdk/core/http/request/Method;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun patch (Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun post (Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun put (Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun removeHeader (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun removeHeader (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun setHeader (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun setHeader (Ljava/lang/String;Ljava/util/List;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun setHeader (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun setHeader (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Ljava/util/List;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun url (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
+ public final fun url (Ljava/net/URI;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
public final fun url (Ljava/net/URL;)Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder;
}
@@ -1268,6 +1386,8 @@ public abstract class org/dexpace/sdk/core/http/request/RequestBody {
public fun contentLength ()J
public static final fun create (Ljava/io/InputStream;J)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static final fun create (Ljava/io/InputStream;JLorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/RequestBody;
+ public static final fun create (Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;)Lorg/dexpace/sdk/core/http/request/RequestBody;
+ public static final fun create (Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static final fun create (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;Ljava/nio/charset/Charset;)Lorg/dexpace/sdk/core/http/request/RequestBody;
@@ -1296,6 +1416,8 @@ public abstract class org/dexpace/sdk/core/http/request/RequestBody {
public final class org/dexpace/sdk/core/http/request/RequestBody$Companion {
public final fun create (Ljava/io/InputStream;J)Lorg/dexpace/sdk/core/http/request/RequestBody;
public final fun create (Ljava/io/InputStream;JLorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/RequestBody;
+ public final fun create (Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;)Lorg/dexpace/sdk/core/http/request/RequestBody;
+ public final fun create (Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public final fun create (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;Ljava/nio/charset/Charset;)Lorg/dexpace/sdk/core/http/request/RequestBody;
@@ -1314,6 +1436,7 @@ public final class org/dexpace/sdk/core/http/request/RequestBody$Companion {
public final fun create ([B)Lorg/dexpace/sdk/core/http/request/RequestBody;
public final fun create ([BLorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/request/RequestBody$Companion;Ljava/io/InputStream;JLorg/dexpace/sdk/core/http/common/MediaType;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody;
+ public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/request/RequestBody$Companion;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/request/RequestBody$Companion;Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;Ljava/nio/charset/Charset;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/request/RequestBody$Companion;Ljava/nio/file/Path;Lorg/dexpace/sdk/core/http/common/MediaType;JJILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody;
public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/request/RequestBody$Companion;Ljava/util/Map;Ljava/nio/charset/Charset;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody;
@@ -1322,6 +1445,34 @@ public final class org/dexpace/sdk/core/http/request/RequestBody$Companion {
public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/request/RequestBody$Companion;[BLorg/dexpace/sdk/core/http/common/MediaType;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody;
}
+public final class org/dexpace/sdk/core/http/request/RequestOptions {
+ public static final field Companion Lorg/dexpace/sdk/core/http/request/RequestOptions$Companion;
+ public static final field EMPTY Lorg/dexpace/sdk/core/http/request/RequestOptions;
+ public synthetic fun (Ljava/time/Duration;Ljava/util/Map;Ljava/lang/Integer;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public static final fun builder ()Lorg/dexpace/sdk/core/http/request/RequestOptions$Builder;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getMaxRetries ()Ljava/lang/Integer;
+ public final fun getTags ()Ljava/util/Map;
+ public final fun getTimeout ()Ljava/time/Duration;
+ public fun hashCode ()I
+ public final fun newBuilder ()Lorg/dexpace/sdk/core/http/request/RequestOptions$Builder;
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class org/dexpace/sdk/core/http/request/RequestOptions$Builder : org/dexpace/sdk/core/generics/Builder {
+ public fun ()V
+ public fun (Lorg/dexpace/sdk/core/http/request/RequestOptions;)V
+ public synthetic fun build ()Ljava/lang/Object;
+ public fun build ()Lorg/dexpace/sdk/core/http/request/RequestOptions;
+ public final fun maxRetries (Ljava/lang/Integer;)Lorg/dexpace/sdk/core/http/request/RequestOptions$Builder;
+ public final fun tag (Ljava/lang/String;Ljava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestOptions$Builder;
+ public final fun timeout (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/request/RequestOptions$Builder;
+}
+
+public final class org/dexpace/sdk/core/http/request/RequestOptions$Companion {
+ public final fun builder ()Lorg/dexpace/sdk/core/http/request/RequestOptions$Builder;
+}
+
public final class org/dexpace/sdk/core/http/response/LoggableResponseBody : org/dexpace/sdk/core/http/response/ResponseBody {
public fun (Lorg/dexpace/sdk/core/http/response/ResponseBody;)V
public fun (Lorg/dexpace/sdk/core/http/response/ResponseBody;Lorg/dexpace/sdk/core/io/IoProvider;)V
@@ -1375,6 +1526,12 @@ public final class org/dexpace/sdk/core/http/response/Response : java/io/Closeab
public final fun getRequest ()Lorg/dexpace/sdk/core/http/request/Request;
public final fun getStatus ()Lorg/dexpace/sdk/core/http/response/Status;
public fun hashCode ()I
+ public final fun isClientError ()Z
+ public final fun isError ()Z
+ public final fun isInformational ()Z
+ public final fun isRedirect ()Z
+ public final fun isServerError ()Z
+ public final fun isSuccessful ()Z
public final fun newBuilder ()Lorg/dexpace/sdk/core/http/response/Response$ResponseBuilder;
public fun toString ()Ljava/lang/String;
}
@@ -1405,6 +1562,7 @@ public final class org/dexpace/sdk/core/http/response/Response$ResponseBuilder :
public abstract class org/dexpace/sdk/core/http/response/ResponseBody : java/io/Closeable {
public static final field Companion Lorg/dexpace/sdk/core/http/response/ResponseBody$Companion;
public fun ()V
+ public final fun bytes ()[B
public abstract fun close ()V
public abstract fun contentLength ()J
public static final fun create (Lorg/dexpace/sdk/core/io/BufferedSource;)Lorg/dexpace/sdk/core/http/response/ResponseBody;
@@ -1412,6 +1570,9 @@ public abstract class org/dexpace/sdk/core/http/response/ResponseBody : java/io/
public static final fun create (Lorg/dexpace/sdk/core/io/BufferedSource;Lorg/dexpace/sdk/core/http/common/MediaType;J)Lorg/dexpace/sdk/core/http/response/ResponseBody;
public abstract fun mediaType ()Lorg/dexpace/sdk/core/http/common/MediaType;
public abstract fun source ()Lorg/dexpace/sdk/core/io/BufferedSource;
+ public final fun string ()Ljava/lang/String;
+ public final fun string (Ljava/nio/charset/Charset;)Ljava/lang/String;
+ public static synthetic fun string$default (Lorg/dexpace/sdk/core/http/response/ResponseBody;Ljava/nio/charset/Charset;ILjava/lang/Object;)Ljava/lang/String;
}
public final class org/dexpace/sdk/core/http/response/ResponseBody$Companion {
@@ -1421,6 +1582,13 @@ public final class org/dexpace/sdk/core/http/response/ResponseBody$Companion {
public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/response/ResponseBody$Companion;Lorg/dexpace/sdk/core/io/BufferedSource;Lorg/dexpace/sdk/core/http/common/MediaType;JILjava/lang/Object;)Lorg/dexpace/sdk/core/http/response/ResponseBody;
}
+public final class org/dexpace/sdk/core/http/response/ResponseExtensions {
+ public static final fun bodyAs (Lorg/dexpace/sdk/core/http/response/exception/HttpException;Lorg/dexpace/sdk/core/serde/Deserializer;Ljava/lang/Class;)Ljava/lang/Object;
+ public static final fun deserialize (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/serde/Serde;Ljava/lang/Class;)Ljava/lang/Object;
+ public static final fun deserialize (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/serde/TypeRef;)Ljava/lang/Object;
+ public static final fun throwOnError (Lorg/dexpace/sdk/core/http/response/Response;)Lorg/dexpace/sdk/core/http/response/Response;
+}
+
public abstract interface class org/dexpace/sdk/core/http/response/ResponseHandler {
public static final field Companion Lorg/dexpace/sdk/core/http/response/ResponseHandler$Companion;
public static fun empty ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
@@ -1506,6 +1674,11 @@ public final class org/dexpace/sdk/core/http/response/Status {
public final fun getCode ()I
public final fun getStatusName ()Ljava/lang/String;
public fun hashCode ()I
+ public final fun isClientError ()Z
+ public final fun isError ()Z
+ public final fun isInformational ()Z
+ public final fun isRedirect ()Z
+ public final fun isServerError ()Z
public final fun isSuccess ()Z
public fun toString ()Ljava/lang/String;
}
@@ -2115,6 +2288,9 @@ public final class org/dexpace/sdk/core/instrumentation/UrlRedactor {
public static final fun redact (Ljava/net/URL;)Ljava/lang/String;
public static final fun redact (Ljava/net/URL;Ljava/util/Set;)Ljava/lang/String;
public static synthetic fun redact$default (Ljava/net/URL;Ljava/util/Set;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun redactUrlValue (Ljava/lang/String;)Ljava/lang/String;
+ public static final fun redactUrlValue (Ljava/lang/String;Ljava/util/Set;)Ljava/lang/String;
+ public static synthetic fun redactUrlValue$default (Ljava/lang/String;Ljava/util/Set;ILjava/lang/Object;)Ljava/lang/String;
}
public abstract interface class org/dexpace/sdk/core/instrumentation/metrics/DoubleHistogram {
@@ -2208,19 +2384,24 @@ public abstract interface class org/dexpace/sdk/core/io/BufferedSource : org/dex
public final class org/dexpace/sdk/core/io/Io {
public static final field INSTANCE Lorg/dexpace/sdk/core/io/Io;
- public final fun getProvider ()Lorg/dexpace/sdk/core/io/IoProvider;
- public final fun installProvider (Lorg/dexpace/sdk/core/io/IoProvider;)V
+ public static final fun getProvider ()Lorg/dexpace/sdk/core/io/IoProvider;
+ public static final fun installProvider (Lorg/dexpace/sdk/core/io/IoProvider;)V
}
public abstract interface class org/dexpace/sdk/core/io/IoProvider {
public abstract fun buffer ()Lorg/dexpace/sdk/core/io/Buffer;
public abstract fun bufferedSink (Lorg/dexpace/sdk/core/io/Sink;)Lorg/dexpace/sdk/core/io/BufferedSink;
public abstract fun bufferedSource (Lorg/dexpace/sdk/core/io/Source;)Lorg/dexpace/sdk/core/io/BufferedSource;
+ public fun getUnderlying ()Lorg/dexpace/sdk/core/io/IoProvider;
public abstract fun sink (Ljava/io/OutputStream;)Lorg/dexpace/sdk/core/io/BufferedSink;
public abstract fun source (Ljava/io/InputStream;)Lorg/dexpace/sdk/core/io/BufferedSource;
public abstract fun source ([B)Lorg/dexpace/sdk/core/io/BufferedSource;
}
+public final class org/dexpace/sdk/core/io/IoProvider$DefaultImpls {
+ public static fun getUnderlying (Lorg/dexpace/sdk/core/io/IoProvider;)Lorg/dexpace/sdk/core/io/IoProvider;
+}
+
public abstract interface class org/dexpace/sdk/core/io/Sink : java/io/Closeable {
public abstract fun flush ()V
public abstract fun write (Lorg/dexpace/sdk/core/io/Buffer;J)V
@@ -2255,7 +2436,8 @@ public final class org/dexpace/sdk/core/operation/OperationParams$DefaultImpls {
public final class org/dexpace/sdk/core/pagination/AsyncPaginator {
public fun (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;)V
public fun (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;J)V
- public synthetic fun (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;JILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;JLorg/dexpace/sdk/core/http/request/RequestOptions;)V
+ public synthetic fun (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;JLorg/dexpace/sdk/core/http/request/RequestOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun collectAllAsync ()Ljava/util/concurrent/CompletableFuture;
public final fun collectAllAsync (Ljava/util/concurrent/Executor;)Ljava/util/concurrent/CompletableFuture;
public final fun forEachAsync (Ljava/util/function/Consumer;)Ljava/util/concurrent/CompletableFuture;
@@ -2270,10 +2452,14 @@ public final class org/dexpace/sdk/core/pagination/CloseablePages : java/lang/Au
public final fun stream ()Ljava/util/stream/Stream;
}
+public abstract interface class org/dexpace/sdk/core/pagination/CursorExtractor {
+ public abstract fun extract (Lorg/dexpace/sdk/core/http/response/Response;)Lorg/dexpace/sdk/core/pagination/CursorResult;
+}
+
public final class org/dexpace/sdk/core/pagination/CursorPaginationStrategy : org/dexpace/sdk/core/pagination/PaginationStrategy {
- public fun (Lkotlin/jvm/functions/Function1;)V
- public fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;)V
- public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (Lorg/dexpace/sdk/core/pagination/CursorExtractor;)V
+ public fun (Lorg/dexpace/sdk/core/pagination/CursorExtractor;Ljava/lang/String;)V
+ public synthetic fun (Lorg/dexpace/sdk/core/pagination/CursorExtractor;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun parse (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/pagination/PageInfo;
}
@@ -2294,10 +2480,14 @@ public abstract interface class org/dexpace/sdk/core/pagination/FirstPageFetcher
public abstract fun fetch (Lorg/dexpace/sdk/core/pagination/PagingOptions;)Lorg/dexpace/sdk/core/pagination/Page;
}
+public abstract interface class org/dexpace/sdk/core/pagination/ItemsExtractor {
+ public abstract fun extract (Lorg/dexpace/sdk/core/http/response/Response;)Ljava/util/List;
+}
+
public final class org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy : org/dexpace/sdk/core/pagination/PaginationStrategy {
- public fun (Lkotlin/jvm/functions/Function1;)V
- public fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;)V
- public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (Lorg/dexpace/sdk/core/pagination/ItemsExtractor;)V
+ public fun (Lorg/dexpace/sdk/core/pagination/ItemsExtractor;Ljava/lang/String;)V
+ public synthetic fun (Lorg/dexpace/sdk/core/pagination/ItemsExtractor;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun parse (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/pagination/PageInfo;
}
@@ -2333,10 +2523,10 @@ public final class org/dexpace/sdk/core/pagination/PageInfo {
}
public final class org/dexpace/sdk/core/pagination/PageNumberPaginationStrategy : org/dexpace/sdk/core/pagination/PaginationStrategy {
- public fun (Lkotlin/jvm/functions/Function1;)V
- public fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;)V
- public fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;I)V
- public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (Lorg/dexpace/sdk/core/pagination/ItemsExtractor;)V
+ public fun (Lorg/dexpace/sdk/core/pagination/ItemsExtractor;Ljava/lang/String;)V
+ public fun (Lorg/dexpace/sdk/core/pagination/ItemsExtractor;Ljava/lang/String;I)V
+ public synthetic fun (Lorg/dexpace/sdk/core/pagination/ItemsExtractor;Ljava/lang/String;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun parse (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/pagination/PageInfo;
}
@@ -2359,7 +2549,8 @@ public abstract interface class org/dexpace/sdk/core/pagination/PaginationStrate
public final class org/dexpace/sdk/core/pagination/Paginator {
public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;)V
public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;J)V
- public synthetic fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;JILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;JLorg/dexpace/sdk/core/http/request/RequestOptions;)V
+ public synthetic fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;JLorg/dexpace/sdk/core/http/request/RequestOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun byPage ()Lorg/dexpace/sdk/core/pagination/CloseablePages;
public final fun iterateAll ()Ljava/lang/Iterable;
public final fun streamAll ()Ljava/util/stream/Stream;
@@ -2382,23 +2573,23 @@ public final class org/dexpace/sdk/core/pagination/PagingOptions {
public final fun setPageSize (Ljava/lang/Long;)V
}
-public final class org/dexpace/sdk/core/pipeline/ExecutionPipeline {
+public final class org/dexpace/sdk/core/pipeline/RecoveryChain {
public fun (Lorg/dexpace/sdk/core/client/HttpClient;)V
- public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/RequestPipeline;)V
- public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/RequestPipeline;Lorg/dexpace/sdk/core/pipeline/ResponsePipeline;)V
- public synthetic fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/RequestPipeline;Lorg/dexpace/sdk/core/pipeline/ResponsePipeline;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun execute (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/response/Response;
+ public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/RequestRecoveryChain;)V
+ public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/RequestRecoveryChain;Lorg/dexpace/sdk/core/pipeline/ResponseRecoveryChain;)V
+ public synthetic fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/RequestRecoveryChain;Lorg/dexpace/sdk/core/pipeline/ResponseRecoveryChain;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getHttpClient ()Lorg/dexpace/sdk/core/client/HttpClient;
- public final fun getRequestPipeline ()Lorg/dexpace/sdk/core/pipeline/RequestPipeline;
- public final fun getResponsePipeline ()Lorg/dexpace/sdk/core/pipeline/ResponsePipeline;
+ public final fun getRequestChain ()Lorg/dexpace/sdk/core/pipeline/RequestRecoveryChain;
+ public final fun getResponseChain ()Lorg/dexpace/sdk/core/pipeline/ResponseRecoveryChain;
+ public final fun recover (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/response/Response;
}
-public final class org/dexpace/sdk/core/pipeline/RequestPipeline {
+public final class org/dexpace/sdk/core/pipeline/RequestRecoveryChain {
public fun ()V
public fun (Ljava/util/List;)V
public synthetic fun (Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun execute (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/request/Request;
public final fun getSteps ()Ljava/util/List;
+ public final fun recover (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/request/Request;
}
public abstract class org/dexpace/sdk/core/pipeline/ResponseOutcome {
@@ -2431,7 +2622,7 @@ public final class org/dexpace/sdk/core/pipeline/ResponseOutcome$Success : org/d
public fun toString ()Ljava/lang/String;
}
-public final class org/dexpace/sdk/core/pipeline/ResponsePipeline {
+public final class org/dexpace/sdk/core/pipeline/ResponseRecoveryChain {
public fun ()V
public fun (Ljava/util/List;)V
public fun (Ljava/util/List;Ljava/util/List;)V
@@ -2449,7 +2640,6 @@ public final class org/dexpace/sdk/core/pipeline/step/ClientIdentityStep : org/d
public fun (Ljava/util/List;Ljava/lang/String;)V
public fun (Ljava/util/List;Ljava/lang/String;Lorg/dexpace/sdk/core/pipeline/step/ClientIdentityStep$Mode;)V
public synthetic fun (Ljava/util/List;Ljava/lang/String;Lorg/dexpace/sdk/core/pipeline/step/ClientIdentityStep$Mode;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun apply (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/request/Request;
public static final fun builder ()Lorg/dexpace/sdk/core/pipeline/step/ClientIdentityStep$ClientIdentityStepBuilder;
public synthetic fun execute (Ljava/lang/Object;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Ljava/lang/Object;
public fun execute (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/request/Request;
@@ -2529,8 +2719,8 @@ public abstract interface class org/dexpace/sdk/core/pipeline/step/ResponseRecov
public abstract fun invoke (Lorg/dexpace/sdk/core/pipeline/ResponseOutcome;)Lorg/dexpace/sdk/core/pipeline/ResponseOutcome;
}
-public final class org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStep : org/dexpace/sdk/core/pipeline/step/ResponsePipelineStep {
- public static final field INSTANCE Lorg/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStep;
+public final class org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecovery : org/dexpace/sdk/core/pipeline/step/ResponsePipelineStep {
+ public static final field INSTANCE Lorg/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecovery;
public synthetic fun execute (Ljava/lang/Object;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Ljava/lang/Object;
public fun execute (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/response/Response;
}
@@ -2549,6 +2739,21 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetryAfterParser {
public static final fun parseHeaderValue (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Ljava/lang/String;Ljava/time/Instant;)Ljava/time/Duration;
}
+public final class org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery : org/dexpace/sdk/core/pipeline/step/ResponseRecoveryStep {
+ public static final field Companion Lorg/dexpace/sdk/core/pipeline/step/retry/RetryRecovery$Companion;
+ public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;Lorg/dexpace/sdk/core/http/request/Request;)V
+ public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;Lorg/dexpace/sdk/core/http/request/Request;Ljava/time/Clock;)V
+ public synthetic fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;Lorg/dexpace/sdk/core/http/request/Request;Ljava/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun attempt ()Lorg/dexpace/sdk/core/http/response/Response;
+ public final fun getHttpClient ()Lorg/dexpace/sdk/core/client/HttpClient;
+ public final fun getRequest ()Lorg/dexpace/sdk/core/http/request/Request;
+ public final fun getSettings ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
+ public fun invoke (Lorg/dexpace/sdk/core/pipeline/ResponseOutcome;)Lorg/dexpace/sdk/core/pipeline/ResponseOutcome;
+}
+
+public final class org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery$Companion {
+}
+
public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings {
public static final field Companion Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$Companion;
public static final field DEFAULT_DELAY_MULTIPLIER D
@@ -2597,21 +2802,6 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetryS
public final fun totalTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
}
-public final class org/dexpace/sdk/core/pipeline/step/retry/RetryStep : org/dexpace/sdk/core/pipeline/step/ResponseRecoveryStep {
- public static final field Companion Lorg/dexpace/sdk/core/pipeline/step/retry/RetryStep$Companion;
- public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;Lorg/dexpace/sdk/core/http/request/Request;)V
- public fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;Lorg/dexpace/sdk/core/http/request/Request;Ljava/time/Clock;)V
- public synthetic fun (Lorg/dexpace/sdk/core/client/HttpClient;Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;Lorg/dexpace/sdk/core/http/request/Request;Ljava/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun attempt ()Lorg/dexpace/sdk/core/http/response/Response;
- public final fun getHttpClient ()Lorg/dexpace/sdk/core/client/HttpClient;
- public final fun getRequest ()Lorg/dexpace/sdk/core/http/request/Request;
- public final fun getSettings ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
- public fun invoke (Lorg/dexpace/sdk/core/pipeline/ResponseOutcome;)Lorg/dexpace/sdk/core/pipeline/ResponseOutcome;
-}
-
-public final class org/dexpace/sdk/core/pipeline/step/retry/RetryStep$Companion {
-}
-
public class org/dexpace/sdk/core/serde/DeserializationException : org/dexpace/sdk/core/serde/SerdeException {
public fun ()V
public fun (Ljava/lang/String;)V
@@ -2621,11 +2811,21 @@ public class org/dexpace/sdk/core/serde/DeserializationException : org/dexpace/s
public abstract interface class org/dexpace/sdk/core/serde/Deserializer {
public abstract fun deserialize (Ljava/io/InputStream;Ljava/lang/Class;)Ljava/lang/Object;
+ public fun deserialize (Ljava/io/InputStream;Lorg/dexpace/sdk/core/serde/TypeRef;)Ljava/lang/Object;
public abstract fun deserialize (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;
+ public fun deserialize (Ljava/lang/String;Lorg/dexpace/sdk/core/serde/TypeRef;)Ljava/lang/Object;
public abstract fun deserialize ([BLjava/lang/Class;)Ljava/lang/Object;
+ public fun deserialize ([BLorg/dexpace/sdk/core/serde/TypeRef;)Ljava/lang/Object;
+}
+
+public final class org/dexpace/sdk/core/serde/Deserializer$DefaultImpls {
+ public static fun deserialize (Lorg/dexpace/sdk/core/serde/Deserializer;Ljava/io/InputStream;Lorg/dexpace/sdk/core/serde/TypeRef;)Ljava/lang/Object;
+ public static fun deserialize (Lorg/dexpace/sdk/core/serde/Deserializer;Ljava/lang/String;Lorg/dexpace/sdk/core/serde/TypeRef;)Ljava/lang/Object;
+ public static fun deserialize (Lorg/dexpace/sdk/core/serde/Deserializer;[BLorg/dexpace/sdk/core/serde/TypeRef;)Ljava/lang/Object;
}
public abstract interface class org/dexpace/sdk/core/serde/Serde {
+ public abstract fun contentType ()Lorg/dexpace/sdk/core/http/common/MediaType;
public abstract fun getDeserializer ()Lorg/dexpace/sdk/core/serde/Deserializer;
public abstract fun getSerializer ()Lorg/dexpace/sdk/core/serde/Serializer;
}
@@ -2697,6 +2897,12 @@ public final class org/dexpace/sdk/core/serde/Tristate$Present : org/dexpace/sdk
public fun toString ()Ljava/lang/String;
}
+public abstract class org/dexpace/sdk/core/serde/TypeRef {
+ protected fun ()V
+ public final fun getRawClass ()Ljava/lang/Class;
+ public final fun getType ()Ljava/lang/reflect/Type;
+}
+
public abstract interface class org/dexpace/sdk/core/util/Clock {
public static final field Companion Lorg/dexpace/sdk/core/util/Clock$Companion;
public static final field SYSTEM Lorg/dexpace/sdk/core/util/Clock;
@@ -2732,6 +2938,7 @@ public final class org/dexpace/sdk/core/util/ProxyOptions {
public synthetic fun (Lorg/dexpace/sdk/core/util/ProxyOptions$Type;Ljava/net/InetSocketAddress;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lorg/dexpace/sdk/core/auth/ChallengeHandler;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun bypassesProxy (Ljava/lang/String;)Z
public static final fun fromConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/util/ProxyOptions;
+ public static final fun fromEnvironment ()Lorg/dexpace/sdk/core/util/ProxyOptions;
public final fun getAddress ()Ljava/net/InetSocketAddress;
public final fun getBypassAllHosts ()Z
public final fun getChallengeHandler ()Lorg/dexpace/sdk/core/auth/ChallengeHandler;
@@ -2744,6 +2951,7 @@ public final class org/dexpace/sdk/core/util/ProxyOptions {
public final class org/dexpace/sdk/core/util/ProxyOptions$Companion {
public final fun fromConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)Lorg/dexpace/sdk/core/util/ProxyOptions;
+ public final fun fromEnvironment ()Lorg/dexpace/sdk/core/util/ProxyOptions;
}
public final class org/dexpace/sdk/core/util/ProxyOptions$Type : java/lang/Enum {
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/AsyncHttpClient.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/AsyncHttpClient.kt
index c6d03266..61c14908 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/AsyncHttpClient.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/AsyncHttpClient.kt
@@ -10,8 +10,10 @@
package org.dexpace.sdk.core.client
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.util.Futures
+import org.dexpace.sdk.core.util.interruptibleFuture
import java.io.IOException
import java.io.InterruptedIOException
import java.util.concurrent.CompletableFuture
@@ -71,6 +73,19 @@ public fun interface AsyncHttpClient : AutoCloseable {
*/
public fun executeAsync(request: Request): CompletableFuture
+ /**
+ * Sends [request] with per-call [options] applied. The default implementation ignores
+ * [options] and delegates to [executeAsync], so this stays a single-abstract-method
+ * `fun interface` and existing implementations (SAM literals, transports written before
+ * per-request overrides) keep working unchanged. Transports that honour per-call overrides
+ * override this method to read [options]; see the reference transports for the
+ * per-call-timeout wiring. Pipelines thread the caller's options into this overload.
+ */
+ public fun executeAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture = executeAsync(request)
+
/**
* Releases any resources held by this transport. The default implementation is a no-op so
* SAM literals and lightweight client wrappers do not need to implement [AutoCloseable.close]
@@ -94,14 +109,33 @@ public fun interface AsyncHttpClient : AutoCloseable {
* number of platform threads that the JVM shares with parallel streams and CompletableFuture
* default executors, so a blocking HTTP call starves every other commonPool consumer.
*
- * The returned future completes with the response from `execute`. Cancellation of the future
- * does NOT interrupt the in-flight blocking call (Java's `CompletableFuture` has no such
- * hook); for true cancellation, use an [AsyncHttpClient] backed by an async transport.
+ * The returned future completes with the response from `execute`.
+ *
+ * ## Cancellation
+ * Cancelling the returned future with `cancel(true)` interrupts the worker thread running the
+ * in-flight `execute(...)`, matching [HttpPipeline.toAsync][org.dexpace.sdk.core.http.pipeline.toAsync]'s
+ * cancellation semantics. The interrupt is delivered only while the send is actually executing — a
+ * not-yet-started task (still queued on [executor]) is simply abandoned, and an already-completed
+ * send is unaffected. `cancel(false)` completes the future as cancelled without interrupting the
+ * worker, so a blocking `execute` that ignores interrupts runs to completion in the background.
+ * For the interrupt to abort I/O, the wrapped [HttpClient] must honour `Thread.interrupt()` (the
+ * shipped transports do).
*/
-public fun HttpClient.asAsync(executor: Executor): AsyncHttpClient =
- AsyncHttpClient { request ->
- CompletableFuture.supplyAsync({ execute(request) }, executor)
+public fun HttpClient.asAsync(executor: Executor): AsyncHttpClient {
+ val sync = this
+ return object : AsyncHttpClient {
+ override fun executeAsync(request: Request): CompletableFuture =
+ executeAsync(request, RequestOptions.EMPTY)
+
+ // Thread the caller's per-call options into the wrapped blocking send so overrides
+ // (timeout, retry budget, tags) survive the sync→async bridge instead of being dropped
+ // by the SPI's options-ignoring default.
+ override fun executeAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture = interruptibleFuture(executor) { sync.execute(request, options) }
}
+}
/**
* Wraps an [AsyncHttpClient] as a blocking [HttpClient] by blocking on `executeAsync(...).get()`
@@ -111,30 +145,44 @@ public fun HttpClient.asAsync(executor: Executor): AsyncHttpClient =
*
* The blocking wait honours `Thread.interrupt()`: interrupting the calling thread restores the
* interrupt flag, cancels the in-flight future, and throws an [InterruptedIOException] (an
- * [IOException] subtype, so the `@Throws(IOException::class)` contract holds).
+ * [IOException] subtype). The `throws IOException` contract lives on the returned client's
+ * [HttpClient.execute] — where the blocking wait actually happens — not on this factory, which
+ * only constructs the wrapper and performs no I/O.
*
* The returned [Response] must be closed by the caller, per the [HttpClient.execute] contract.
*/
-@Throws(IOException::class)
-public fun AsyncHttpClient.asBlocking(): HttpClient =
- HttpClient { request ->
- val future = executeAsync(request)
- try {
- future.get()
- } catch (ie: InterruptedException) {
- // `get()` parks interruptibly (unlike `join()`). Restore the interrupt flag, abort
- // the in-flight exchange, and surface an InterruptedIOException so callers' I/O
- // error handling terminates cleanly. See RetryStep.awaitDelay for the same pattern.
- Thread.currentThread().interrupt()
- future.cancel(true)
- val ioe = InterruptedIOException("Interrupted while waiting for response")
- ioe.initCause(ie)
- throw ioe
- } catch (ee: ExecutionException) {
- // `get()` wraps every exceptional completion in ExecutionException; unwrap so
- // callers' `catch (IOException)` blocks see the original failure rather than the
- // JDK wrapper. `CancellationException` from a cancelled future is unaffected — it
- // is a RuntimeException, not an ExecutionException, so it propagates as-is.
- throw Futures.unwrap(ee)
+public fun AsyncHttpClient.asBlocking(): HttpClient {
+ val async = this
+ return object : HttpClient {
+ @Throws(IOException::class)
+ override fun execute(request: Request): Response = execute(request, RequestOptions.EMPTY)
+
+ // Thread the caller's per-call options into the wrapped async send so overrides survive
+ // the async→sync bridge instead of being dropped by the SPI's options-ignoring default.
+ @Throws(IOException::class)
+ override fun execute(
+ request: Request,
+ options: RequestOptions,
+ ): Response {
+ val future = async.executeAsync(request, options)
+ return try {
+ future.get()
+ } catch (ie: InterruptedException) {
+ // `get()` parks interruptibly (unlike `join()`). Restore the interrupt flag, abort
+ // the in-flight exchange, and surface an InterruptedIOException so callers' I/O
+ // error handling terminates cleanly. See RetryRecovery.awaitDelay for the same pattern.
+ Thread.currentThread().interrupt()
+ future.cancel(true)
+ val ioe = InterruptedIOException("Interrupted while waiting for response")
+ ioe.initCause(ie)
+ throw ioe
+ } catch (ee: ExecutionException) {
+ // `get()` wraps every exceptional completion in ExecutionException; unwrap so
+ // callers' `catch (IOException)` blocks see the original failure rather than the
+ // JDK wrapper. `CancellationException` from a cancelled future is unaffected — it
+ // is a RuntimeException, not an ExecutionException, so it propagates as-is.
+ throw Futures.unwrap(ee)
+ }
}
}
+}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/HttpClient.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/HttpClient.kt
index cf2df3af..3277872a 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/HttpClient.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/client/HttpClient.kt
@@ -8,7 +8,9 @@
package org.dexpace.sdk.core.client
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
+import java.io.IOException
/**
* Transport SPI: the seam between the SDK's request/response models and a concrete HTTP transport
@@ -47,9 +49,29 @@ public fun interface HttpClient : AutoCloseable {
/**
* Sends [request] over the underlying transport and returns the matching [Response]. The
* response body is not pre-buffered — callers are responsible for closing it.
+ *
+ * Declares `throws IOException` (the SDK's canonical transport failure
+ * `org.dexpace.sdk.core.http.response.exception.NetworkException` extends [java.io.IOException])
+ * so a Java-authored transport may declare and throw it and a Java caller can
+ * `catch (IOException)` at this call site.
*/
+ @Throws(IOException::class)
public fun execute(request: Request): Response
+ /**
+ * Sends [request] with per-call [options] applied. The default implementation ignores
+ * [options] and delegates to [execute], so this stays a single-abstract-method `fun interface`
+ * and existing implementations (SAM literals, transports written before per-request overrides)
+ * keep working unchanged. Transports that honour per-call overrides — a timeout, a retry
+ * budget, tags — override this method to read [options]; see the reference transports for the
+ * per-call-timeout wiring. Pipelines thread the caller's options into this overload.
+ */
+ @Throws(IOException::class)
+ public fun execute(
+ request: Request,
+ options: RequestOptions,
+ ): Response = execute(request)
+
/**
* Releases any resources held by this transport. The default implementation is a no-op so
* SAM literals and lightweight client wrappers do not need to implement [AutoCloseable.close]
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/CommonMediaTypes.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/CommonMediaTypes.kt
index 2996214e..e94a7fd0 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/CommonMediaTypes.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/CommonMediaTypes.kt
@@ -18,12 +18,15 @@ package org.dexpace.sdk.core.http.common
*/
@Suppress("unused")
public object CommonMediaTypes {
+ // The six constants that also exist on MediaType's companion delegate to those instances so
+ // there is a single source of truth and no duplicate MediaType objects.
+
// Text Types
@JvmField
- public val TEXT_PLAIN: MediaType = MediaType.of("text", "plain")
+ public val TEXT_PLAIN: MediaType = MediaType.TEXT_PLAIN
@JvmField
- public val TEXT_HTML: MediaType = MediaType.of("text", "html")
+ public val TEXT_HTML: MediaType = MediaType.TEXT_HTML
@JvmField
public val TEXT_CSS: MediaType = MediaType.of("text", "css")
@@ -36,16 +39,16 @@ public object CommonMediaTypes {
// Application Types
@JvmField
- public val APPLICATION_JSON: MediaType = MediaType.of("application", "json")
+ public val APPLICATION_JSON: MediaType = MediaType.APPLICATION_JSON
@JvmField
- public val APPLICATION_XML: MediaType = MediaType.of("application", "xml")
+ public val APPLICATION_XML: MediaType = MediaType.APPLICATION_XML
@JvmField
- public val APPLICATION_FORM_URLENCODED: MediaType = MediaType.of("application", "x-www-form-urlencoded")
+ public val APPLICATION_FORM_URLENCODED: MediaType = MediaType.APPLICATION_FORM_URLENCODED
@JvmField
- public val APPLICATION_OCTET_STREAM: MediaType = MediaType.of("application", "octet-stream")
+ public val APPLICATION_OCTET_STREAM: MediaType = MediaType.APPLICATION_OCTET_STREAM
@JvmField
public val APPLICATION_PDF: MediaType = MediaType.of("application", "pdf")
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/ETag.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/ETag.kt
index d4920c2d..fbb3ecb3 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/ETag.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/ETag.kt
@@ -17,14 +17,23 @@ package org.dexpace.sdk.core.http.common
*
* The opaque value is exposed unquoted via [opaque]; the raw header form is exposed via
* [toHeaderValue]. Round-trips with [parse] preserve the textual form exactly.
+ *
+ * A plain immutable final class (not a `value class`): the factories and accessors are
+ * header helpers where the boxing an inline class would avoid is irrelevant, and keeping
+ * it a plain class gives every factory and accessor a clean, unmangled JVM name so the
+ * whole conditional-request surface is reachable from Java.
*/
-@JvmInline
-public value class ETag private constructor(private val raw: String) {
+public class ETag private constructor(private val raw: String) {
/** Returns the raw header form, e.g. `"\"foo\""`, `W/"foo"`, or `*`. */
public fun toHeaderValue(): String = raw
override fun toString(): String = raw
+ /** Value equality over the raw header form. */
+ override fun equals(other: Any?): Boolean = this === other || (other is ETag && other.raw == raw)
+
+ override fun hashCode(): Int = raw.hashCode()
+
/** `true` when this is a weak validator (`W/"opaque"`). */
public val isWeak: Boolean get() = raw.startsWith("W/")
@@ -42,31 +51,35 @@ public value class ETag private constructor(private val raw: String) {
/**
* Matches any entity: `If-Match: *` / `If-None-Match: *`.
*
- * Exposed via `@JvmStatic` getter (rather than `@JvmField`) because the JVM
- * forbids storing inline-class instances as static fields — they'd auto-box at
- * the storage site and defeat the value-class optimisation. Java callers see
- * `ETag.getANY()`; Kotlin callers see `ETag.ANY`.
+ * Exposed as a `@JvmField` static field, so Java callers see `ETag.ANY` (a field)
+ * and Kotlin callers see `ETag.ANY`.
*/
- @JvmStatic
+ @JvmField
public val ANY: ETag = ETag("*")
/**
* Strong validator: `"opaque"`. The supplied [opaque] value is quote-wrapped.
*
- * @throws IllegalArgumentException if [opaque] is empty (use [ANY] for `*`).
+ * @throws IllegalArgumentException if [opaque] is empty (use [ANY] for `*`), or contains a
+ * character outside the RFC 7232 §2.3 `etagc` set (`%x21 / %x23-7E / obs-text`) — a
+ * literal double-quote, a control character, or `DEL`.
*/
@JvmStatic
public fun strong(opaque: String): ETag {
require(opaque.isNotEmpty()) { "opaque must not be empty (use ETag.ANY for `*`)" }
- return ETag("\"$opaque\"")
+ return fromOpaque(opaque, weak = false)
}
/**
* Weak validator: `W/"opaque"`. Empty [opaque] (producing `W/""`) is permitted
* — it is technically valid per RFC 7232 §2.3.
+ *
+ * @throws IllegalArgumentException if [opaque] contains a character outside the RFC 7232
+ * §2.3 `etagc` set (`%x21 / %x23-7E / obs-text`) — a literal double-quote, a control
+ * character, or `DEL`.
*/
@JvmStatic
- public fun weak(opaque: String): ETag = ETag("W/\"$opaque\"")
+ public fun weak(opaque: String): ETag = fromOpaque(opaque, weak = true)
/**
* Parses a raw header form. Returns [ANY] for `*`, `null` for blank or missing
@@ -98,8 +111,52 @@ public value class ETag private constructor(private val raw: String) {
return ETag(trimmed)
}
+ /**
+ * Shared construction path for [strong] and [weak]: validates that [opaque] contains only
+ * RFC 7232 §2.3 `etagc` characters, then quote-wraps it (prefixed with `W/` when [weak]).
+ * Rejecting the illegal characters here keeps a raw double-quote, control character, or
+ * `DEL` from breaking out of the quoted entity-tag on the wire.
+ */
+ private fun fromOpaque(
+ opaque: String,
+ weak: Boolean,
+ ): ETag {
+ require(opaque.all(::isEtagChar)) {
+ "opaque must contain only RFC 7232 etagc characters " +
+ "(no double-quote, control characters, or DEL): \"$opaque\""
+ }
+ val quoted = "\"$opaque\""
+ return ETag(if (weak) "W/$quoted" else quoted)
+ }
+
+ /**
+ * True when [ch] is an RFC 7232 §2.3 `etagc` character: `%x21 / %x23-7E / obs-text
+ * (%x80-FF)`. Excludes the double-quote (`%x22`), the C0 controls and space (below
+ * `%x21`), and `DEL` (`%x7F`).
+ */
+ private fun isEtagChar(ch: Char): Boolean {
+ val code = ch.code
+ return (code in ETAGC_PRINTABLE_LOW..ETAGC_PRINTABLE_HIGH && code != DOUBLE_QUOTE) ||
+ code in OBS_TEXT_LOW..OBS_TEXT_HIGH
+ }
+
// Minimum length for a weak-form ETag: `W/"x"` is 4 chars (`W/"` prefix + `"`
// suffix + at least 1 opaque char between).
private const val WEAK_FORM_MIN_LEN = 4
+
+ /** First printable ASCII byte allowed in RFC 7232 §2.3 `etagc` (`!`). */
+ private const val ETAGC_PRINTABLE_LOW: Int = 0x21
+
+ /** The double-quote (`"`) — excluded from `etagc` so it cannot close the entity-tag. */
+ private const val DOUBLE_QUOTE: Int = 0x22
+
+ /** Last printable ASCII byte allowed in `etagc` (`~`); `DEL` (`0x7F`) sits just above it. */
+ private const val ETAGC_PRINTABLE_HIGH: Int = 0x7E
+
+ /** Lower bound of the obs-text range allowed in `etagc`. */
+ private const val OBS_TEXT_LOW: Int = 0x80
+
+ /** Upper bound of the obs-text range allowed in `etagc`. */
+ private const val OBS_TEXT_HIGH: Int = 0xFF
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpHeaderName.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpHeaderName.kt
index f538e3fe..849ccded 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpHeaderName.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpHeaderName.kt
@@ -199,7 +199,7 @@ public class HttpHeaderName private constructor(
@JvmField public val X_XSS_PROTECTION: HttpHeaderName = fromString("X-XSS-Protection")
- // Microsoft retry headers — consumed by RetryStep (Phase B).
+ // Microsoft retry headers — consumed by RetryRecovery (Phase B).
@JvmField public val RETRY_AFTER_MS: HttpHeaderName = fromString("retry-after-ms")
@JvmField public val X_MS_RETRY_AFTER_MS: HttpHeaderName = fromString("x-ms-retry-after-ms")
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpRange.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpRange.kt
index c00fb33f..0a922056 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpRange.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpRange.kt
@@ -15,16 +15,26 @@ package org.dexpace.sdk.core.http.common
* intentionally **not** supported by this type — call [parse] with a single range or
* compose multiple `Range` headers at the [Headers.Builder] level.
*
- * Round-trips with [toHeaderValue] / [parse]: parsing preserves the original textual
- * form, so unusual but valid whitespace and casing survive a parse/format cycle.
+ * Round-trips with [toHeaderValue] / [parse]: [parse] stores the accepted value verbatim, so
+ * its exact casing survives a parse/format cycle (`BYTES=0-1023` is preserved as written). The
+ * value must begin with the `bytes=` unit token — matched case-insensitively, but with no leading
+ * whitespace — so a leading space before `bytes=` is rejected rather than preserved.
+ *
+ * A plain immutable final class (not a `value class`): the factories are header helpers
+ * where the boxing an inline class would avoid is irrelevant, and keeping it a plain class
+ * gives every factory a clean, unmangled JVM name so the range surface is reachable from Java.
*/
-@JvmInline
-public value class HttpRange private constructor(private val raw: String) {
+public class HttpRange private constructor(private val raw: String) {
/** Header value (e.g. `"bytes=0-1023"`). */
public fun toHeaderValue(): String = raw
override fun toString(): String = raw
+ /** Value equality over the raw header form. */
+ override fun equals(other: Any?): Boolean = this === other || (other is HttpRange && other.raw == raw)
+
+ override fun hashCode(): Int = raw.hashCode()
+
public companion object {
/**
* Range `bytes=offset-(offset+length-1)`.
@@ -68,14 +78,16 @@ public value class HttpRange private constructor(private val raw: String) {
}
/**
- * Parses a `Range` header value into an [HttpRange]. Round-trips with
- * [toHeaderValue]: the original textual form is preserved (including any unusual
- * whitespace) so identical headers can be emitted on the wire.
+ * Parses a `Range` header value into an [HttpRange]. The accepted value is stored
+ * verbatim and round-trips through [toHeaderValue] unchanged, so its exact casing is
+ * preserved on the wire.
*
- * Only the `bytes` unit is accepted; multi-range values are rejected.
+ * The value must begin with the `bytes=` unit token (matched case-insensitively, with no
+ * leading whitespace); only the `bytes` unit is accepted, and multi-range values are
+ * rejected.
*
- * @throws IllegalArgumentException if the unit is not `bytes` or the value
- * contains a comma (multi-range).
+ * @throws IllegalArgumentException if the value does not begin with the `bytes=` unit
+ * token or contains a comma (multi-range).
*/
@JvmStatic
public fun parse(raw: String): HttpRange {
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/MediaType.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/MediaType.kt
index 145ebc8a..12a5ed29 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/MediaType.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/MediaType.kt
@@ -159,6 +159,35 @@ public data class MediaType private constructor(
private fun isHeaderUnsafe(ch: Char): Boolean = isProhibitedInValue(ch.code)
public companion object {
+ // --- Common media-type shortcuts -----------------------------------------------
+ // Built directly via of() so these constants are self-contained. They must NOT read
+ // CommonMediaTypes, which builds its own constants through MediaType.of(...): a reference
+ // in either direction forms a class-initialization cycle, and whichever class initializes
+ // second would observe the other's static fields still at their default null (JLS 12.4.2),
+ // permanently leaving these non-null constants null. Keeping MediaType's free of
+ // any CommonMediaTypes read breaks that cycle. Exposed as @JvmField so Java callers see a
+ // plain static field (MediaType.APPLICATION_JSON) rather than a generated getter.
+
+ /** `application/json`. */
+ @JvmField public val APPLICATION_JSON: MediaType = of("application", "json")
+
+ /** `text/plain`. */
+ @JvmField public val TEXT_PLAIN: MediaType = of("text", "plain")
+
+ /** `application/octet-stream`. */
+ @JvmField public val APPLICATION_OCTET_STREAM: MediaType = of("application", "octet-stream")
+
+ /** `application/x-www-form-urlencoded`. */
+ @JvmField public val APPLICATION_FORM_URLENCODED: MediaType = of("application", "x-www-form-urlencoded")
+
+ /** `text/html`. */
+ @JvmField public val TEXT_HTML: MediaType = of("text", "html")
+
+ /** `application/xml`. */
+ @JvmField public val APPLICATION_XML: MediaType = of("application", "xml")
+
+ // -------------------------------------------------------------------------------
+
/** RFC 7230 §3.2.6 `tchar` punctuation (the non-alphanumeric members of the token set). */
private const val TOKEN_SPECIALS: String = "!#$%&'*+-.^_`|~"
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/RequestConditions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/RequestConditions.kt
index cde3e982..30076314 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/RequestConditions.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/RequestConditions.kt
@@ -17,7 +17,9 @@ import java.time.Instant
* Constructed via [builder]. Apply to a request via [applyTo], which writes the configured
* conditions onto a [Headers.Builder]:
* - Multiple [ETag] entries in [ifMatch] / [ifNoneMatch] are emitted as a single
- * comma-separated header value (per RFC 7232).
+ * comma-separated header value (per RFC 7232). [ETag.ANY] (`*`) is mutually exclusive with
+ * concrete entity-tags in a list — [Builder.build] rejects a mix and collapses repeated `*`
+ * to a single `*`.
* - [ifModifiedSince] / [ifUnmodifiedSince] are formatted via [DateTimeRfc1123].
*
* Specifying both [ifModifiedSince] and [ifUnmodifiedSince] is legal per the spec
@@ -79,22 +81,70 @@ public class RequestConditions private constructor(
/** Adds an `If-Match` validator. Multiple calls accumulate (comma-separated on the wire). */
public fun ifMatch(tag: ETag): Builder = apply { ifMatch.add(tag) }
+ /**
+ * Adds an `If-Match` validator from its raw RFC 7232 header form (`"opaque"`,
+ * `W/"opaque"`, or `*`) — a String escape hatch equivalent to
+ * `ifMatch(ETag.parse(value)!!)`. Multiple calls accumulate.
+ *
+ * @throws IllegalArgumentException if [value] is blank or not a recognised ETag form.
+ */
+ public fun ifMatch(value: String): Builder = apply { ifMatch.add(parseTag(value)) }
+
/** Adds an `If-None-Match` validator. Multiple calls accumulate (comma-separated on the wire). */
public fun ifNoneMatch(tag: ETag): Builder = apply { ifNoneMatch.add(tag) }
+ /**
+ * Adds an `If-None-Match` validator from its raw RFC 7232 header form (`"opaque"`,
+ * `W/"opaque"`, or `*`) — a String escape hatch equivalent to
+ * `ifNoneMatch(ETag.parse(value)!!)`. Multiple calls accumulate.
+ *
+ * @throws IllegalArgumentException if [value] is blank or not a recognised ETag form.
+ */
+ public fun ifNoneMatch(value: String): Builder = apply { ifNoneMatch.add(parseTag(value)) }
+
/** Sets the `If-Modified-Since` instant; subsequent calls replace prior values. */
public fun ifModifiedSince(instant: Instant): Builder = apply { this.ifModifiedSince = instant }
/** Sets the `If-Unmodified-Since` instant; subsequent calls replace prior values. */
public fun ifUnmodifiedSince(instant: Instant): Builder = apply { this.ifUnmodifiedSince = instant }
+ /**
+ * Builds the [RequestConditions].
+ *
+ * @throws IllegalArgumentException if [ETag.ANY] (`*`) is combined with any concrete
+ * entity-tag in the same `If-Match` or `If-None-Match` list. RFC 7232 grammar is
+ * `"*" / 1#entity-tag`, so `*` is mutually exclusive with entity-tags; repeated `*`
+ * entries collapse to a single `*`.
+ */
public fun build(): RequestConditions =
RequestConditions(
- ifMatch = ifMatch.toList(),
- ifNoneMatch = ifNoneMatch.toList(),
+ ifMatch = normalizeTags(ifMatch.toList()),
+ ifNoneMatch = normalizeTags(ifNoneMatch.toList()),
ifModifiedSince = ifModifiedSince,
ifUnmodifiedSince = ifUnmodifiedSince,
)
+
+ /**
+ * Parses a raw ETag header form, rejecting blank input. [ETag.parse] returns `null`
+ * for blank/missing values, which is not a usable validator here, so a blank value
+ * is an [IllegalArgumentException] rather than a silent no-op.
+ */
+ private fun parseTag(value: String): ETag = requireNotNull(ETag.parse(value)) { "ETag value must not be blank" }
+
+ /**
+ * Normalizes an `If-Match` / `If-None-Match` tag list against the RFC 7232
+ * `"*" / 1#entity-tag` grammar: [ETag.ANY] (`*`) is mutually exclusive with concrete
+ * entity-tags, so a mix of the two is rejected; a list of one or more `*` entries
+ * collapses to a single `*`.
+ */
+ private fun normalizeTags(tags: List): List {
+ if (tags.none { it.isAny }) return tags
+ require(tags.all { it.isAny }) {
+ "ETag.ANY (*) is mutually exclusive with concrete entity-tags in the same " +
+ "If-Match/If-None-Match list (RFC 7232: \"*\" / 1#entity-tag)"
+ }
+ return listOf(ETag.ANY)
+ }
}
public companion object {
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline.kt
index 6f2c5a32..6774cba9 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline.kt
@@ -9,9 +9,12 @@ package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.client.AsyncHttpClient
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
+import org.dexpace.sdk.core.http.response.ResponseHandler
import org.dexpace.sdk.core.util.Futures
import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ScheduledExecutorService
/**
* Asynchronous counterpart of [HttpPipeline]. Holds an ordered, stage-sorted [Array] of
@@ -24,13 +27,20 @@ import java.util.concurrent.CompletableFuture
* concurrent sends never share mutable state inside the pipeline itself. Steps must remain
* thread-safe across concurrent invocations per the pipeline contract.
*
+ * ## Transport SPI conformance
+ * `AsyncHttpPipeline` itself implements [AsyncHttpClient]: [executeAsync] delegates to
+ * [sendAsync]. A fully configured pipeline can therefore stand in anywhere an async transport is
+ * expected — most usefully as the client backing an
+ * [org.dexpace.sdk.core.pagination.AsyncPaginator]. The inherited [close] is a no-op: the
+ * pipeline wraps a caller-supplied transport it does not own, so it never closes it.
+ *
* Construct via [AsyncHttpPipelineBuilder]; the constructor is internal.
*/
public class AsyncHttpPipeline internal constructor(
/** The transport that receives the request when no further step remains. */
public val httpClient: AsyncHttpClient,
internal val stepArray: Array,
-) {
+) : AsyncHttpClient {
/** Read-only list view of the ordered steps. Backed by [stepArray]; for inspection only. */
public val steps: List = stepArray.asList()
@@ -44,10 +54,22 @@ public class AsyncHttpPipeline internal constructor(
* or step failure. Synchronous exceptions thrown by the first step are normalised into
* a failed future so callers see a uniform async error model.
*/
- public fun sendAsync(request: Request): CompletableFuture {
+ public fun sendAsync(request: Request): CompletableFuture = sendAsync(request, RequestOptions.EMPTY)
+
+ /**
+ * Runs [request] through the pipeline with per-call [options] applied. The options are carried
+ * for the whole call: async steps read them via [AsyncPipelineNext.options] (the async stage
+ * retry step honours a per-call [RequestOptions.maxRetries]), and the terminal dispatch threads
+ * them into `AsyncHttpClient.executeAsync(request, options)` so the transport applies a per-call
+ * timeout. Empty pipelines short-circuit directly to the transport's per-call overload.
+ */
+ public fun sendAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture {
if (stepArray.isEmpty()) {
return try {
- httpClient.executeAsync(request)
+ httpClient.executeAsync(request, options)
} catch (e: Exception) {
// Defensive: the AsyncHttpClient contract says transport failures complete
// the future exceptionally. Normalise any sync throw to keep the contract
@@ -55,10 +77,68 @@ public class AsyncHttpPipeline internal constructor(
Futures.failed(e)
}
}
- val state = AsyncPipelineCallState(this, request)
+ val state = AsyncPipelineCallState(this, request, options)
return AsyncPipelineNext(state).processAsync()
}
+ /**
+ * Runs [request] through the pipeline and maps the response with [handler], returning the
+ * typed result. A terminal convenience equivalent to `sendAsync(request).handleWith(handler)`:
+ * on success the handler's value completes the future and the response is closed; on failure
+ * the future is failed with the unwrapped cause. See [handleWith] for the exact semantics.
+ */
+ public fun sendAsync(
+ request: Request,
+ handler: ResponseHandler,
+ ): CompletableFuture = sendAsync(request).handleWith(handler)
+
+ /**
+ * Runs [request] through the pipeline with per-call [options] applied, then maps the response
+ * with [handler], returning the typed result. Combines the per-call [options] threading of
+ * [sendAsync]`(request, options)` with the terminal mapping of [sendAsync]`(request, handler)`;
+ * equivalent to `sendAsync(request, options).handleWith(handler)`. On success the handler's value
+ * completes the future and the response is closed; on failure the future is failed with the
+ * unwrapped cause. See [handleWith] for the exact semantics.
+ */
+ public fun sendAsync(
+ request: Request,
+ options: RequestOptions,
+ handler: ResponseHandler,
+ ): CompletableFuture = sendAsync(request, options).handleWith(handler)
+
+ /**
+ * [AsyncHttpClient] SPI conformance: delegates to [sendAsync] so this pipeline can be used as
+ * an async transport. [sendAsync] remains the primary entry point.
+ */
+ override fun executeAsync(request: Request): CompletableFuture = sendAsync(request)
+
+ /**
+ * [AsyncHttpClient] per-call SPI conformance: delegates to [sendAsync] so a caller's [options]
+ * reach the pipeline (and the transport) even when the pipeline is used through the async
+ * transport SPI.
+ */
+ override fun executeAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture = sendAsync(request, options)
+
+ /**
+ * No-op. **Closing the pipeline does NOT close the underlying transport.**
+ *
+ * The pipeline never owns its [httpClient] — it wraps a caller-supplied transport. Per the
+ * SDK's ownership rule a bring-your-own transport must never be closed by the SDK, and an
+ * SDK-managed transport handed to the pipeline stays the responsibility of whoever created it.
+ * Wrapping a pipeline in try-with-resources / `use { }` therefore releases **nothing**: do not
+ * let it create a false sense that the transport's threads, connection pool, or executor were
+ * freed. Close the transport directly (or through whatever created it) when you are done.
+ *
+ * This override exists only to carry that warning; behaviour is identical to the inherited
+ * [AsyncHttpClient] no-op.
+ */
+ override fun close() {
+ // Intentionally a no-op: the pipeline does not own the transport. See KDoc.
+ }
+
public companion object {
/**
* Builds a step-less [AsyncHttpPipeline] that forwards every `sendAsync` directly to
@@ -69,5 +149,23 @@ public class AsyncHttpPipeline internal constructor(
*/
@JvmStatic
public fun of(client: AsyncHttpClient): AsyncHttpPipeline = AsyncHttpPipelineBuilder(client).build()
+
+ /**
+ * Builds an async pipeline over [transport] wired with the async standard resilience
+ * defaults — retry with backoff and instrumentation — via
+ * [AsyncHttpPipelineBuilder.appendStandardResilience]. [scheduler] backs the retry step's
+ * non-blocking delays (the caller owns and shuts it down; the SDK never closes it).
+ *
+ * **This async pipeline does NOT follow HTTP redirects at any layer.** There is no async
+ * redirect step in the SDK, and both reference transports default to
+ * `followRedirects = false`, so a 3xx surfaces to the caller verbatim. Callers who need
+ * redirect following must enable it on the transport itself, or use the synchronous
+ * [HttpPipeline.standard]. See [AsyncHttpPipelineBuilder.appendStandardResilience].
+ */
+ @JvmStatic
+ public fun standard(
+ transport: AsyncHttpClient,
+ scheduler: ScheduledExecutorService,
+ ): AsyncHttpPipeline = AsyncHttpPipelineBuilder(transport).appendStandardResilience(scheduler).build()
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder.kt
index 52e4a0d6..2c336a8b 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder.kt
@@ -8,27 +8,43 @@
package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.client.AsyncHttpClient
-import org.dexpace.sdk.core.instrumentation.ClientLogger
+import org.dexpace.sdk.core.http.pipeline.steps.AsyncThrowOnHttpErrorStep
+import org.dexpace.sdk.core.http.pipeline.steps.DefaultAsyncInstrumentationStep
+import org.dexpace.sdk.core.http.pipeline.steps.DefaultAsyncRetryStep
+import java.util.concurrent.ScheduledExecutorService
/**
* Async counterpart of [HttpPipelineBuilder]. Mirrors the same API verbatim but typed over
* [AsyncHttpStep] / [AsyncHttpClient]. The step-storage and surgical-edit policy lives in the
- * shared [StagedSteps] helper.
+ * shared [StagedSteps] helper — including exclusive pillar occupancy: appending or prepending a
+ * distinct second step to an already-occupied pillar throws [IllegalStateException]; use [replace]
+ * to swap it.
+ *
+ * @constructor Starts a fresh, step-less builder whose terminal transport is [httpClient]. When
+ * [httpClient] happens to be an [AsyncHttpPipeline], this **nests** it as an opaque transport:
+ * the steps added to the outer builder run once, *outside* the nested pipeline's own
+ * retry / auth loops. That is rarely the intent — to instead **flatten** an existing pipeline
+ * (copy its steps into this builder) use the secondary `AsyncHttpPipelineBuilder(AsyncHttpPipeline)`
+ * constructor or [from]. Overload resolution picks the flattening constructor for a
+ * statically-typed [AsyncHttpPipeline] argument, so `AsyncHttpPipelineBuilder(pipeline)` flattens;
+ * reach for this transport constructor only when you genuinely want to nest a pipeline as a
+ * transport.
*/
public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) {
- private val steps: StagedSteps =
- StagedSteps(
- stageOf = AsyncHttpStep::stage,
- onPillarReplaced = { stage, prev, next ->
- LOG.atWarning()
- .event("pipeline.pillar.replaced")
- .field("pipeline.kind", "async")
- .field("stage", stage.name)
- .field("previous", prev::class.simpleName ?: "")
- .field("replacement", next::class.simpleName ?: "")
- .log()
- },
- )
+ private val steps: StagedSteps = StagedSteps(stageOf = AsyncHttpStep::stage)
+
+ /**
+ * Seeds a new builder from [pipeline] by **flattening** it: [pipeline]'s steps become this
+ * builder's steps and its transport becomes this builder's terminal transport (identical to
+ * [from]). Prefer this over the generic [AsyncHttpClient] transport constructor whenever the
+ * argument is an [AsyncHttpPipeline] — the transport constructor would instead **nest** the
+ * pipeline as an opaque transport, running the outer steps outside the inner pipeline's
+ * retry / auth loops. Because [AsyncHttpPipeline] is more specific than [AsyncHttpClient],
+ * `AsyncHttpPipelineBuilder(pipeline)` resolves to this flattening constructor.
+ */
+ public constructor(pipeline: AsyncHttpPipeline) : this(pipeline.httpClient) {
+ steps.reload(pipeline.steps)
+ }
/** Append [step] at the tail of its stage's deque (runs after steps already there). */
public fun append(step: AsyncHttpStep): AsyncHttpPipelineBuilder = apply { steps.append(step) }
@@ -49,6 +65,11 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) {
* matched anchor. A cross-stage insert throws [IllegalArgumentException] rather than
* silently relocating [step] to wherever its own stage falls. Route a different-stage step
* with [append] / [prepend] instead.
+ *
+ * @throws IllegalStateException if [step] would install a *distinct* second step onto an
+ * already-occupied pillar stage — i.e. [T] is itself the pillar occupant and [step] is a
+ * different step declaring that same pillar stage. A pillar admits exactly one step, so the
+ * re-bucket after the insert rejects the duplicate. Use [replace] to swap a pillar's occupant.
*/
public inline fun insertAfter(step: AsyncHttpStep): AsyncHttpPipelineBuilder =
insertAfter(T::class.java, step)
@@ -56,6 +77,9 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) {
/**
* Insert [step] immediately before the first instance of [T] in the pipeline. Same
* within-stage-only constraint as [insertAfter] — see its KDoc.
+ *
+ * @throws IllegalStateException if [step] would install a *distinct* second step onto an
+ * already-occupied pillar stage (see [insertAfter]); use [replace] to swap a pillar instead.
*/
public inline fun insertBefore(step: AsyncHttpStep): AsyncHttpPipelineBuilder =
insertBefore(T::class.java, step)
@@ -96,6 +120,53 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) {
internal fun remove(anchorType: Class): AsyncHttpPipelineBuilder =
apply { steps.removeMatching(anchorType) }
+ /**
+ * Appends an [AsyncThrowOnHttpErrorStep] so a 4xx / 5xx completion is surfaced as the matching
+ * typed [org.dexpace.sdk.core.http.response.exception.HttpException] (via an exceptional future
+ * completion) instead of a plain [org.dexpace.sdk.core.http.response.Response]. Mirrors
+ * [HttpPipelineBuilder.throwOnHttpError]; the step occupies the outermost [Stage.PRE_REDIRECT]
+ * slot so it only maps the terminal response.
+ */
+ public fun throwOnHttpError(): AsyncHttpPipelineBuilder = append(AsyncThrowOnHttpErrorStep())
+
+ /**
+ * Appends the async standard resilience defaults: [DefaultAsyncRetryStep] ([Stage.RETRY]) and
+ * [DefaultAsyncInstrumentationStep] ([Stage.LOGGING]).
+ *
+ * ## The async pipeline does NOT follow redirects
+ * There is **no async redirect step in the SDK**, so — unlike the synchronous
+ * [HttpPipelineBuilder.appendStandardResilience], which installs a `DefaultRedirectStep` — this
+ * preset (and the async pipeline as a whole) follows **no** HTTP redirects at any layer. Both
+ * reference transports also default to `followRedirects = false`, so a 3xx surfaces to the
+ * caller verbatim rather than being followed. If you need redirects followed you must either
+ * enable redirect following on the transport itself (e.g. the OkHttp / JDK-HttpClient
+ * `followRedirects` option) or use the synchronous [HttpPipeline], which ships a redirect step.
+ * Installing a custom async [Stage.REDIRECT] step is also an option.
+ *
+ * [DefaultAsyncRetryStep] requires a [ScheduledExecutorService] on which to schedule its
+ * (non-blocking) backoff delays, so — unlike the synchronous no-arg preset — this method takes
+ * [scheduler]. The scheduler is the caller's to own and shut down; the SDK never closes it.
+ *
+ * Like its synchronous mirror, this is a preset for the **empty** [Stage.RETRY] / [Stage.LOGGING]
+ * slots, not a composable overlay on top of pillars you have already installed. The supported
+ * order is: call this first, then customize an individual pillar with [replace].
+ *
+ * @throws IllegalStateException if the [Stage.RETRY] or [Stage.LOGGING] pillar is already
+ * occupied on this builder — including calling this method twice, or invoking it on a builder
+ * seeded via [from] from a pipeline that already carries them. The check runs before any step
+ * is installed, so a rejected call leaves the builder completely unchanged (all-or-nothing).
+ * Swap a single pillar with [replace] instead.
+ */
+ public fun appendStandardResilience(scheduler: ScheduledExecutorService): AsyncHttpPipelineBuilder {
+ val additions: List =
+ listOf(
+ DefaultAsyncRetryStep(scheduler),
+ DefaultAsyncInstrumentationStep(),
+ )
+ steps.requirePillarsVacant(additions)
+ return appendAll(additions)
+ }
+
/** Builds an immutable [AsyncHttpPipeline]. */
public fun build(): AsyncHttpPipeline {
val ordered = steps.flatten()
@@ -103,11 +174,12 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) {
}
public companion object {
- private val LOG = ClientLogger(AsyncHttpPipelineBuilder::class)
-
- /** Returns a new builder seeded with [pipeline]'s steps and client. */
+ /**
+ * Returns a new builder seeded with [pipeline]'s steps and client — i.e. **flattens**
+ * [pipeline] into a fresh builder. Delegates to the flattening
+ * `AsyncHttpPipelineBuilder(AsyncHttpPipeline)` constructor.
+ */
@JvmStatic
- public fun from(pipeline: AsyncHttpPipeline): AsyncHttpPipelineBuilder =
- AsyncHttpPipelineBuilder(pipeline.httpClient).also { it.steps.reload(pipeline.steps) }
+ public fun from(pipeline: AsyncHttpPipeline): AsyncHttpPipelineBuilder = AsyncHttpPipelineBuilder(pipeline)
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineBridges.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineBridges.kt
index fd22d542..f44af9b4 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineBridges.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineBridges.kt
@@ -12,11 +12,12 @@ package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.client.AsyncHttpClient
import org.dexpace.sdk.core.client.asBlocking
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
+import org.dexpace.sdk.core.util.interruptibleFuture
import java.io.InterruptedIOException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executor
-import java.util.concurrent.atomic.AtomicReference
/**
* Adapts a synchronous [HttpPipeline] into an [AsyncHttpPipeline] by submitting each
@@ -47,68 +48,20 @@ import java.util.concurrent.atomic.AtomicReference
*/
public fun HttpPipeline.toAsync(executor: Executor): AsyncHttpPipeline {
val sync = this
- return AsyncHttpPipeline.of { request -> sendInterruptibly(sync, request, executor) }
-}
-
-/**
- * A [CompletableFuture] that publishes the worker thread running a blocking task so that
- * `cancel(true)` can interrupt it. `cancel(false)` cancels without interrupting — mirroring
- * the `mayInterruptIfRunning` contract that plain `CompletableFuture.cancel` silently ignores.
- *
- * The worker reference is set when the task begins and cleared (in a `finally`) when it ends,
- * so a thread that has returned to its pool is never interrupted for a completed call.
- */
-private class InterruptibleSendFuture : CompletableFuture() {
- private val worker = AtomicReference()
-
- fun bindWorker(thread: Thread) {
- worker.set(thread)
- }
+ val asyncClient =
+ object : AsyncHttpClient {
+ override fun executeAsync(request: Request): CompletableFuture =
+ executeAsync(request, RequestOptions.EMPTY)
- fun unbindWorker() {
- worker.set(null)
- }
-
- override fun cancel(mayInterruptIfRunning: Boolean): Boolean {
- val cancelled = super.cancel(mayInterruptIfRunning)
- if (cancelled && mayInterruptIfRunning) {
- worker.getAndSet(null)?.interrupt()
- }
- return cancelled
- }
-}
-
-/**
- * Submits `pipeline.send(request)` to [executor] on an [InterruptibleSendFuture] so the returned
- * future's `cancel(true)` interrupts the in-flight send. A send not yet started (still queued) or
- * already finished is never interrupted.
- */
-private fun sendInterruptibly(
- pipeline: HttpPipeline,
- request: Request,
- executor: Executor,
-): CompletableFuture {
- val result = InterruptibleSendFuture()
- executor.execute {
- // Don't start if the caller already cancelled while we were queued.
- if (result.isDone) return@execute
- result.bindWorker(Thread.currentThread())
- try {
- // Re-check after publishing the thread: a cancel between the isDone check and the
- // bind would otherwise miss us; if it already happened, skip the send entirely.
- if (result.isDone) return@execute
- val response = pipeline.send(request)
- result.complete(response)
- } catch (t: Throwable) {
- result.completeExceptionally(t)
- } finally {
- // Stop targeting this thread before it returns to the pool, then clear any interrupt
- // the cancel may have set so a pooled thread is handed back clean.
- result.unbindWorker()
- Thread.interrupted()
+ // Thread the caller's per-call options into the wrapped sync `send` so overrides
+ // (timeout, per-call retry budget, tags) survive the sync→async bridge instead of
+ // being dropped by the SPI's options-ignoring default.
+ override fun executeAsync(
+ request: Request,
+ options: RequestOptions,
+ ): CompletableFuture = interruptibleFuture(executor) { sync.send(request, options) }
}
- }
- return result
+ return AsyncHttpPipeline.of(asyncClient)
}
/**
@@ -121,4 +74,7 @@ private fun sendInterruptibly(
* interrupt flag, cancels the in-flight future, and throws an [InterruptedIOException].
*/
public fun AsyncHttpPipeline.toBlocking(): HttpPipeline =
- HttpPipeline.of(AsyncHttpClient { sendAsync(it) }.asBlocking())
+ // AsyncHttpPipeline already implements AsyncHttpClient, and its `executeAsync(request, options)`
+ // override threads options into `sendAsync(request, options)`. Bridging it directly (rather than
+ // through a 1-arg SAM literal) preserves per-call options across the async→sync bridge.
+ HttpPipeline.of(asBlocking())
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineCallState.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineCallState.kt
index 1cba85a0..4aa9d2cc 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineCallState.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineCallState.kt
@@ -8,20 +8,29 @@
package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
/**
* Per-call mutable cursor over an [AsyncHttpPipeline]'s steps array. Async counterpart of
- * [PipelineCallState]: holds the index of the next step to invoke and the in-flight [Request].
+ * [PipelineCallState]: holds the index of the next step to invoke, the in-flight [Request], and
+ * the caller's [RequestOptions].
*
* Cloned via [copy] (exposed to user code through [AsyncPipelineNext.copy]) so async retry /
- * redirect steps can re-drive the downstream chain. Cloning copies the current index — the
- * new state resumes from the same position, advancing independently.
+ * redirect steps can re-drive the downstream chain. Cloning copies the current index and the
+ * [options] — the new state resumes from the same position, advancing independently.
*
* Internal: cloning is reachable only through [AsyncPipelineNext.copy].
*/
internal class AsyncPipelineCallState internal constructor(
val pipeline: AsyncHttpPipeline,
initialRequest: Request,
+ /**
+ * The caller's per-call overrides, carried for the whole call. Read by steps via
+ * [AsyncPipelineNext.options] and threaded into the terminal
+ * `AsyncHttpClient.executeAsync(request, options)`. Immutable and shared unchanged across
+ * [copy] (retry / redirect re-drives).
+ */
+ val options: RequestOptions = RequestOptions.EMPTY,
private var index: Int = 0,
) {
/**
@@ -39,5 +48,5 @@ internal class AsyncPipelineCallState internal constructor(
}
/** Returns an independent state cloned at the current cursor position. */
- fun copy(): AsyncPipelineCallState = AsyncPipelineCallState(pipeline, request, index)
+ fun copy(): AsyncPipelineCallState = AsyncPipelineCallState(pipeline, request, options, index)
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineNext.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineNext.kt
index b109f673..9a7f5840 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineNext.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncPipelineNext.kt
@@ -8,6 +8,7 @@
package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.util.Futures
import java.util.concurrent.CompletableFuture
@@ -23,9 +24,18 @@ import java.util.concurrent.CompletableFuture
* across copies.
*/
public class AsyncPipelineNext internal constructor(private val state: AsyncPipelineCallState) {
+ /**
+ * The caller's per-call [RequestOptions] for this send. Constant across the whole call
+ * (and across [copy] re-drives), so a step can read a per-call timeout, retry budget, or tag
+ * without threading it through the request. The async stage retry step reads
+ * [RequestOptions.maxRetries] here to honour a per-call override.
+ */
+ public val options: RequestOptions get() = state.options
+
/**
* Advances to the next step and invokes it. If no further step exists, dispatches the
- * request to the pipeline's [org.dexpace.sdk.core.client.AsyncHttpClient]. Synchronous
+ * request to the pipeline's [org.dexpace.sdk.core.client.AsyncHttpClient], threading the
+ * caller's [options] into the transport's per-call `executeAsync` overload. Synchronous
* exceptions thrown by the next step's `processAsync` (typically argument-validation
* errors) are wrapped into the returned future via
* [CompletableFuture.failedFuture] so callers receive a uniform async error model.
@@ -34,7 +44,7 @@ public class AsyncPipelineNext internal constructor(private val state: AsyncPipe
val nextStep = state.advance()
return try {
if (nextStep == null) {
- state.pipeline.httpClient.executeAsync(state.request)
+ state.pipeline.httpClient.executeAsync(state.request, state.options)
} else {
nextStep.processAsync(state.request, this)
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlers.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlers.kt
new file mode 100644
index 00000000..dd5b6e27
--- /dev/null
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlers.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026 dexpace and Omar Aljarrah
+ *
+ * Licensed under the MIT License. See LICENSE in the project root.
+ * SPDX-License-Identifier: MIT
+ */
+
+@file:JvmName("AsyncResponseHandlers")
+
+package org.dexpace.sdk.core.http.pipeline
+
+import org.dexpace.sdk.core.http.response.Response
+import org.dexpace.sdk.core.http.response.ResponseHandler
+import org.dexpace.sdk.core.util.Futures
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Terminal operator that maps an async [Response] future to a typed result via [handler] — the
+ * async counterpart of invoking a [ResponseHandler] on a synchronous [Response].
+ *
+ * On successful completion, [handler]'s [ResponseHandler.handle] is applied to the [Response] and
+ * the response is then closed. A [ResponseHandler] that reads the body already closes it (per its
+ * contract), but this operator closes again to guarantee the response is released even for a
+ * handler that reads nothing; [Response.close] is idempotent, so the double close is safe.
+ *
+ * On exceptional completion, the failure is unwrapped through any
+ * [java.util.concurrent.CompletionException] / [java.util.concurrent.ExecutionException] layers
+ * (via [Futures.unwrap]) before the returned future is failed, so a caller that inspects the cause
+ * sees the original transport or step failure rather than a wrapper. Should a [Response] somehow
+ * accompany a failure, it is closed to avoid leaking the body.
+ *
+ * The mapping runs on the thread that completes the upstream future (or the caller's thread if it
+ * is already complete), matching the non-async [CompletableFuture.handle] contract. Reading the
+ * body may therefore block that thread; supply an async transport whose completion runs off the
+ * caller's critical path when that matters.
+ *
+ * @param handler Maps the raw [Response] to a typed [T].
+ * @return A future completing with the handler's result, or failed with the unwrapped cause.
+ */
+public fun CompletableFuture.handleWith(handler: ResponseHandler): CompletableFuture =
+ handle { response, throwable ->
+ if (throwable != null) {
+ // Defensive: a failed completion normally carries no Response, but close one if present.
+ response?.close()
+ throw Futures.unwrap(throwable)
+ }
+ // `use` closes the response once the handler returns (or throws); safe even if the handler
+ // already closed it, since Response.close is idempotent.
+ response.use { handler.handle(it) }
+ }
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipeline.kt
index 64ed7f24..2f0d4f20 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipeline.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipeline.kt
@@ -9,6 +9,7 @@ package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.client.HttpClient
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import java.io.IOException
@@ -22,27 +23,81 @@ import java.io.IOException
* sends never share mutable state inside the pipeline itself. Steps must remain
* thread-safe across concurrent invocations per the pipeline contract.
*
+ * ## Transport SPI conformance
+ * `HttpPipeline` itself implements [HttpClient]: [execute] delegates to [send]. A fully
+ * configured pipeline can therefore stand in anywhere a transport is expected — most usefully as
+ * the client backing a [org.dexpace.sdk.core.pagination.Paginator], so every page request runs
+ * through the same redirect / retry / auth / logging steps. The inherited [close] is a no-op:
+ * the pipeline wraps a caller-supplied transport it does not own, so it never closes it.
+ *
* Construct via [HttpPipelineBuilder]; the constructor is internal.
*/
public class HttpPipeline internal constructor(
/** Transport invoked once the step chain is exhausted; the terminal slot of [Stage.SEND]. */
public val httpClient: HttpClient,
internal val stepArray: Array,
-) {
+) : HttpClient {
/** Read-only list view of the ordered steps. Backed by [stepArray]; for inspection only. */
public val steps: List = stepArray.asList()
/**
- * Runs [request] through the pipeline. Empty pipelines short-circuit directly to
- * [HttpClient.execute] with no allocation.
+ * Runs [request] through the pipeline with no per-call overrides. Equivalent to
+ * `send(request, RequestOptions.EMPTY)`.
+ */
+ @Throws(IOException::class)
+ public fun send(request: Request): Response = send(request, RequestOptions.EMPTY)
+
+ /**
+ * Runs [request] through the pipeline with per-call [options] applied. The options are carried
+ * for the whole call: steps read them via [PipelineNext.options] (the stage retry step honours
+ * a per-call [RequestOptions.maxRetries]), and the terminal dispatch threads them into
+ * `HttpClient.execute(request, options)` so the transport applies a per-call timeout. Empty
+ * pipelines short-circuit directly to the transport's per-call overload with no allocation.
*/
@Throws(IOException::class)
- public fun send(request: Request): Response {
- if (stepArray.isEmpty()) return httpClient.execute(request)
- val state = PipelineCallState(this, request)
+ public fun send(
+ request: Request,
+ options: RequestOptions,
+ ): Response {
+ if (stepArray.isEmpty()) return httpClient.execute(request, options)
+ val state = PipelineCallState(this, request, options)
return PipelineNext(state).process()
}
+ /**
+ * [HttpClient] SPI conformance: delegates to [send] so this pipeline can be used as a
+ * transport. [send] remains the primary entry point.
+ */
+ @Throws(IOException::class)
+ override fun execute(request: Request): Response = send(request)
+
+ /**
+ * [HttpClient] per-call SPI conformance: delegates to [send] so a caller's [options] reach the
+ * pipeline (and the transport) even when the pipeline is used through the transport SPI.
+ */
+ @Throws(IOException::class)
+ override fun execute(
+ request: Request,
+ options: RequestOptions,
+ ): Response = send(request, options)
+
+ /**
+ * No-op. **Closing the pipeline does NOT close the underlying transport.**
+ *
+ * The pipeline never owns its [httpClient] — it wraps a caller-supplied transport. Per the
+ * SDK's ownership rule a bring-your-own transport must never be closed by the SDK, and an
+ * SDK-managed transport handed to the pipeline stays the responsibility of whoever created it.
+ * Wrapping a pipeline in try-with-resources / `use { }` therefore releases **nothing**: do not
+ * let it create a false sense that the transport's threads, connection pool, or executor were
+ * freed. Close the transport directly (or through whatever created it) when you are done.
+ *
+ * This override exists only to carry that warning; behaviour is identical to the inherited
+ * [HttpClient] no-op.
+ */
+ override fun close() {
+ // Intentionally a no-op: the pipeline does not own the transport. See KDoc.
+ }
+
public companion object {
/**
* Builds a step-less [HttpPipeline] that forwards every `send` directly to [client].
@@ -52,5 +107,16 @@ public class HttpPipeline internal constructor(
*/
@JvmStatic
public fun of(client: HttpClient): HttpPipeline = HttpPipelineBuilder(client).build()
+
+ /**
+ * Builds a pipeline over [transport] wired with the SDK's standard resilience pillars —
+ * redirect following, retry with backoff, and instrumentation — via
+ * [HttpPipelineBuilder.appendStandardResilience]. The turnkey preset for "give me a
+ * sensible pipeline over my transport"; start from [HttpPipelineBuilder] when you need to
+ * tune options or add error mapping / auth.
+ */
+ @JvmStatic
+ public fun standard(transport: HttpClient): HttpPipeline =
+ HttpPipelineBuilder(transport).appendStandardResilience().build()
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder.kt
index b91e9ff0..94becf22 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder.kt
@@ -8,12 +8,14 @@
package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.client.HttpClient
-import org.dexpace.sdk.core.instrumentation.ClientLogger
+import org.dexpace.sdk.core.http.pipeline.steps.DefaultInstrumentationStep
+import org.dexpace.sdk.core.http.pipeline.steps.DefaultRedirectStep
+import org.dexpace.sdk.core.http.pipeline.steps.DefaultRetryStep
+import org.dexpace.sdk.core.http.pipeline.steps.ThrowOnHttpErrorStep
/**
* Mutable builder for [HttpPipeline]. Steps are organised by their declared [Stage]:
- * pillar stages hold a single instance (with replace-emits-warning semantics); non-pillar
- * stages hold an ordered deque.
+ * pillar stages hold a single instance; non-pillar stages hold an ordered deque.
*
* Primary API ([append] / [prepend]) routes a step by its declared stage. Surgical API
* ([insertAfter] / [insertBefore] / [replace] / [remove]) operates relative to existing
@@ -22,21 +24,34 @@ import org.dexpace.sdk.core.instrumentation.ClientLogger
* Thread-safety: not thread-safe. Construct on one thread, then call [build] — the resulting
* [HttpPipeline] is immutable.
*
- * Logging: pillar replacement emits a `pipeline.pillar.replaced` warning via SLF4J.
+ * Pillar occupancy: a pillar stage admits exactly one step. Appending or prepending a *distinct*
+ * second step to an already-occupied pillar throws [IllegalStateException]; use [replace] to swap
+ * the existing step. Re-installing the same instance is idempotent.
+ *
+ * @constructor Starts a fresh, step-less builder whose terminal transport is [httpClient]. When
+ * [httpClient] happens to be an [HttpPipeline], this **nests** it as an opaque transport: the
+ * steps added to the outer builder run once, *outside* the nested pipeline's own
+ * redirect / retry / auth loops. That is rarely the intent — to instead **flatten** an existing
+ * pipeline (copy its steps into this builder) use the secondary `HttpPipelineBuilder(HttpPipeline)`
+ * constructor or [from]. Overload resolution picks the flattening constructor for a
+ * statically-typed [HttpPipeline] argument, so `HttpPipelineBuilder(pipeline)` flattens; reach
+ * for this transport constructor only when you genuinely want to nest a pipeline as a transport.
*/
public class HttpPipelineBuilder(private val httpClient: HttpClient) {
- private val steps: StagedSteps =
- StagedSteps(
- stageOf = HttpStep::stage,
- onPillarReplaced = { stage, prev, next ->
- LOG.atWarning()
- .event("pipeline.pillar.replaced")
- .field("stage", stage.name)
- .field("previous", prev::class.simpleName ?: "")
- .field("replacement", next::class.simpleName ?: "")
- .log()
- },
- )
+ private val steps: StagedSteps = StagedSteps(stageOf = HttpStep::stage)
+
+ /**
+ * Seeds a new builder from [pipeline] by **flattening** it: [pipeline]'s steps become this
+ * builder's steps and its transport becomes this builder's terminal transport (identical to
+ * [from]). Prefer this over the generic [HttpClient] transport constructor whenever the argument
+ * is an [HttpPipeline] — the transport constructor would instead **nest** the pipeline as an
+ * opaque transport, running the outer steps outside the inner pipeline's redirect / retry / auth
+ * loops. Because [HttpPipeline] is more specific than [HttpClient], `HttpPipelineBuilder(pipeline)`
+ * resolves to this flattening constructor.
+ */
+ public constructor(pipeline: HttpPipeline) : this(pipeline.httpClient) {
+ steps.reload(pipeline.steps)
+ }
/** Append [step] at the tail of its stage's deque (runs after steps already there). */
public fun append(step: HttpStep): HttpPipelineBuilder = apply { steps.append(step) }
@@ -61,6 +76,11 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) {
* matched anchor. A cross-stage insert throws [IllegalArgumentException] rather than
* silently relocating [step] to wherever its own stage falls (steps are re-bucketed by
* stage on every edit). Route a different-stage step with [append] / [prepend] instead.
+ *
+ * @throws IllegalStateException if [step] would install a *distinct* second step onto an
+ * already-occupied pillar stage — i.e. [T] is itself the pillar occupant and [step] is a
+ * different step declaring that same pillar stage. A pillar admits exactly one step, so the
+ * re-bucket after the insert rejects the duplicate. Use [replace] to swap a pillar's occupant.
*/
public inline fun insertAfter(step: HttpStep): HttpPipelineBuilder =
insertAfter(T::class.java, step)
@@ -68,6 +88,9 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) {
/**
* Insert [step] immediately before the first instance of [T] in the pipeline. Same
* within-stage-only constraint as [insertAfter] — see its KDoc.
+ *
+ * @throws IllegalStateException if [step] would install a *distinct* second step onto an
+ * already-occupied pillar stage (see [insertAfter]); use [replace] to swap a pillar instead.
*/
public inline fun insertBefore(step: HttpStep): HttpPipelineBuilder =
insertBefore(T::class.java, step)
@@ -107,6 +130,45 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) {
internal fun remove(anchorType: Class): HttpPipelineBuilder =
apply { steps.removeMatching(anchorType) }
+ /**
+ * Appends a [ThrowOnHttpErrorStep] so a 4xx / 5xx response is surfaced as the matching typed
+ * [org.dexpace.sdk.core.http.response.exception.HttpException] instead of a plain
+ * [org.dexpace.sdk.core.http.response.Response]. The step occupies the outermost
+ * [Stage.PRE_REDIRECT] slot, so it only maps the terminal response (after redirect following
+ * and retry exhaustion). See [ThrowOnHttpErrorStep].
+ */
+ public fun throwOnHttpError(): HttpPipelineBuilder = append(ThrowOnHttpErrorStep())
+
+ /**
+ * Appends the SDK's standard resilience pillars with their no-arg defaults:
+ * [DefaultRedirectStep] ([Stage.REDIRECT]), [DefaultRetryStep] ([Stage.RETRY]), and
+ * [DefaultInstrumentationStep] ([Stage.LOGGING]). Each lands in its own pillar slot; their
+ * relative run order is fixed by [Stage] — [Stage.REDIRECT], then [Stage.RETRY], then
+ * [Stage.LOGGING] — no matter where in the builder chain this call sits.
+ *
+ * This is a preset for the **empty** REDIRECT / RETRY / LOGGING slots, not a composable overlay
+ * on top of pillars you have already installed. The supported order is: call this first — on a
+ * fresh builder, or before configuring any of those three pillars by hand — then customize an
+ * individual pillar with [replace]. Combine with [throwOnHttpError] for turnkey error mapping on
+ * top of the resilience stack.
+ *
+ * @throws IllegalStateException if any of the [Stage.REDIRECT], [Stage.RETRY], or
+ * [Stage.LOGGING] pillars is already occupied on this builder — including calling this method
+ * twice, or invoking it on a builder seeded via [from] from a pipeline that already carries
+ * them. The check runs before any step is installed, so a rejected call leaves the builder
+ * completely unchanged (all-or-nothing). Swap a single pillar with [replace] instead.
+ */
+ public fun appendStandardResilience(): HttpPipelineBuilder {
+ val additions: List =
+ listOf(
+ DefaultRedirectStep(),
+ DefaultRetryStep(),
+ DefaultInstrumentationStep(),
+ )
+ steps.requirePillarsVacant(additions)
+ return appendAll(additions)
+ }
+
/**
* Builds an immutable [HttpPipeline] in stage order. [Stage.SEND] is reserved for the
* transport and is skipped.
@@ -117,12 +179,12 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) {
}
public companion object {
- private val LOG = ClientLogger(HttpPipelineBuilder::class)
-
- /** Returns a new builder seeded with [pipeline]'s steps and client. */
+ /**
+ * Returns a new builder seeded with [pipeline]'s steps and client — i.e. **flattens**
+ * [pipeline] into a fresh builder. Delegates to the flattening
+ * `HttpPipelineBuilder(HttpPipeline)` constructor.
+ */
@JvmStatic
- public fun from(pipeline: HttpPipeline): HttpPipelineBuilder =
- HttpPipelineBuilder(pipeline.httpClient)
- .also { it.steps.reload(pipeline.steps) }
+ public fun from(pipeline: HttpPipeline): HttpPipelineBuilder = HttpPipelineBuilder(pipeline)
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineCallState.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineCallState.kt
index c748afdb..4ae0d78b 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineCallState.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineCallState.kt
@@ -9,14 +9,15 @@ package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.client.HttpClient
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
/**
* Per-call mutable cursor over a [HttpPipeline]'s steps array. Holds the index of the
- * next step to invoke and the originating [Request].
+ * next step to invoke, the originating [Request], and the caller's [RequestOptions].
*
* Cloned via [copy] (exposed to user code through [PipelineNext.copy]) so retry / redirect
- * steps can re-drive the downstream chain. Cloning copies the current index — the new
- * state resumes from the same position, advancing independently.
+ * steps can re-drive the downstream chain. Cloning copies the current index and the
+ * [options] — the new state resumes from the same position, advancing independently.
*
* Backed by an [Array] of steps for tight iteration (per the pipeline performance
* guardrails); index advance is a single field write.
@@ -26,6 +27,12 @@ import org.dexpace.sdk.core.http.request.Request
internal class PipelineCallState internal constructor(
val pipeline: HttpPipeline,
initialRequest: Request,
+ /**
+ * The caller's per-call overrides, carried for the whole call. Read by steps via
+ * [PipelineNext.options] and threaded into the terminal `HttpClient.execute(request, options)`.
+ * Immutable and shared unchanged across [copy] (retry / redirect re-drives).
+ */
+ val options: RequestOptions = RequestOptions.EMPTY,
private var index: Int = 0,
) {
/**
@@ -49,5 +56,5 @@ internal class PipelineCallState internal constructor(
}
/** Returns an independent state cloned at the current cursor position. */
- fun copy(): PipelineCallState = PipelineCallState(pipeline, request, index)
+ fun copy(): PipelineCallState = PipelineCallState(pipeline, request, options, index)
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineNext.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineNext.kt
index 62c3d4b3..953d5d18 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineNext.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/PipelineNext.kt
@@ -8,6 +8,7 @@
package org.dexpace.sdk.core.http.pipeline
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import java.io.IOException
@@ -19,15 +20,24 @@ import java.io.IOException
* allocation per [copy] — and the underlying steps array is shared across copies.
*/
public class PipelineNext internal constructor(private val state: PipelineCallState) {
+ /**
+ * The caller's per-call [RequestOptions] for this send. Constant across the whole call
+ * (and across [copy] re-drives), so a step can read a per-call timeout, retry budget, or tag
+ * without threading it through the request. The stage retry step reads
+ * [RequestOptions.maxRetries] here to honour a per-call override.
+ */
+ public val options: RequestOptions get() = state.options
+
/**
* Advances to the next step and invokes it. If no further step exists, dispatches the
- * request to the pipeline's [org.dexpace.sdk.core.client.HttpClient].
+ * request to the pipeline's [org.dexpace.sdk.core.client.HttpClient], threading the caller's
+ * [options] into the transport's per-call `execute` overload.
*/
@Throws(IOException::class)
public fun process(): Response {
val nextStep = state.advance()
return if (nextStep == null) {
- state.pipeline.httpClient.execute(state.request)
+ state.pipeline.httpClient.execute(state.request, state.options)
} else {
nextStep.process(state.request, this)
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/Stage.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/Stage.kt
index 0c39dc7a..afe65a08 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/Stage.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/Stage.kt
@@ -25,6 +25,16 @@ package org.dexpace.sdk.core.http.pipeline
*/
@Suppress("unused")
public enum class Stage(public val order: Int, public val isPillar: Boolean) {
+ /**
+ * Outermost stage — runs *outside* the redirect and retry loops. A step here observes only the
+ * single final response, after redirects have been followed and retries exhausted, and never
+ * sees the intermediate 3xx or retried responses that those loops consume internally. Intended
+ * for steps that must react to just the terminal outcome, e.g.
+ * [org.dexpace.sdk.core.http.pipeline.steps.ThrowOnHttpErrorStep] (installed via
+ * [HttpPipelineBuilder.throwOnHttpError]).
+ */
+ PRE_REDIRECT(50, false),
+
// -- Wrapping steps (re-invoke downstream via next.copy()) --
REDIRECT(100, true), // pillar: RedirectStep singleton
POST_REDIRECT(150, false), // runs *inside* the redirect loop, per hop
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/StagedSteps.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/StagedSteps.kt
index 6793d887..9f5c80ce 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/StagedSteps.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/StagedSteps.kt
@@ -21,6 +21,13 @@ package org.dexpace.sdk.core.http.pipeline
* type-erased view onto each step's stage; we don't require a base interface because the two
* step types are siblings with no shared supertype beyond `Any`.
*
+ * ## Pillar occupancy is exclusive
+ * A pillar stage admits exactly one step. Trying to install a *distinct* second step on an
+ * already-occupied pillar (via [append], [prepend], or [reload]) fails fast with
+ * [IllegalStateException] rather than silently overwriting the first — a distinct second pillar
+ * is a configuration mistake, and the caller should swap the existing step through the builder's
+ * `replace()` path instead. Re-installing the *same* instance is idempotent.
+ *
* This type is plain `internal` (not `@PublishedApi`): the builders' surgical-edit helpers go
* through the non-inline [insertRelativeToFirst] / [replaceFirst] / [removeMatching] entry
* points, so `StagedSteps` and its mutable `perStage` / `pillars` storage never escape into the
@@ -29,13 +36,6 @@ package org.dexpace.sdk.core.http.pipeline
internal class StagedSteps(
/** Reads the stage off a step instance. */
private val stageOf: (S) -> Stage,
- /**
- * Called when an `append`/`prepend` of a pillar step would replace an existing one with
- * a different instance. The builder uses this to log a warning through its own logger
- * (SLF4J directly today; could become `ClientLogger` later) without baking logging into
- * this generic helper.
- */
- private val onPillarReplaced: (Stage, S, S) -> Unit = { _, _, _ -> },
) {
/** Non-pillar stages: ordered deque per stage. */
private val perStage: MutableMap> = mutableMapOf()
@@ -80,11 +80,49 @@ internal class StagedSteps(
return out
}
- /** Replaces the contents of this storage with [steps], preserving order. */
+ /**
+ * Replaces the contents of this storage with [steps], preserving order.
+ *
+ * All-or-nothing: the replacement stage/pillar state is assembled into local collections
+ * first, running the same exclusive-pillar validation as [append]. The live `perStage` /
+ * `pillars` fields are reassigned only once the whole rebuild succeeds, so a pillar collision
+ * (a *distinct* second step on an already-filled pillar) throws [IllegalStateException] with
+ * this storage left completely unchanged — never half-reloaded with the post-collision steps
+ * dropped.
+ */
fun reload(steps: List) {
+ val newPerStage = mutableMapOf>()
+ val newPillars = mutableMapOf()
+ for (step in steps) {
+ val stage = stageOf(step)
+ if (stage.isPillar) {
+ installPillarInto(newPillars, step, stage)
+ } else {
+ newPerStage.getOrPut(stage) { ArrayDeque() }.addLast(step)
+ }
+ }
+ // Rebuild succeeded — commit atomically. No throw can occur past this point.
perStage.clear()
+ perStage.putAll(newPerStage)
pillars.clear()
- steps.forEach(::append)
+ pillars.putAll(newPillars)
+ }
+
+ /**
+ * Verifies that none of the pillar stages [additions] would occupy is already filled. The
+ * builders' `appendStandardResilience()` presets call this before installing any step so a
+ * conflict turns into an atomic up-front rejection rather than a partial-mutation-then-throw.
+ *
+ * @throws IllegalStateException if any pillar stage among [additions] already holds a step.
+ */
+ fun requirePillarsVacant(additions: List) {
+ val occupied = additions.map(stageOf).filter { it.isPillar && pillarAt(it) != null }
+ check(occupied.isEmpty()) {
+ "appendStandardResilience() installs the SDK's resilience pillars into empty slots only, " +
+ "but ${occupied.joinToString(", ")} ${if (occupied.size == 1) "is" else "are"} already " +
+ "configured on this builder. Call appendStandardResilience() first (on a fresh builder), " +
+ "then customize an individual pillar via replace()."
+ }
}
/**
@@ -166,11 +204,32 @@ internal class StagedSteps(
private fun installPillar(
step: S,
stage: Stage,
+ ) {
+ installPillarInto(pillars, step, stage)
+ }
+
+ /**
+ * Installs [step] as the pillar for [stage] into [target], enforcing exclusive occupancy.
+ * Shared by [append]/[prepend] (which pass the live [pillars]) and [reload] (which passes a
+ * scratch map so a mid-rebuild collision leaves the live state untouched).
+ */
+ private fun installPillarInto(
+ target: MutableMap,
+ step: S,
+ stage: Stage,
) {
check(stage != Stage.SEND) { "SEND is the terminal client — not a step slot" }
- val existing = pillars[stage]
- if (existing != null && existing !== step) onPillarReplaced(stage, existing, step)
- pillars[stage] = step
+ val existing = target[stage]
+ if (existing != null && existing !== step) {
+ val existingType = existing::class.simpleName ?: ""
+ val newType = step::class.simpleName ?: ""
+ throw IllegalStateException(
+ "Pillar stage $stage already holds a $existingType and admits exactly one step; " +
+ "refusing to add a second $newType. To swap the existing step, call " +
+ "replace<$existingType>(newStep) instead of append/prepend.",
+ )
+ }
+ target[stage] = step
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStep.kt
new file mode 100644
index 00000000..6b41e20f
--- /dev/null
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStep.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026 dexpace and Omar Aljarrah
+ *
+ * Licensed under the MIT License. See LICENSE in the project root.
+ * SPDX-License-Identifier: MIT
+ */
+
+package org.dexpace.sdk.core.http.pipeline.steps
+
+import org.dexpace.sdk.core.http.pipeline.AsyncHttpStep
+import org.dexpace.sdk.core.http.pipeline.AsyncPipelineNext
+import org.dexpace.sdk.core.http.pipeline.Stage
+import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.response.Response
+import org.dexpace.sdk.core.http.response.throwOnError
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Async mirror of [ThrowOnHttpErrorStep]. Maps a 4xx / 5xx completion into a typed
+ * [org.dexpace.sdk.core.http.response.exception.HttpException] on the [CompletableFuture] path,
+ * buffering a bounded (≤ 1 MiB) error body via [Response.throwOnError]; a 2xx (or any non-error
+ * status) completes with the response untouched.
+ *
+ * The mapping runs in the future's success continuation ([CompletableFuture.thenApply]), so an
+ * upstream failure propagates unchanged and the thrown exception completes the returned future
+ * exceptionally — wrapped in a [java.util.concurrent.CompletionException] per `CompletableFuture`
+ * semantics, with the mapped exception as its cause. Callers using `join()` / `get()` observe the
+ * mapped exception through that wrapper (or directly via
+ * [org.dexpace.sdk.core.util.Futures.unwrap]).
+ *
+ * Placement is identical to the synchronous step — [Stage.PRE_REDIRECT], outermost — so it only
+ * evaluates the terminal response after async redirect/retry have run. See [ThrowOnHttpErrorStep]
+ * for the full stage-placement and buffering rationale.
+ *
+ * ## Error-body buffering runs on the completing thread
+ *
+ * The [CompletableFuture.thenApply] mapping runs **synchronously on whatever thread completed the
+ * upstream future** — typically the transport's I/O / completion thread. On a 4xx / 5xx it calls
+ * [Response.throwOnError], which **blocks** while it drains up to 1 MiB of the error body from the
+ * network source into the in-memory copy. The success path never reads a byte, so this cost is
+ * paid only for error responses. Callers that supply a bounded completion executor to a
+ * bring-your-own async transport (e.g. `java.net.http.HttpClient`) should size that executor with
+ * this drain in mind: a burst of large error responses can hold completion threads for the
+ * duration of the buffering, delaying unrelated exchanges that share the pool. There is no
+ * executor hop here — doing so would need machinery the async pipeline does not yet expose.
+ *
+ * ## Thread-safety
+ *
+ * Stateless after construction — safe to share across concurrent requests.
+ */
+public class AsyncThrowOnHttpErrorStep : AsyncHttpStep {
+ override val stage: Stage = Stage.PRE_REDIRECT
+
+ override fun processAsync(
+ request: Request,
+ next: AsyncPipelineNext,
+ ): CompletableFuture =
+ next.processAsync().thenApply { response ->
+ if (!response.status.isError) {
+ response
+ } else {
+ // throwOnError buffers the bounded error body and throws the mapped exception;
+ // thenApply routes the throw into the returned future as an exceptional completion.
+ response.throwOnError()
+ }
+ }
+}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStep.kt
index 49c75ea8..6b33c139 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStep.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStep.kt
@@ -37,6 +37,12 @@ import java.io.IOException
* (no further challenge handling — one retry only). The default implementation returns
* `null`; subclasses override to implement token-refresh / step-up auth flows.
*
+ * The replay is gated on body replayability, exactly like the retry and redirect steps: if the
+ * replacement request carries a body that is not replayable
+ * ([org.dexpace.sdk.core.http.request.RequestBody.isReplayable]), the replay is skipped and the
+ * original 401 is surfaced unchanged. Re-sending a single-use streaming body would trip its
+ * consume-once guard and mask the real 401 as an `IOException`.
+ *
* ## Cross-origin redirects
*
* The AUTH stage runs *inside* the REDIRECT stage, so a redirect re-issue flows back
@@ -61,6 +67,7 @@ public abstract class AuthStep : HttpStep {
final override val stage: Stage = Stage.AUTH
@Throws(IOException::class)
+ @Suppress("ReturnCount") // distinct early exits: no-challenge, non-replayable-body, and the replay
final override fun process(
request: Request,
next: PipelineNext,
@@ -98,6 +105,15 @@ public abstract class AuthStep : HttpStep {
response.close()
throw t
} ?: return response
+
+ // Gate the challenge replay on body replayability, exactly like the retry and redirect
+ // steps. A single-use streaming body cannot be re-sent: replaying it would trip the body's
+ // consume-once guard and surface an IOException("HTTP pipeline failure") that masks the real
+ // 401. When the replacement carries a non-replayable body, skip the replay and return the
+ // original 401 unchanged (do NOT close it — the caller owns and consumes it).
+ val retryBody = retryRequest.body
+ if (retryBody != null && !retryBody.isReplayable()) return response
+
response.close()
return next.copy().process(retryRequest)
}
@@ -117,7 +133,9 @@ public abstract class AuthStep : HttpStep {
*
* Subclasses override to refresh tokens, parse the `WWW-Authenticate` challenge, or
* step up to a different auth scheme. Returning a non-null [Request] triggers a
- * single retry through the downstream chain; the original 401 is closed first.
+ * single retry through the downstream chain, provided its body is replayable — a
+ * non-replayable body skips the retry and surfaces the original 401 instead; the
+ * original 401 is closed first only when the replay actually proceeds.
*
* @param request the request already stamped with the original credential (the one
* that produced the 401). May or may not be reused for the retry.
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncInstrumentationStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncInstrumentationStep.kt
index d83d7cb5..be913bee 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncInstrumentationStep.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncInstrumentationStep.kt
@@ -365,7 +365,7 @@ public class DefaultAsyncInstrumentationStep
val typed = HttpHeaderName.fromString(nameLower)
when {
options.allowedHeaderNames.contains(typed) ->
- ev.field(prefix + nameLower, joinHeaderValues(values))
+ ev.field(prefix + nameLower, joinHeaderValues(typed, values))
options.isRedactedHeaderNamesLoggingEnabled ->
ev.field(prefix + nameLower, "REDACTED")
// else: silently omit
@@ -373,8 +373,20 @@ public class DefaultAsyncInstrumentationStep
}
}
- private fun joinHeaderValues(values: List): String =
- if (values.size == 1) values[0] else values.joinToString(", ")
+ private fun joinHeaderValues(
+ name: HttpHeaderName?,
+ values: List,
+ ): String {
+ // A URL-valued header (Location, Content-Location) can carry credentials in its query
+ // or fragment; redact it through the same UrlRedactor applied to url.full.
+ val rendered =
+ if (name in URL_VALUED_HEADERS) {
+ values.map { UrlRedactor.redactUrlValue(it, options.allowedQueryParamNames) }
+ } else {
+ values
+ }
+ return if (rendered.size == 1) rendered[0] else rendered.joinToString(", ")
+ }
private fun safeRedact(request: Request): String =
try {
@@ -439,5 +451,9 @@ public class DefaultAsyncInstrumentationStep
// Nanoseconds in one millisecond, expressed as Double so the division returns
// millisecond fractions (e.g. 1.234 ms) for high-resolution latency histograms.
private const val NANOS_PER_MILLI_DOUBLE = 1_000_000.0
+
+ // Allowed headers whose value is a URL: redacted through UrlRedactor, not logged raw.
+ private val URL_VALUED_HEADERS: Set =
+ setOf(HttpHeaderName.LOCATION, HttpHeaderName.CONTENT_LOCATION)
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStep.kt
index c9c7f024..1e32f9d5 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStep.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStep.kt
@@ -19,6 +19,7 @@ import org.dexpace.sdk.core.util.Clock
import org.dexpace.sdk.core.util.Futures
import java.io.IOException
import java.io.InterruptedIOException
+import java.net.SocketTimeoutException
import java.time.Duration
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ScheduledExecutorService
@@ -115,7 +116,10 @@ public open class DefaultAsyncRetryStep
next: AsyncPipelineNext,
): CompletableFuture {
val result = CompletableFuture()
- val driver = RetryDriver(next, support.isRetrySafe(request), result)
+ // Per-call RequestOptions.maxRetries override for THIS call. See
+ // RetryPolicySupport.effectiveMaxRetries for the null/negative fallback rule.
+ val maxRetries = support.effectiveMaxRetries(next.options)
+ val driver = RetryDriver(next, support.isRetrySafe(request), maxRetries, result)
driver.drive()
return result
}
@@ -129,6 +133,7 @@ public open class DefaultAsyncRetryStep
private inner class RetryDriver(
private val next: AsyncPipelineNext,
private val retrySafe: Boolean,
+ private val maxRetries: Int,
private val result: CompletableFuture,
) {
private var tryCount = 0
@@ -176,8 +181,10 @@ public open class DefaultAsyncRetryStep
// Atomic exit-check: clearing `pumping` under the same lock as drive()'s
// re-arm means a concurrent drive() either set `rearm` before this check
// (so the pump continues) or starts a fresh pump after it (because it sees
- // `pumping` cleared) — the wakeup is never lost.
- if (!rearm) {
+ // `pumping` cleared) — the wakeup is never lost. Also stop if the returned
+ // future is already done (the caller cancelled or completed it), so a
+ // cancelled call launches no further network attempts.
+ if (!rearm || result.isDone) {
pumping = false
return
}
@@ -219,11 +226,17 @@ public open class DefaultAsyncRetryStep
}
private fun onSuccess(response: Response) {
+ // The caller already completed or cancelled the returned future while this attempt
+ // was in flight — close the now-orphaned response and stop; do not retry.
+ if (result.isDone) {
+ closeQuietly(response)
+ return
+ }
val delay: Duration =
try {
val retry =
retrySafe &&
- tryCount < support.options.maxRetries &&
+ tryCount < maxRetries &&
shouldRetryResponse(response)
if (!retry) {
// Not retrying: hand the still-open response to the caller, who then
@@ -260,7 +273,11 @@ public open class DefaultAsyncRetryStep
scheduleNext(delay)
}
+ @Suppress("ReturnCount") // distinct early exits: cancelled, Error, interrupt, no-retry, predicate-threw
private fun onFailure(rawError: Throwable) {
+ // The caller already completed or cancelled the returned future; drop the failure
+ // rather than scheduling more attempts.
+ if (result.isDone) return
val error = Futures.unwrap(rawError)
// Errors (OOM, StackOverflow, …) are unrecoverable — never retry, never log.
// Surface as-is, matching DefaultRetryStep (which rethrows an Error before
@@ -270,12 +287,15 @@ public open class DefaultAsyncRetryStep
return
}
val exception = error as Exception
- // Interrupts are never retryable, per the SDK-wide cancellation convention.
- // Restore the interrupt flag (the completing thread's catch may have cleared it),
- // normalise a bare InterruptedException to InterruptedIOException, and surface
- // terminally with the prior-attempt trail attached — mirroring DefaultRetryStep's
- // pre-classification interrupt carve-out.
- if (exception is InterruptedIOException || exception is InterruptedException) {
+ // Interrupts are never retryable, per the SDK-wide cancellation convention: restore
+ // the interrupt flag, normalise a bare InterruptedException to InterruptedIOException,
+ // and surface terminally with the prior-attempt trail attached — mirroring
+ // DefaultRetryStep. SocketTimeoutException extends InterruptedIOException but is a
+ // retryable read timeout, not a cancellation, so it is excluded here and left to the
+ // normal retry classification below.
+ if ((exception is InterruptedIOException && exception !is SocketTimeoutException) ||
+ exception is InterruptedException
+ ) {
Thread.currentThread().interrupt()
failTerminally(support.asInterruptedIo(exception))
return
@@ -284,7 +304,7 @@ public open class DefaultAsyncRetryStep
try {
val retry =
retrySafe &&
- tryCount < support.options.maxRetries &&
+ tryCount < maxRetries &&
shouldRetryException(exception)
if (!retry) {
failTerminally(exception)
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultInstrumentationStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultInstrumentationStep.kt
index 2b7e84b1..4e38773d 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultInstrumentationStep.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultInstrumentationStep.kt
@@ -269,7 +269,7 @@ public class DefaultInstrumentationStep
val typed = HttpHeaderName.fromString(nameLower)
when {
options.allowedHeaderNames.contains(typed) ->
- ev.field(prefix + nameLower, joinHeaderValues(values))
+ ev.field(prefix + nameLower, joinHeaderValues(typed, values))
options.isRedactedHeaderNamesLoggingEnabled ->
ev.field(prefix + nameLower, "REDACTED")
// else: silently omit
@@ -277,8 +277,21 @@ public class DefaultInstrumentationStep
}
}
- private fun joinHeaderValues(values: List): String =
- if (values.size == 1) values[0] else values.joinToString(", ")
+ private fun joinHeaderValues(
+ name: HttpHeaderName?,
+ values: List,
+ ): String {
+ // A URL-valued header (Location, Content-Location) can carry credentials in its query
+ // or fragment (OAuth code, pre-signed signature, implicit-flow token). Redact its value
+ // through the same UrlRedactor applied to url.full instead of logging it verbatim.
+ val rendered =
+ if (name in URL_VALUED_HEADERS) {
+ values.map { UrlRedactor.redactUrlValue(it, options.allowedQueryParamNames) }
+ } else {
+ values
+ }
+ return if (rendered.size == 1) rendered[0] else rendered.joinToString(", ")
+ }
private fun safeRedact(request: Request): String =
try {
@@ -343,5 +356,10 @@ public class DefaultInstrumentationStep
// Nanoseconds in one millisecond, expressed as Double so the division returns
// millisecond fractions (e.g. 1.234 ms) for high-resolution latency histograms.
private const val NANOS_PER_MILLI_DOUBLE = 1_000_000.0
+
+ // Allowed headers whose value is a URL: their query/fragment can carry credentials, so
+ // they are redacted through UrlRedactor rather than logged verbatim.
+ private val URL_VALUED_HEADERS: Set =
+ setOf(HttpHeaderName.LOCATION, HttpHeaderName.CONTENT_LOCATION)
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultRetryStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultRetryStep.kt
index e62e2ad3..da99b39b 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultRetryStep.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultRetryStep.kt
@@ -18,6 +18,7 @@ import org.dexpace.sdk.core.pipeline.step.retry.RetrySettings
import org.dexpace.sdk.core.util.Clock
import java.io.IOException
import java.io.InterruptedIOException
+import java.net.SocketTimeoutException
import java.time.Duration
/**
@@ -182,6 +183,11 @@ public open class DefaultRetryStep
// Lazily allocated on first failure so the success path never pays for the list.
var suppressed: MutableList? = null
+ // Per-call RequestOptions.maxRetries override for THIS call, resolved once (the options
+ // are constant across retry re-drives). See RetryPolicySupport.effectiveMaxRetries for
+ // the null/negative fallback rule.
+ val maxRetries = support.effectiveMaxRetries(next.options)
+
// A request whose body is single-use and whose method is non-idempotent cannot be
// safely re-sent: the second writeTo would trip the body's consume-once guard. When
// that holds, the loop runs exactly one attempt and never retries — mirroring the
@@ -199,7 +205,7 @@ public open class DefaultRetryStep
val response = attemptResult.getOrThrow()
val shouldRetry =
retrySafe &&
- tryCount < support.options.maxRetries &&
+ tryCount < maxRetries &&
decideRetryResponse(response, tryCount, suppressed, retrySequenceStartNanos)
if (shouldRetry) {
tryCount++
@@ -221,13 +227,17 @@ public open class DefaultRetryStep
// downstream blocking call that did not wrap the interrupt) are handled here:
// the flag is restored and cancellation is surfaced as InterruptedIOException so
// the @Throws(IOException) contract holds, with prior failures attached.
- if (exception is InterruptedIOException || exception is InterruptedException) {
+ // SocketTimeoutException extends InterruptedIOException but is a read timeout, not a
+ // cancellation, so it is excluded here and left to the normal retry classification.
+ if ((exception is InterruptedIOException && exception !is SocketTimeoutException) ||
+ exception is InterruptedException
+ ) {
Thread.currentThread().interrupt()
val ioe = support.asInterruptedIo(exception)
suppressed?.forEach(ioe::addSuppressed)
throw ioe
}
- if (retrySafe && tryCount < support.options.maxRetries) {
+ if (retrySafe && tryCount < maxRetries) {
val accumulator = suppressed ?: ArrayList().also { suppressed = it }
if (decideRetryException(exception, tryCount, accumulator, retrySequenceStartNanos)) {
tryCount++
@@ -238,18 +248,14 @@ public open class DefaultRetryStep
// Terminal failure path — every prior attempt's exception is attached as
// suppressed on the rethrown exception so callers see the full trail.
suppressed?.forEach(exception::addSuppressed)
- // The method is declared `@Throws(IOException::class)`, which becomes
- // `throws IOException` in the generated bytecode. Java callers
- // `catch (IOException e)` expect ONLY `IOException` here — surfacing an
- // `IllegalStateException` (from a misbehaving `shouldRetry` predicate, say)
- // would silently bypass that catch. Wrap any non-IO exception so the
- // checked-exception contract is honored.
- //
- // Non-`IOException` causes are wrapped in `IOException("HTTP pipeline failure", cause)`
- // to honor the `@Throws(IOException::class)` contract; the original cause is
- // attached via the standard Java chained-exception mechanism so callers can
- // retrieve it via `Throwable.getCause()`.
- throw if (exception is IOException) {
+ // An IOException propagates as-is. An unchecked exception (RuntimeException) also
+ // propagates as-is: Java does not require it to be declared, so it does not violate
+ // the `@Throws(IOException::class)` contract, and preserving it keeps a typed
+ // HttpException from an inner throwOnHttpError pipeline (used as this pipeline's
+ // transport) catchable by the caller instead of being buried under an IOException.
+ // Only a genuinely-checked non-IO exception is wrapped so the checked-exception
+ // contract still holds; the original is retained as the cause for getCause().
+ throw if (exception is IOException || exception is RuntimeException) {
exception
} else {
IOException("HTTP pipeline failure", exception)
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions.kt
index 6e7b7ee7..e29f5194 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptions.kt
@@ -71,6 +71,68 @@ public class HttpInstrumentationOptions
public val meter: Meter = NoopMeter,
public val bodyPreviewMaxBytes: Int = DEFAULT_BODY_PREVIEW_MAX_BYTES,
) {
+ /**
+ * Returns a new [Builder] pre-filled with every field from this instance. Mutating the
+ * builder never affects this [HttpInstrumentationOptions].
+ */
+ public fun newBuilder(): Builder =
+ Builder().apply {
+ logLevel(this@HttpInstrumentationOptions.logLevel)
+ allowedHeaderNames(this@HttpInstrumentationOptions.allowedHeaderNames)
+ allowedQueryParamNames(this@HttpInstrumentationOptions.allowedQueryParamNames)
+ isRedactedHeaderNamesLoggingEnabled(this@HttpInstrumentationOptions.isRedactedHeaderNamesLoggingEnabled)
+ tracer(this@HttpInstrumentationOptions.tracer)
+ meter(this@HttpInstrumentationOptions.meter)
+ bodyPreviewMaxBytes(this@HttpInstrumentationOptions.bodyPreviewMaxBytes)
+ }
+
+ /**
+ * Builder for [HttpInstrumentationOptions]. All fields default to the same values as
+ * the primary constructor. Obtain via [HttpInstrumentationOptions.builder].
+ */
+ public class Builder : org.dexpace.sdk.core.generics.Builder {
+ private var logLevel: HttpLogLevel = HttpLogLevel.NONE
+ private var allowedHeaderNames: Set = DEFAULT_ALLOWED_HEADERS
+ private var allowedQueryParamNames: Set = UrlRedactor.DEFAULT_ALLOWED
+ private var isRedactedHeaderNamesLoggingEnabled: Boolean = true
+ private var tracer: Tracer = NoopTracer
+ private var meter: Meter = NoopMeter
+ private var bodyPreviewMaxBytes: Int = DEFAULT_BODY_PREVIEW_MAX_BYTES
+
+ /** Sets the log level applied to every request/response pair. */
+ public fun logLevel(value: HttpLogLevel): Builder = apply { logLevel = value }
+
+ /** Sets the allow-listed header names emitted without redaction. */
+ public fun allowedHeaderNames(value: Set): Builder = apply { allowedHeaderNames = value }
+
+ /** Sets the allow-listed query parameter names emitted without redaction. */
+ public fun allowedQueryParamNames(value: Set): Builder = apply { allowedQueryParamNames = value }
+
+ /** When `false`, redacted headers are omitted entirely rather than emitted as `"REDACTED"`. */
+ public fun isRedactedHeaderNamesLoggingEnabled(value: Boolean): Builder =
+ apply { isRedactedHeaderNamesLoggingEnabled = value }
+
+ /** Sets the tracer used for span creation. */
+ public fun tracer(value: Tracer): Builder = apply { tracer = value }
+
+ /** Sets the meter used for emitting metrics. */
+ public fun meter(value: Meter): Builder = apply { meter = value }
+
+ /** Sets the maximum byte count buffered for body previews under [HttpLogLevel.BODY_AND_HEADERS]. */
+ public fun bodyPreviewMaxBytes(value: Int): Builder = apply { bodyPreviewMaxBytes = value }
+
+ override fun build(): HttpInstrumentationOptions =
+ HttpInstrumentationOptions(
+ logLevel = logLevel,
+ allowedHeaderNames = allowedHeaderNames,
+ allowedQueryParamNames = allowedQueryParamNames,
+ isRedactedHeaderNamesLoggingEnabled = isRedactedHeaderNamesLoggingEnabled,
+ tracer = tracer,
+ meter = meter,
+ bodyPreviewMaxBytes = bodyPreviewMaxBytes,
+ )
+ }
+
public companion object {
// ~20 headers that are safe to surface by default — diagnostic, not credential.
@JvmField
@@ -102,5 +164,9 @@ public class HttpInstrumentationOptions
)
public const val DEFAULT_BODY_PREVIEW_MAX_BYTES: Int = 8 * 1024
+
+ /** Returns a fresh [Builder] with all fields at their defaults. */
+ @JvmStatic
+ public fun builder(): Builder = Builder()
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions.kt
index a776b8c3..a981dcce 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions.kt
@@ -49,16 +49,86 @@ public class HttpRedirectOptions
* redirect following entirely.
*/
public val maxHops: Int = 3,
- allowedMethods: EnumSet = EnumSet.of(Method.GET, Method.HEAD),
+ allowedMethods: Set = EnumSet.of(Method.GET, Method.HEAD),
public val locationHeader: HttpHeaderName = HttpHeaderName.LOCATION,
public val follow303: Boolean = false,
public val allowSchemeDowngrade: Boolean = false,
public val shouldRedirect: HttpRedirectPredicate? = null,
) {
/**
- * Methods that follow a redirect. Stored as a defensive copy, exposed as a read-only [Set],
- * so a later mutation of the caller-supplied [EnumSet] cannot change the configured policy
- * after construction.
+ * Methods that follow a redirect. Stored as a single defensive copy (a fresh [EnumSet]),
+ * exposed as a read-only [Set], so a later mutation of the caller-supplied set cannot
+ * change the configured policy after construction.
*/
- public val allowedMethods: Set = EnumSet.copyOf(allowedMethods)
+ public val allowedMethods: Set = allowedMethods.toCollection(EnumSet.noneOf(Method::class.java))
+
+ /**
+ * Returns a new [Builder] pre-filled with every field from this instance. Mutating the
+ * builder never affects this [HttpRedirectOptions].
+ */
+ public fun newBuilder(): Builder =
+ Builder().apply {
+ maxHops(this@HttpRedirectOptions.maxHops)
+ allowedMethods(this@HttpRedirectOptions.allowedMethods)
+ locationHeader(this@HttpRedirectOptions.locationHeader)
+ follow303(this@HttpRedirectOptions.follow303)
+ allowSchemeDowngrade(this@HttpRedirectOptions.allowSchemeDowngrade)
+ shouldRedirect(this@HttpRedirectOptions.shouldRedirect)
+ }
+
+ /**
+ * Builder for [HttpRedirectOptions]. All fields default to the same values as the primary
+ * constructor. Obtain via [HttpRedirectOptions.builder].
+ */
+ public class Builder : org.dexpace.sdk.core.generics.Builder {
+ private var maxHops: Int = 3
+ private var allowedMethods: Set = EnumSet.of(Method.GET, Method.HEAD)
+ private var locationHeader: HttpHeaderName = HttpHeaderName.LOCATION
+ private var follow303: Boolean = false
+ private var allowSchemeDowngrade: Boolean = false
+ private var shouldRedirect: HttpRedirectPredicate? = null
+
+ /** Sets the maximum number of redirect hops followed after the initial request. */
+ public fun maxHops(value: Int): Builder = apply { maxHops = value }
+
+ /** Sets the methods that follow a redirect. A defensive copy is taken at construction. */
+ public fun allowedMethods(value: Set): Builder = apply { allowedMethods = value }
+
+ /** Sets the response header from which the redirect location is read. */
+ public fun locationHeader(value: HttpHeaderName): Builder = apply { locationHeader = value }
+
+ /**
+ * When `true`, 303 responses are followed (re-issued as `GET`, body dropped).
+ * Defaults to `false`.
+ */
+ public fun follow303(value: Boolean): Builder = apply { follow303 = value }
+
+ /**
+ * When `true`, HTTPS → HTTP scheme downgrades are permitted (with a warning log).
+ * Defaults to `false` (downgrades throw).
+ */
+ public fun allowSchemeDowngrade(value: Boolean): Builder = apply { allowSchemeDowngrade = value }
+
+ /**
+ * Sets a custom predicate that fully overrides the built-in redirect decision logic.
+ * Pass `null` to restore the default predicate.
+ */
+ public fun shouldRedirect(value: HttpRedirectPredicate?): Builder = apply { shouldRedirect = value }
+
+ override fun build(): HttpRedirectOptions =
+ HttpRedirectOptions(
+ maxHops = maxHops,
+ allowedMethods = allowedMethods,
+ locationHeader = locationHeader,
+ follow303 = follow303,
+ allowSchemeDowngrade = allowSchemeDowngrade,
+ shouldRedirect = shouldRedirect,
+ )
+ }
+
+ public companion object {
+ /** Returns a fresh [Builder] with all fields at their defaults. */
+ @JvmStatic
+ public fun builder(): Builder = Builder()
+ }
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions.kt
index ae83a7ba..740281db 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptions.kt
@@ -7,6 +7,7 @@
package org.dexpace.sdk.core.http.pipeline.steps
+import org.dexpace.sdk.core.config.Configuration
import org.dexpace.sdk.core.http.common.HttpHeaderName
import org.dexpace.sdk.core.pipeline.step.retry.RetrySettings
import org.dexpace.sdk.core.util.RetryUtils
@@ -96,6 +97,80 @@ public class HttpRetryOptions
delayFromCondition = delayFromCondition,
)
+ /**
+ * Returns a new [Builder] pre-filled with every field from this instance. Mutating
+ * the builder never affects this [HttpRetryOptions].
+ */
+ public fun newBuilder(): Builder =
+ Builder().apply {
+ maxRetries(this@HttpRetryOptions.maxRetries)
+ baseDelay(this@HttpRetryOptions.baseDelay)
+ maxDelay(this@HttpRetryOptions.maxDelay)
+ fixedDelay(this@HttpRetryOptions.fixedDelay)
+ retryAfterHeaders(this@HttpRetryOptions.retryAfterHeaders)
+ shouldRetryCondition(this@HttpRetryOptions.shouldRetryCondition)
+ shouldRetryException(this@HttpRetryOptions.shouldRetryException)
+ delayFromCondition(this@HttpRetryOptions.delayFromCondition)
+ }
+
+ /**
+ * Builder for [HttpRetryOptions]. All fields default to the same values as the
+ * primary constructor. Obtain via [HttpRetryOptions.builder].
+ */
+ public class Builder : org.dexpace.sdk.core.generics.Builder {
+ private var maxRetries: Int = DefaultRetryStep.DEFAULT_MAX_RETRIES
+ private var baseDelay: Duration = RetrySettings.DEFAULT_INITIAL_DELAY
+ private var maxDelay: Duration = RetrySettings.DEFAULT_MAX_DELAY
+ private var fixedDelay: Duration? = null
+ private var retryAfterHeaders: List = DEFAULT_RETRY_AFTER_HEADERS
+ private var shouldRetryCondition: HttpRetryConditionPredicate =
+ HttpRetryConditionPredicate(::defaultShouldRetryResponse)
+ private var shouldRetryException: HttpRetryConditionPredicate =
+ HttpRetryConditionPredicate(::defaultShouldRetryException)
+ private var delayFromCondition: HttpRetryDelayProvider = HttpRetryDelayProvider { null }
+
+ /** Sets the maximum number of retry attempts. */
+ public fun maxRetries(value: Int): Builder = apply { maxRetries = value }
+
+ /** Sets the base exponential-backoff delay. */
+ public fun baseDelay(value: Duration): Builder = apply { baseDelay = value }
+
+ /** Sets the maximum backoff delay cap. */
+ public fun maxDelay(value: Duration): Builder = apply { maxDelay = value }
+
+ /**
+ * Sets a fixed per-retry delay, disabling exponential backoff. Pass `null` to
+ * re-enable backoff (the default).
+ */
+ public fun fixedDelay(value: Duration?): Builder = apply { fixedDelay = value }
+
+ /** Sets the ordered list of `Retry-After`-style headers to parse. */
+ public fun retryAfterHeaders(value: List): Builder = apply { retryAfterHeaders = value }
+
+ /** Sets the response-side retry predicate. */
+ public fun shouldRetryCondition(value: HttpRetryConditionPredicate): Builder =
+ apply { shouldRetryCondition = value }
+
+ /** Sets the exception-side retry predicate. */
+ public fun shouldRetryException(value: HttpRetryConditionPredicate): Builder =
+ apply { shouldRetryException = value }
+
+ /** Sets the custom delay provider (return `null` to fall through to backoff). */
+ public fun delayFromCondition(value: HttpRetryDelayProvider): Builder = apply { delayFromCondition = value }
+
+ override fun build(): HttpRetryOptions =
+ HttpRetryOptions(
+ maxRetries = maxRetries,
+ baseDelay = baseDelay,
+ maxDelay = maxDelay,
+ fixedDelay = fixedDelay,
+ retryAfterHeaders = retryAfterHeaders,
+ shouldRetryCondition = shouldRetryCondition,
+ shouldRetryException = shouldRetryException,
+ delayFromCondition = delayFromCondition,
+ )
+ }
+
public companion object {
// The default retry count is the canonical SDK budget, kept in one place on
// DefaultRetryStep (initial send + DEFAULT_MAX_RETRIES == RetrySettings.DEFAULT_MAX_ATTEMPTS).
@@ -113,6 +188,25 @@ public class HttpRetryOptions
HttpHeaderName.X_MS_RETRY_AFTER_MS,
)
+ /** Returns a fresh [Builder] with all fields at their defaults. */
+ @JvmStatic
+ public fun builder(): Builder = Builder()
+
+ /**
+ * Reads retry configuration from [source] and returns an [HttpRetryOptions] that
+ * reflects the values found. Only [Configuration.MAX_RETRY_ATTEMPTS] is consulted;
+ * every other field keeps its default value.
+ *
+ * The lookup goes through the full [Configuration] layering — explicit override →
+ * environment variable → normalized system property → SDK default. Parsing
+ * failures (non-integer or missing key) fall back to [DefaultRetryStep.DEFAULT_MAX_RETRIES].
+ */
+ @JvmStatic
+ public fun fromConfiguration(source: Configuration): HttpRetryOptions =
+ Builder()
+ .maxRetries(source.getInt(Configuration.MAX_RETRY_ATTEMPTS, DefaultRetryStep.DEFAULT_MAX_RETRIES))
+ .build()
+
/**
* Returns an [HttpRetryOptions] that uses a flat [delay] between every retry — no
* exponential growth, no jitter. [baseDelay] and [maxDelay] are forced to zero
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupport.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupport.kt
index dd1e2b1e..cc7c3ced 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupport.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupport.kt
@@ -9,6 +9,7 @@ package org.dexpace.sdk.core.http.pipeline.steps
import org.dexpace.sdk.core.http.request.Method
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.instrumentation.ClientLogger
import org.dexpace.sdk.core.pipeline.step.retry.BackoffCalculator
import org.dexpace.sdk.core.pipeline.step.retry.RetrySettings
@@ -48,6 +49,20 @@ internal class RetryPolicySupport(
.totalTimeout(Duration.ZERO)
.build()
+ /**
+ * Resolves the retry budget for a single call from its [RequestOptions]. A per-call
+ * [RequestOptions.maxRetries] override wins only when it is non-negative; a `null` (no
+ * override) or a negative override falls back to the configured, already-clamped
+ * [options]`.maxRetries`.
+ *
+ * The negative-override fallback is deliberate: it mirrors [clampOptions]' handling of a
+ * negative *configured* `maxRetries`, so a negative per-call value means "use the configured
+ * default", NOT "0 retries" (which is what `maxRetries = 0` requests). Callers should read this
+ * once per call — the options are constant across retry re-drives.
+ */
+ fun effectiveMaxRetries(callOptions: RequestOptions): Int =
+ callOptions.maxRetries?.takeIf { it >= 0 } ?: options.maxRetries
+
/**
* Returns `true` when [request] may be re-sent: a body-less request only when its method is
* idempotent ([IDEMPOTENT_METHODS]); a body-bearing request only when its body is replayable.
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStep.kt
new file mode 100644
index 00000000..82b0a7d3
--- /dev/null
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStep.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026 dexpace and Omar Aljarrah
+ *
+ * Licensed under the MIT License. See LICENSE in the project root.
+ * SPDX-License-Identifier: MIT
+ */
+
+package org.dexpace.sdk.core.http.pipeline.steps
+
+import org.dexpace.sdk.core.http.pipeline.HttpStep
+import org.dexpace.sdk.core.http.pipeline.PipelineNext
+import org.dexpace.sdk.core.http.pipeline.Stage
+import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.response.Response
+import org.dexpace.sdk.core.http.response.throwOnError
+import java.io.IOException
+
+/**
+ * Turns a non-successful HTTP response into the matching typed
+ * [org.dexpace.sdk.core.http.response.exception.HttpException]. On a 4xx / 5xx response this step
+ * buffers a bounded (≤ 1 MiB) copy of the error body — via [Response.throwOnError], which owns the
+ * cap — and throws the mapped exception; on any other status (2xx, and any 1xx / 3xx that reaches
+ * the step because no redirect was followed) it returns the response **untouched** — the body is
+ * neither read, consumed, nor closed on that path.
+ *
+ * ## Stage placement — [Stage.PRE_REDIRECT] (outermost)
+ *
+ * This step must evaluate only the **final** response handed back to the caller — after redirect
+ * following and after retry has exhausted. It therefore occupies [Stage.PRE_REDIRECT], the
+ * outermost non-pillar stage, which runs *outside* both the redirect ([Stage.REDIRECT]) and retry
+ * ([Stage.RETRY]) loops. Because a lower stage number wraps the higher ones, this step's
+ * [process] sees the response **last**: it never observes an intermediate 3xx redirect hop (the
+ * redirect step, running inside, has already followed it) and never preempts a retry of a
+ * retryable 5xx (the retry step, running inside, has already exhausted its attempts). Placing the
+ * step inside those loops would break both invariants — it would throw on the first 503 before the
+ * retry step could recover it, and on a 3xx before the redirect step could follow it.
+ *
+ * ## Error-body buffering
+ *
+ * The heavy lifting is delegated to [Response.throwOnError], which drains up to 1 MiB of the error
+ * body into an in-memory copy, releases the transport connection, and throws the mapped exception
+ * carrying that bounded body — so the exception stays readable after the step returns while a
+ * rogue multi-megabyte error payload cannot exhaust memory. The step only guards the call with the
+ * response's own `status.isError` range check (4xx / 5xx) so a non-error status (which would trip
+ * `throwOnError`'s / the factory's success-range check) is passed through untouched.
+ *
+ * ## Thread-safety
+ *
+ * Stateless after construction — safe to share across concurrent requests.
+ */
+public class ThrowOnHttpErrorStep : HttpStep {
+ override val stage: Stage = Stage.PRE_REDIRECT
+
+ @Throws(IOException::class)
+ override fun process(
+ request: Request,
+ next: PipelineNext,
+ ): Response {
+ val response = next.process()
+ // Success (and any non-error status) is returned verbatim: the body must not be read,
+ // consumed, or closed on this path. Read the Status directly — no Int round-trip.
+ if (!response.status.isError) return response
+ // Error status: throwOnError buffers the bounded error body and throws the mapped
+ // exception — it never returns here since the status is non-successful.
+ return response.throwOnError()
+ }
+}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/MultipartBody.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/MultipartBody.kt
index 7ae32106..8b9296fd 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/MultipartBody.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/MultipartBody.kt
@@ -171,9 +171,12 @@ public class MultipartBody private constructor(
/**
* A value field named [name] whose [value] is encoded through [serde]'s serializer at
- * construction time. The resulting bytes are replayable; the part carries the supplied
- * [mediaType] (the serialized bytes are opaque to `sdk-core`, so the caller names the
- * type).
+ * construction time. The resulting bytes are replayable.
+ *
+ * When [mediaType] is `null` the part's `Content-Type` defaults to [Serde.contentType]
+ * (typically `application/json`) — mirroring `RequestBody.create(value, serde)`. Pass an
+ * explicit [mediaType] to override it (the serialized bytes are opaque to `sdk-core`, so
+ * the caller may name a different type).
*/
@JvmStatic
@JvmOverloads
@@ -184,8 +187,9 @@ public class MultipartBody private constructor(
mediaType: MediaType? = null,
): Part {
val bytes = serde.serializer.serializeToByteArray(value)
- val body = RequestBody.create(bytes, mediaType)
- return Part(buildHeaders(name, null, mediaType), body)
+ val resolvedMediaType = mediaType ?: serde.contentType()
+ val body = RequestBody.create(bytes, resolvedMediaType)
+ return Part(buildHeaders(name, null, resolvedMediaType), body)
}
private fun buildHeaders(
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Request.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Request.kt
index 7bf3834e..61acc223 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Request.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Request.kt
@@ -11,7 +11,9 @@ import org.dexpace.sdk.core.generics.Builder
import org.dexpace.sdk.core.generics.checkRequired
import org.dexpace.sdk.core.http.common.Headers
import org.dexpace.sdk.core.http.common.HttpHeaderName
+import org.dexpace.sdk.core.http.common.MediaType
import java.net.MalformedURLException
+import java.net.URI
import java.net.URL
/**
@@ -134,22 +136,46 @@ public data class Request private constructor(
}
/**
- * Sets the URL.
+ * Sets the URL from a string.
*
* @param url The URL as a string.
* @return This builder.
- * @throws MalformedURLException If [url] is invalid.
+ * @throws IllegalArgumentException If [url] is not a valid URL.
*/
- @Throws(MalformedURLException::class)
public fun url(url: String): RequestBuilder =
apply {
- this.url = URL(url)
+ try {
+ this.url = URL(url)
+ } catch (e: MalformedURLException) {
+ throw IllegalArgumentException("Invalid URL: $url", e)
+ }
+ }
+
+ /**
+ * Sets the URL from a [URI]. Converts via [URI.toURL].
+ *
+ * @param uri The URI to convert and set.
+ * @return This builder.
+ * @throws IllegalArgumentException If [uri] cannot be converted to a URL.
+ */
+ public fun url(uri: URI): RequestBuilder =
+ apply {
+ try {
+ this.url = uri.toURL()
+ } catch (e: MalformedURLException) {
+ // A missing protocol handler surfaces as MalformedURLException...
+ throw IllegalArgumentException("Invalid URL: $uri", e)
+ } catch (e: IllegalArgumentException) {
+ // ...while a non-absolute URI throws an unwrapped IllegalArgumentException;
+ // both are re-thrown with the same contextual message.
+ throw IllegalArgumentException("Invalid URL: $uri", e)
+ }
}
/**
* Sets the URL.
*
- * @param url The URL as an [URL] object.
+ * @param url The URL as a [URL] object.
* @return This builder.
*/
public fun url(url: URL): RequestBuilder =
@@ -217,6 +243,82 @@ public data class Request private constructor(
headersBuilder.set(name, values)
}
+ /**
+ * Adds a header using a typed [HttpHeaderName] and a single value.
+ *
+ * @param name The typed header name.
+ * @param value The header value.
+ * @return This builder.
+ */
+ public fun addHeader(
+ name: HttpHeaderName,
+ value: String,
+ ): RequestBuilder =
+ apply {
+ headersBuilder.add(name, value)
+ }
+
+ /**
+ * Adds a header using a typed [HttpHeaderName] and a list of values.
+ *
+ * @param name The typed header name.
+ * @param values The header values.
+ * @return This builder.
+ */
+ public fun addHeader(
+ name: HttpHeaderName,
+ values: List,
+ ): RequestBuilder =
+ apply {
+ headersBuilder.add(name, values)
+ }
+
+ /**
+ * Convenience overload: adds a header whose value is the wire form of [value].
+ * Delegates to [MediaType.toString].
+ *
+ * @param name The typed header name (e.g. [HttpHeaderName.CONTENT_TYPE]).
+ * @param value The media type whose [MediaType.toString] form is used as the header value.
+ * @return This builder.
+ */
+ public fun addHeader(
+ name: HttpHeaderName,
+ value: MediaType,
+ ): RequestBuilder =
+ apply {
+ headersBuilder.add(name, value.toString())
+ }
+
+ /**
+ * Sets a header using a typed [HttpHeaderName] and a single value, replacing any existing values.
+ *
+ * @param name The typed header name.
+ * @param value The header value.
+ * @return This builder.
+ */
+ public fun setHeader(
+ name: HttpHeaderName,
+ value: String,
+ ): RequestBuilder =
+ apply {
+ headersBuilder.set(name, value)
+ }
+
+ /**
+ * Sets a header using a typed [HttpHeaderName] and a list of values, replacing any existing values.
+ *
+ * @param name The typed header name.
+ * @param values The header values.
+ * @return This builder.
+ */
+ public fun setHeader(
+ name: HttpHeaderName,
+ values: List,
+ ): RequestBuilder =
+ apply {
+ headersBuilder.set(name, values)
+ }
+
/**
* Sets a complete Headers instance, replacing all other headers
*
@@ -250,9 +352,78 @@ public data class Request private constructor(
headersBuilder.remove(name)
}
+ /**
+ * Shared implementation for the verb convenience methods: sets [method] and [body]
+ * together (bodyless verbs pass `null`).
+ */
+ private fun verb(
+ method: Method,
+ body: RequestBody?,
+ ): RequestBuilder =
+ apply {
+ this.method = method
+ this.body = body
+ }
+
+ /**
+ * Sets the method to [Method.GET] and clears any previously-set body. Equivalent to
+ * `method(Method.GET).body(null)`.
+ *
+ * @return This builder.
+ */
+ public fun get(): RequestBuilder = verb(Method.GET, null)
+
+ /**
+ * Sets the method to [Method.POST] and the body to [body].
+ *
+ * @param body The request body.
+ * @return This builder.
+ */
+ public fun post(body: RequestBody): RequestBuilder = verb(Method.POST, body)
+
+ /**
+ * Sets the method to [Method.PUT] and the body to [body].
+ *
+ * @param body The request body.
+ * @return This builder.
+ */
+ public fun put(body: RequestBody): RequestBuilder = verb(Method.PUT, body)
+
+ /**
+ * Sets the method to [Method.DELETE] and clears any previously-set body. Equivalent to
+ * `method(Method.DELETE).body(null)`.
+ *
+ * DELETE-with-body remains available explicitly via `.method(Method.DELETE).body(body)`.
+ *
+ * @return This builder.
+ */
+ public fun delete(): RequestBuilder = verb(Method.DELETE, null)
+
+ /**
+ * Sets the method to [Method.HEAD] and clears any previously-set body. Equivalent to
+ * `method(Method.HEAD).body(null)`.
+ *
+ * @return This builder.
+ */
+ public fun head(): RequestBuilder = verb(Method.HEAD, null)
+
+ /**
+ * Sets the method to [Method.PATCH] and the body to [body].
+ *
+ * @param body The request body.
+ * @return This builder.
+ */
+ public fun patch(body: RequestBody): RequestBuilder = verb(Method.PATCH, body)
+
/**
* Builds the [Request].
*
+ * When no method was set the method defaults to [Method.GET] — but only if no body is
+ * present. A body with no method is a forgotten verb (e.g. a dropped `.post(body)` in a
+ * refactor), so it is reported as a missing `method` rather than silently defaulting to
+ * GET and then failing the body check below with the misleading "GET must not carry a
+ * request body".
+ *
* A body set on a method that forbids one ([Method.permitsRequestBody] is `false` —
* `GET`, `HEAD`, `TRACE`, `CONNECT`) is rejected here rather than passed to a transport:
* the two reference transports disagree on the case (OkHttp throws, the JDK builder drops
@@ -261,12 +432,13 @@ public data class Request private constructor(
* one of these methods, clear the body first with `body(null)`.
*
* @return The built request.
- * @throws IllegalStateException If a required field is missing.
+ * @throws IllegalStateException If a required field is missing — the `url`, or the `method`
+ * when a body is present.
* @throws IllegalArgumentException If a body is set on a method that forbids one
* ([Method.GET], [Method.HEAD], [Method.TRACE], or [Method.CONNECT]).
*/
override fun build(): Request {
- val resolvedMethod = checkRequired("method", method)
+ val resolvedMethod = method ?: if (body == null) Method.GET else checkRequired("method", method)
val resolvedUrl = checkRequired("url", url)
require(body == null || resolvedMethod.permitsRequestBody) {
"$resolvedMethod must not carry a request body; remove the body or use a " +
@@ -290,5 +462,77 @@ public data class Request private constructor(
*/
@JvmStatic
public fun builder(): RequestBuilder = RequestBuilder()
+
+ /**
+ * Builds a GET request for [url]. Equivalent to
+ * `builder().url(url).get().build()`.
+ *
+ * @param url The target URL string; throws [IllegalArgumentException] if invalid.
+ * @return A [Request] with method [Method.GET] and no body.
+ */
+ @JvmStatic
+ public fun get(url: String): Request = builder().url(url).get().build()
+
+ /**
+ * Builds a POST request for [url] with [body]. Equivalent to
+ * `builder().url(url).post(body).build()`.
+ *
+ * @param url The target URL string; throws [IllegalArgumentException] if invalid.
+ * @param body The request body.
+ * @return A [Request] with method [Method.POST] and the given [body].
+ */
+ @JvmStatic
+ public fun post(
+ url: String,
+ body: RequestBody,
+ ): Request = builder().url(url).post(body).build()
+
+ /**
+ * Builds a PUT request for [url] with [body]. Equivalent to
+ * `builder().url(url).put(body).build()`.
+ *
+ * @param url The target URL string; throws [IllegalArgumentException] if invalid.
+ * @param body The request body.
+ * @return A [Request] with method [Method.PUT] and the given [body].
+ */
+ @JvmStatic
+ public fun put(
+ url: String,
+ body: RequestBody,
+ ): Request = builder().url(url).put(body).build()
+
+ /**
+ * Builds a PATCH request for [url] with [body]. Equivalent to
+ * `builder().url(url).patch(body).build()`.
+ *
+ * @param url The target URL string; throws [IllegalArgumentException] if invalid.
+ * @param body The request body.
+ * @return A [Request] with method [Method.PATCH] and the given [body].
+ */
+ @JvmStatic
+ public fun patch(
+ url: String,
+ body: RequestBody,
+ ): Request = builder().url(url).patch(body).build()
+
+ /**
+ * Builds a DELETE request for [url]. Equivalent to
+ * `builder().url(url).delete().build()`.
+ *
+ * @param url The target URL string; throws [IllegalArgumentException] if invalid.
+ * @return A [Request] with method [Method.DELETE] and no body.
+ */
+ @JvmStatic
+ public fun delete(url: String): Request = builder().url(url).delete().build()
+
+ /**
+ * Builds a HEAD request for [url]. Equivalent to
+ * `builder().url(url).head().build()`.
+ *
+ * @param url The target URL string; throws [IllegalArgumentException] if invalid.
+ * @return A [Request] with method [Method.HEAD] and no body.
+ */
+ @JvmStatic
+ public fun head(url: String): Request = builder().url(url).head().build()
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt
index 864d7f21..868d5722 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt
@@ -14,6 +14,7 @@ import org.dexpace.sdk.core.io.BufferedSink
import org.dexpace.sdk.core.io.BufferedSource
import org.dexpace.sdk.core.io.Io
import org.dexpace.sdk.core.io.IoProvider
+import org.dexpace.sdk.core.serde.Serde
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
@@ -188,6 +189,27 @@ public abstract class RequestBody {
count: Long = -1,
): RequestBody = FileRequestBody(file, mediaType, position, count)
+ /**
+ * Creates a replayable request body by serializing [value] via [serde].
+ *
+ * The [mediaType] defaults to [Serde.contentType] (typically `application/json`). Pass an
+ * explicit [mediaType] to override the content type for a given call without changing the
+ * serde's default (e.g. when a single serde instance is shared across multiple endpoints
+ * that agree on encoding but declare different `Content-Type` values).
+ *
+ * @param value The object to serialize.
+ * @param serde The serde whose [Serde.serializer] encodes [value] to wire bytes.
+ * @param mediaType The body's media type; defaults to [Serde.contentType].
+ * @return A replayable body whose bytes are the [serde]-encoded form of [value].
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun create(
+ value: Any,
+ serde: Serde,
+ mediaType: MediaType = serde.contentType(),
+ ): RequestBody = create(serde.serializer.serializeToByteArray(value), mediaType)
+
/**
* Creates a replayable `application/x-www-form-urlencoded` body from [formData].
* The encoded byte array is computed once at construction and reused for every
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt
new file mode 100644
index 00000000..9ac2a3e2
--- /dev/null
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2026 dexpace and Omar Aljarrah
+ *
+ * Licensed under the MIT License. See LICENSE in the project root.
+ * SPDX-License-Identifier: MIT
+ */
+
+package org.dexpace.sdk.core.http.request
+
+import java.time.Duration
+import org.dexpace.sdk.core.generics.Builder as SdkBuilder
+
+/**
+ * Per-request overrides threaded alongside a [Request] through the pipeline and into the transport.
+ *
+ * A [Request] models only what goes on the wire (method, URL, headers, body). Operational knobs —
+ * how long to wait, how many times to retry, opaque tags for observers — are not part of the wire
+ * form, so they live here instead. Threading a `RequestOptions` through
+ * `HttpPipeline.send(request, options)` lets a single slow or sensitive endpoint ask for a longer
+ * timeout or a smaller retry budget without standing up a whole second transport + pipeline.
+ *
+ * Every field is nullable / empty by default, and the default carries the semantics
+ * "**use the pipeline / transport default**":
+ * - [timeout] `null` → the transport's configured timeout applies unchanged.
+ * - [maxRetries] `null` → the retry step's configured `HttpRetryOptions.maxRetries` applies.
+ * - [tags] empty → no per-call tags. Tags are carried through the call and readable by steps
+ * (via `PipelineNext.options`); the SDK itself attaches no behaviour to them.
+ *
+ * [EMPTY] is the canonical "override nothing" instance and is what `send(request)` threads when no
+ * options are supplied, so the no-options path behaves exactly as before.
+ *
+ * ## Thread-safety
+ * Instances are immutable and safe to share across threads. [tags] is a read-only defensive copy
+ * taken at [Builder.build] time.
+ *
+ * @property timeout Per-call timeout override, or `null` to keep the transport default.
+ * **Scope differs by transport.** On the OkHttp transport it maps to OkHttp's call timeout, which
+ * spans the whole exchange including response-body reads. On the JDK (`java.net.http`) transport
+ * it maps to `HttpRequest.Builder.timeout`, which bounds only the wait until the response headers
+ * arrive — reads of the returned body stream are *not* bounded by it, so a stalled body can still
+ * hang; use a read/idle timeout on the underlying client if you need to bound body reads there.
+ * The timeout is also applied **per transport attempt**, not as an overall deadline: under a
+ * retrying pipeline a single `send` may spend up to roughly `timeout × (1 + maxRetries)` plus
+ * backoff before failing. Pair a per-call `timeout` with `maxRetries(0)` when you need a hard
+ * single-attempt bound.
+ * @property tags Opaque per-call tags, keyed by string. Read-only; never `null`, may be empty.
+ * @property maxRetries Per-call retry-count override, or `null` to keep the retry step's configured
+ * budget. `0` disables retries for this call.
+ */
+public class RequestOptions private constructor(
+ public val timeout: Duration?,
+ public val tags: Map,
+ public val maxRetries: Int?,
+) {
+ /** Returns a new [Builder] initialized with this instance's fields. */
+ public fun newBuilder(): Builder = Builder(this)
+
+ /** Value equality over [timeout], [tags], and [maxRetries]. */
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is RequestOptions) return false
+ return timeout == other.timeout &&
+ tags == other.tags &&
+ maxRetries == other.maxRetries
+ }
+
+ /** Hash consistent with [equals]. */
+ override fun hashCode(): Int {
+ var result = timeout?.hashCode() ?: 0
+ result = HASH_MULTIPLIER * result + tags.hashCode()
+ result = HASH_MULTIPLIER * result + (maxRetries ?: 0)
+ return result
+ }
+
+ override fun toString(): String = "RequestOptions(timeout=$timeout, tags=$tags, maxRetries=$maxRetries)"
+
+ /**
+ * Mutable builder for [RequestOptions]. Implements the generic [SdkBuilder] contract so it can
+ * be driven by builder-folding helpers.
+ */
+ public class Builder : SdkBuilder {
+ private var timeout: Duration? = null
+ private val tags: MutableMap = LinkedHashMap()
+ private var maxRetries: Int? = null
+
+ /** Creates a fresh empty builder. */
+ public constructor()
+
+ /** Creates a builder initialized with the data from [options]. */
+ public constructor(options: RequestOptions) {
+ this.timeout = options.timeout
+ this.tags.putAll(options.tags)
+ this.maxRetries = options.maxRetries
+ }
+
+ /**
+ * Sets the per-call timeout override, or clears it when [timeout] is `null` (fall back to
+ * the transport default).
+ *
+ * A non-`null` [timeout] must be strictly positive. Zero and negative durations are rejected
+ * here rather than reaching a transport, where they carry inconsistent meanings — okio's call
+ * timeout treats `0` as "no timeout" (silently unbounding the call), while the JDK request
+ * builder throws on a non-positive duration.
+ *
+ * @throws IllegalArgumentException if [timeout] is zero or negative.
+ */
+ public fun timeout(timeout: Duration?): Builder =
+ apply {
+ require(timeout == null || (!timeout.isZero && !timeout.isNegative)) {
+ "timeout must be positive (or null to use the transport default), was $timeout"
+ }
+ this.timeout = timeout
+ }
+
+ /** Adds or replaces the tag stored under [key]. Insertion order is preserved. */
+ public fun tag(
+ key: String,
+ value: Any,
+ ): Builder =
+ apply {
+ this.tags[key] = value
+ }
+
+ /**
+ * Sets the per-call retry-count override, or clears it when [maxRetries] is `null` (fall
+ * back to the retry step's configured budget). `0` disables retries for the call.
+ */
+ public fun maxRetries(maxRetries: Int?): Builder =
+ apply {
+ this.maxRetries = maxRetries
+ }
+
+ /** Builds an immutable [RequestOptions]; [tags] is snapshotted into a read-only copy. */
+ override fun build(): RequestOptions =
+ RequestOptions(timeout, if (tags.isEmpty()) emptyMap() else LinkedHashMap(tags), maxRetries)
+ }
+
+ public companion object {
+ private const val HASH_MULTIPLIER = 31
+
+ /** Java-friendly entry point matching the `RequestOptions.builder()` idiom. */
+ @JvmStatic
+ public fun builder(): Builder = Builder()
+
+ /**
+ * The canonical "override nothing" instance. Threaded by `send(request)` when no options
+ * are supplied, so the no-options path keeps the transport / pipeline defaults.
+ */
+ @JvmField
+ public val EMPTY: RequestOptions = Builder().build()
+ }
+}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Response.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Response.kt
index a9082627..c21f1a3a 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Response.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Response.kt
@@ -47,6 +47,24 @@ public data class Response private constructor(
val headers: Headers,
val body: ResponseBody?,
) : Closeable {
+ /** True when [status] is in the 1xx informational range. */
+ public val isInformational: Boolean get() = status.isInformational
+
+ /** True when [status] is in the 2xx success range. */
+ public val isSuccessful: Boolean get() = status.isSuccess
+
+ /** True when [status] is in the 3xx redirect range. */
+ public val isRedirect: Boolean get() = status.isRedirect
+
+ /** True when [status] is in the 4xx client-error range. */
+ public val isClientError: Boolean get() = status.isClientError
+
+ /** True when [status] is in the 5xx server-error range. */
+ public val isServerError: Boolean get() = status.isServerError
+
+ /** True when [status] is in the 4xx–5xx error range. */
+ public val isError: Boolean get() = status.isError
+
/**
* Returns a new [ResponseBuilder] initialized with this response's data.
*
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseBody.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseBody.kt
index 6aa7fb0f..01ca8c78 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseBody.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseBody.kt
@@ -11,6 +11,8 @@ import org.dexpace.sdk.core.http.common.MediaType
import org.dexpace.sdk.core.io.BufferedSource
import java.io.Closeable
import java.io.IOException
+import java.nio.charset.Charset
+import java.util.concurrent.atomic.AtomicBoolean
/**
* Represents the body of an HTTP response.
@@ -61,6 +63,38 @@ public abstract class ResponseBody : Closeable {
*/
public abstract fun source(): BufferedSource
+ /**
+ * Reads the entire body and returns it decoded as a [String]. The body is closed in a
+ * `finally` block whether or not the read succeeds.
+ *
+ * @param charset The character set to use for decoding. Defaults to the charset declared in
+ * the body's Content-Type media type, falling back to UTF-8 when none is declared or it is
+ * unrecognized.
+ * @throws IOException If an I/O error occurs while reading.
+ */
+ @JvmOverloads
+ @Throws(IOException::class)
+ public fun string(charset: Charset = mediaType()?.charset ?: Charsets.UTF_8): String =
+ try {
+ source().readString(charset)
+ } finally {
+ close()
+ }
+
+ /**
+ * Reads the entire body and returns it as a [ByteArray]. The body is closed in a
+ * `finally` block whether or not the read succeeds.
+ *
+ * @throws IOException If an I/O error occurs while reading.
+ */
+ @Throws(IOException::class)
+ public fun bytes(): ByteArray =
+ try {
+ source().readByteArray()
+ } finally {
+ close()
+ }
+
/**
* Closes the response body and releases any transport resources.
*
@@ -97,6 +131,8 @@ public abstract class ResponseBody : Closeable {
contentLength: Long = -1L,
): ResponseBody =
object : ResponseBody() {
+ private val closed = AtomicBoolean(false)
+
override fun mediaType(): MediaType? = mediaType
override fun contentLength(): Long = contentLength
@@ -104,7 +140,11 @@ public abstract class ResponseBody : Closeable {
override fun source(): BufferedSource = source
override fun close() {
- source.close()
+ // Honor the idempotent-close contract even if the wrapped source throws on a
+ // second close: only the first call reaches through to the source.
+ if (closed.compareAndSet(false, true)) {
+ source.close()
+ }
}
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensions.kt
new file mode 100644
index 00000000..669006e4
--- /dev/null
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensions.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2026 dexpace and Omar Aljarrah
+ *
+ * Licensed under the MIT License. See LICENSE in the project root.
+ * SPDX-License-Identifier: MIT
+ */
+
+@file:JvmName("ResponseExtensions")
+
+package org.dexpace.sdk.core.http.response
+
+import org.dexpace.sdk.core.http.common.MediaType
+import org.dexpace.sdk.core.http.response.exception.HttpException
+import org.dexpace.sdk.core.http.response.exception.HttpExceptionFactory
+import org.dexpace.sdk.core.instrumentation.ClientLogger
+import org.dexpace.sdk.core.io.BufferedSource
+import org.dexpace.sdk.core.io.Io
+import org.dexpace.sdk.core.serde.Deserializer
+import org.dexpace.sdk.core.serde.Serde
+import org.dexpace.sdk.core.serde.SerdeException
+import org.dexpace.sdk.core.serde.TypeRef
+
+private val logger = ClientLogger("org.dexpace.sdk.core.http.response.ResponseExtensions")
+
+/**
+ * Maximum bytes buffered from an error body by [throwOnError] and the pipeline's error-mapping
+ * recovery so the two share one bound. Bodies exceeding this limit are truncated.
+ */
+internal const val MAX_BUFFERED_ERROR_BODY_BYTES: Long = 1L * 1024 * 1024
+
+/**
+ * Returns this response unchanged when its status is not an error ([Status.isError] is `false` —
+ * any 1xx, 2xx, or 3xx); otherwise buffers the body into an in-memory [ResponseBody], wraps it in
+ * a new [Response], and throws the matching [HttpException] produced by
+ * [HttpExceptionFactory.fromResponse].
+ *
+ * Only 4xx/5xx responses are mapped, so a non-error, non-2xx response (e.g. a `304 Not Modified`
+ * from a conditional request, or a `3xx` that no redirect step followed) is returned with its body
+ * intact rather than having it consumed and an exception raised.
+ *
+ * The error body is buffered in memory (up to 1 MiB; bytes beyond that limit are dropped)
+ * so it is readable from the thrown exception after the original transport connection is
+ * released. The buffered copy is replayable: [HttpException.bodyAs], `body.string()`, and
+ * `bodySnapshot()` each read the full buffered bytes independently.
+ *
+ * @throws HttpException for any 4xx or 5xx response.
+ */
+public fun Response.throwOnError(): Response {
+ if (!status.isError) return this
+ throw HttpExceptionFactory.fromResponse(bufferErrorBody(this, MAX_BUFFERED_ERROR_BODY_BYTES))
+}
+
+/**
+ * Returns a copy of [response] whose body has been drained (up to [maxBytes]; further bytes are
+ * dropped) into memory and made **replayable** — every [ResponseBody.source] call reads the full
+ * buffered bytes again — so the body survives the original transport connection being released and
+ * can be read more than once (e.g. decode via [HttpException.bodyAs], then log a snapshot). Returns
+ * [response] unchanged when it has no body.
+ *
+ * The in-memory buffer is acquired inside the original body's `use` block, so a failure to resolve
+ * an [Io] provider still closes the original body rather than leaking the connection. Shared by
+ * [throwOnError] and the pipeline's error-mapping recovery step so the two cannot drift.
+ */
+internal fun bufferErrorBody(
+ response: Response,
+ maxBytes: Long,
+): Response {
+ val original = response.body ?: return response
+ val mediaType = original.mediaType()
+ val bytes =
+ original.use { b ->
+ val buffer = Io.provider.buffer()
+ val src = b.source()
+ var remaining = maxBytes
+ while (remaining > 0L) {
+ val n = src.read(buffer, remaining)
+ if (n < 0L) break
+ remaining -= n
+ }
+ buffer.readByteArray()
+ }
+ val replayable =
+ object : ResponseBody() {
+ override fun mediaType(): MediaType? = mediaType
+
+ override fun contentLength(): Long = bytes.size.toLong()
+
+ override fun source(): BufferedSource = Io.provider.source(bytes)
+
+ override fun close() {
+ // In-memory copy; no transport resource to release.
+ }
+ }
+ return response.newBuilder().body(replayable).build()
+}
+
+/**
+ * Attempts to decode the exception's error [body] using [deserializer] as a [type]-typed
+ * value. Returns `null` when [HttpException.body] is `null` or when the deserializer throws.
+ *
+ * The body is closed in a `finally` block whether decoding succeeds or fails. Exceptions
+ * thrown by the deserializer are logged at DEBUG and swallowed rather than propagated; this
+ * function never throws.
+ *
+ * @param deserializer The deserializer to apply.
+ * @param type The target class token required for runtime type dispatch.
+ * @return The decoded payload, or `null` if the body is absent or decoding fails.
+ */
+public fun HttpException.bodyAs(
+ deserializer: Deserializer,
+ type: Class,
+): T? {
+ val b = body ?: return null
+ return try {
+ deserializer.deserialize(b.source().inputStream(), type)
+ } catch (e: Exception) {
+ logger.atVerbose()
+ .event("bodyAs.deserialize.failure")
+ .cause(e)
+ .log("HttpException.bodyAs: deserializer threw; returning null")
+ null
+ } finally {
+ b.close()
+ }
+}
+
+/**
+ * Decodes the response body as a [type]-typed value using [serde]'s deserializer, then closes
+ * the response in a `finally` block.
+ *
+ * @param serde The [Serde] bundle whose [Serde.deserializer] is used for decoding.
+ * @param type The target class token required for runtime type dispatch.
+ * @return The decoded value.
+ * @throws SerdeException if the response body is `null`.
+ */
+public fun Response.deserialize(
+ serde: Serde,
+ type: Class,
+): T {
+ val b = body ?: throw SerdeException("Response body is null — cannot deserialize into $type")
+ return try {
+ serde.deserializer.deserialize(b.source().inputStream(), type)
+ } finally {
+ close()
+ }
+}
+
+/**
+ * Decodes the response body as the parametric type captured by [ref] using [serde]'s deserializer,
+ * then closes the response in a `finally` block.
+ *
+ * This is the entry point for generic targets (`List`, `Map`) whose element
+ * types a bare [Class] token would erase, and the parametric overload Java callers reach for (build
+ * a `TypeRef` as an anonymous subclass, e.g. `new TypeRef>() {}`). It requires a [serde]
+ * whose [Serde.deserializer] resolves generics (e.g. `JacksonSerde`); a format-agnostic deserializer
+ * throws [org.dexpace.sdk.core.serde.SerdeException] for a genuinely parametric [ref].
+ *
+ * @param serde The [Serde] bundle whose [Serde.deserializer] is used for decoding.
+ * @param ref The captured generic target type.
+ * @return The decoded value.
+ * @throws IllegalStateException if the response body is `null`.
+ */
+public fun Response.deserialize(
+ serde: Serde,
+ ref: TypeRef,
+): T {
+ val b = body ?: throw SerdeException("Response body is null — cannot deserialize into ${ref.type}")
+ return try {
+ serde.deserializer.deserialize(b.source().inputStream(), ref)
+ } finally {
+ close()
+ }
+}
+
+/**
+ * Decodes the response body as a reified type [T] using [serde]'s deserializer, then closes
+ * the response.
+ *
+ * This is Kotlin-only sugar over [deserialize]; Java callers should use the explicit `Class` or
+ * `TypeRef` overload. It captures `T` as a [TypeRef], so a parametric target such as
+ * `response.deserialize>(serde)` reifies its full type and decodes correctly instead of
+ * erasing to the raw class (which would heap-pollute). This routes through the `TypeRef` overload,
+ * so it requires a [serde] that resolves generics for a parametric `T`.
+ *
+ * @param serde The [Serde] bundle whose deserializer is used for decoding.
+ * @return The decoded value.
+ * @throws SerdeException if the response body is `null`.
+ */
+public inline fun Response.deserialize(serde: Serde): T = deserialize(serde, object : TypeRef() {})
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseHandler.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseHandler.kt
index 2e897ec0..f973a327 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseHandler.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseHandler.kt
@@ -61,8 +61,10 @@ public fun interface ResponseHandler {
public companion object {
/**
- * A handler that reads the entire response body as a UTF-8 [String] and closes the
- * response. A bodyless response (e.g. `204 No Content`) yields an empty string.
+ * A handler that reads the entire response body as a [String] and closes the response.
+ * The body is decoded using the charset declared in its Content-Type media type, falling
+ * back to UTF-8 when none is declared or it is unrecognized. A bodyless response (e.g.
+ * `204 No Content`) yields an empty string.
*
* **Unbounded.** This reads the whole body into a single in-memory [String] with no size
* cap, so it is an unbounded-allocation vector against a hostile or misbehaving server.
@@ -76,7 +78,7 @@ public fun interface ResponseHandler {
ResponseHandler { response ->
response.use {
val body = it.body ?: return@use ""
- body.source().readString(StandardCharsets.UTF_8)
+ body.source().readString(body.mediaType()?.charset ?: StandardCharsets.UTF_8)
}
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Status.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Status.kt
index 7841c1e7..ffe43d00 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Status.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/Status.kt
@@ -28,9 +28,24 @@ public class Status private constructor(
public val code: Int,
public val statusName: String?,
) {
+ /** True when the code is in the 1xx informational range. */
+ public val isInformational: Boolean get() = code in 100..199
+
/** True when the code is in the 2xx success range. */
public val isSuccess: Boolean get() = code in 200..299
+ /** True when the code is in the 3xx redirect range. */
+ public val isRedirect: Boolean get() = code in 300..399
+
+ /** True when the code is in the 4xx client-error range. */
+ public val isClientError: Boolean get() = code in 400..499
+
+ /** True when the code is in the 5xx server-error range. */
+ public val isServerError: Boolean get() = code in 500..599
+
+ /** True when the code is in the 4xx–5xx error range. */
+ public val isError: Boolean get() = code in 400..599
+
override fun equals(other: Any?): Boolean = this === other || (other is Status && other.code == code)
override fun hashCode(): Int = code
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactory.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactory.kt
index 4c912bd4..e37081ff 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactory.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactory.kt
@@ -68,9 +68,12 @@ public object HttpExceptionFactory {
/**
* Whether [code] is in the 400..599 error range this factory maps to an [HttpException].
*
- * This is the single source of truth for the error-status boundary; callers that want to
- * pre-check before mapping (e.g. a pipeline step that only acts on error responses) should
- * use this rather than re-declaring the range.
+ * A plain integer range check for raw-`Int` callers (e.g. [fromResponse] / [fromResponseOrNull]
+ * pre-checking a wire code before mapping) — it never allocates a
+ * [org.dexpace.sdk.core.http.response.Status]. A caller that already holds a
+ * [org.dexpace.sdk.core.http.response.Status] should prefer its
+ * [org.dexpace.sdk.core.http.response.Status.isError] property directly rather than unwrapping
+ * to an `Int` and routing back through here.
*/
@JvmStatic
public fun isErrorStatus(code: Int): Boolean = code in SC_CLIENT_ERROR_MIN..SC_SERVER_ERROR_MAX
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/Retryable.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/Retryable.kt
index d1811d34..01d75758 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/Retryable.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/exception/Retryable.kt
@@ -11,7 +11,7 @@ package org.dexpace.sdk.core.http.response.exception
* Marks an exception that knows whether the condition it represents is worth retrying.
*
* The retry machinery keys off this interface rather than concrete exception types: a
- * [org.dexpace.sdk.core.pipeline.step.retry.RetryStep] asks `error is Retryable` and reads
+ * [org.dexpace.sdk.core.pipeline.step.retry.RetryRecovery] asks `error is Retryable` and reads
* [isRetryable] instead of matching every subclass it might encounter. New retryable
* exception types participate automatically by implementing this interface — no change to the
* classifier is required.
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/sse/SseStream.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/sse/SseStream.kt
index 12ae6131..47849e48 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/sse/SseStream.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/sse/SseStream.kt
@@ -106,7 +106,7 @@ public class SseStream private constructor(
* a suppressed throwable when an error is in flight; when there is no [primary] to carry it
* (a clean terminal path) the failure is logged at `WARN` and swallowed, since the events were
* already delivered. Mirrors the close-on-failure helper in
- * [org.dexpace.sdk.core.pipeline.ResponsePipeline].
+ * [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain].
*/
internal fun releaseQuietly(primary: Throwable?) {
if (!closed.compareAndSet(false, true)) return
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactor.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactor.kt
index 04957f2a..48da0af2 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactor.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactor.kt
@@ -87,6 +87,29 @@ public object UrlRedactor {
MALFORMED
}
+ /**
+ * Redacts a URL that arrives as a header value (e.g. `Location`, `Content-Location`), which may
+ * be absolute or relative. An absolute value is redacted exactly like [redact]; a relative or
+ * otherwise unparseable value keeps its path and drops any query or fragment — which can carry
+ * an OAuth authorization code, a pre-signed request signature, or an implicit-flow access token.
+ * The value is therefore never logged verbatim while the host/path stays visible for diagnostics.
+ *
+ * @param value the raw header value.
+ * @param allowedQueryParams query parameter names whose values are kept (see [redact]).
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun redactUrlValue(
+ value: String,
+ allowedQueryParams: Set = DEFAULT_ALLOWED,
+ ): String =
+ try {
+ redact(URL(value), allowedQueryParams)
+ } catch (_: Throwable) {
+ val base = value.substringBefore('?').substringBefore('#')
+ if (base.length == value.length) value else "$base?$REDACTED"
+ }
+
private fun lowercaseAllowList(allowed: Set): Set {
if (allowed.isEmpty()) return emptySet()
return allowed.mapTo(HashSet(allowed.size)) { it.lowercase() }
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/Io.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/Io.kt
index a0966007..44482c26 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/Io.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/Io.kt
@@ -7,31 +7,58 @@
package org.dexpace.sdk.core.io
+import org.dexpace.sdk.core.instrumentation.ClientLogger
+import java.util.ServiceLoader
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
- * Holds the [IoProvider] installed by the consuming application.
+ * Holds the [IoProvider] used by the SDK's I/O layer.
*
* Usage:
*
* ```
- * // At application startup, once:
+ * // At application startup, once (optional when exactly one IoProvider is on the classpath):
* Io.installProvider(OkioIoProvider)
*
* // Anywhere afterward:
* val buffer = Io.provider.buffer()
* ```
*
- * If no provider is installed, [provider] throws an [IllegalStateException] with a message
- * naming the missing setup call. Failure is loud and immediate — there is no fallback and no
- * `ServiceLoader` magic.
+ * ## Provider resolution order
+ *
+ * 1. **Explicit install** via [installProvider] — always wins.
+ * 2. **ServiceLoader auto-discovery** — when no explicit install exists, [IoProvider]
+ * implementations are discovered via [ServiceLoader], consulting the thread-context
+ * classloader before the interface's own defining loader so that a provider registered
+ * in a child loader (servlet containers, OSGi, plugin hosts) is still found. Once exactly
+ * one implementation has been resolved it is cached process-wide and the scan does not run
+ * again; an unresolved state (zero or multiple discoverable providers, which throws) is
+ * re-evaluated on the next access, so the scan can run more than once until it either
+ * succeeds or an explicit [installProvider] supersedes it.
+ * 3. **Error** — zero discoverable providers throw with an actionable message;
+ * more than one throw listing all candidates so the ambiguity is obvious.
+ *
+ * The first successfully auto-resolved provider is cached for the whole process, keyed off
+ * whichever thread resolved it first. In a hierarchical-classloader deployment that hosts a
+ * *distinct* provider per child loader, that first-resolution-wins caching means every caller
+ * shares the first provider resolved; such deployments should call [installProvider] explicitly
+ * at startup rather than rely on auto-discovery.
+ *
+ * ## Install after auto-resolution
+ *
+ * Explicit [installProvider] always wins, even after ServiceLoader auto-resolution. If a
+ * provider was already auto-resolved **and handed out** to a caller before the explicit install
+ * arrives, [installProvider] logs a WARN (it does not fail): objects created against the
+ * previously-resolved provider may already exist, so installing a different provider late risks
+ * mixed-provider state. Call [installProvider] at application startup, before any I/O, to avoid it.
*
* ## Thread-safety
*
- * Reads of [provider] go through a `@Volatile` field so callers see the install effect without
+ * Reads of [provider] go through `@Volatile` fields so callers see the install effect without
* locking. Writes ([installProvider], [swapProvider]) take a [ReentrantLock] so concurrent
- * installs cannot race past the conflict check.
+ * installs cannot race past the conflict check. A successful ServiceLoader resolution is cached
+ * through a double-checked locking pattern so the scan does not repeat once a provider is found.
*/
public object Io {
private val lock = ReentrantLock()
@@ -39,15 +66,119 @@ public object Io {
@Volatile
private var installed: IoProvider? = null
+ @Volatile
+ private var resolved: IoProvider? = null
+
+ /**
+ * `true` once a ServiceLoader-[resolved] provider has actually been returned to a caller from
+ * [provider]. Lets [installProvider] warn only when a late explicit install replaces a provider
+ * that was already in use, not merely resolvable.
+ */
+ @Volatile
+ private var resolvedReturned: Boolean = false
+
+ /**
+ * Logger seam. `internal var` (not `private val`) so tests can inject a fake SLF4J-backed
+ * [ClientLogger] to assert the install-after-resolve WARN. Not part of the public API.
+ */
+ internal var logger: ClientLogger = ClientLogger(Io::class)
+
/**
- * Returns the installed provider, or throws if none was installed.
+ * Returns the active provider: explicit install first, then ServiceLoader resolution.
+ *
+ * Throws [IllegalStateException] when no provider is installed and either zero or more
+ * than one [IoProvider] implementations are discoverable on the classpath.
*/
+ @get:JvmStatic
public val provider: IoProvider
- get() =
- installed ?: error(
- "No IoProvider installed. Call Io.installProvider(...) at application startup " +
- "(e.g. Io.installProvider(OkioIoProvider)).",
- )
+ get() {
+ installed?.let { return it }
+ resolved?.let {
+ if (!resolvedReturned) resolvedReturned = true
+ return it
+ }
+ return lock.withLock {
+ installed?.let { return it }
+ resolved?.let {
+ resolvedReturned = true
+ return it
+ }
+ val p = selectProvider(loadCandidates())
+ resolved = p
+ resolvedReturned = true
+ p
+ }
+ }
+
+ /**
+ * Selects the single [IoProvider] from [candidates], or throws a descriptive
+ * [IllegalStateException] when the list is empty or ambiguous.
+ *
+ * Separated from [loadCandidates] so the selection logic can be unit-tested in isolation.
+ */
+ internal fun selectProvider(candidates: List): IoProvider =
+ when (candidates.size) {
+ 0 ->
+ error(
+ "No IoProvider installed. Call Io.installProvider(...) at application startup " +
+ "(e.g. Io.installProvider(OkioIoProvider)).",
+ )
+ 1 -> candidates[0]
+ else -> {
+ val names = candidates.joinToString { it::class.qualifiedName ?: it::class.toString() }
+ error(
+ "Multiple IoProvider implementations found on the classpath ($names); " +
+ "call Io.installProvider(...) explicitly to choose one.",
+ )
+ }
+ }
+
+ /**
+ * Discovers all [IoProvider] implementations via [ServiceLoader], consulting the classloaders
+ * returned by [loaderSearchOrder] (thread-context loader first, then the interface's own
+ * defining loader) and de-duplicating by concrete class.
+ *
+ * Consulting the thread-context classloader first is the hardened SPI pattern for
+ * hierarchical-classloader deployments — servlet containers (e.g. Tomcat shared lib), OSGi, and
+ * plugin hosts — where sdk-core can sit in a parent loader while the provider's
+ * `META-INF/services` entry sits in a child loader that only the context loader can see. A
+ * provider visible through both loaders would otherwise be discovered twice (ServiceLoader
+ * instantiates a fresh object per scan) and misreported as an ambiguous multi-provider setup,
+ * so candidates are keyed by their concrete [Class] and the first occurrence wins.
+ *
+ * Internal (not private) so discovery can be exercised directly from tests.
+ */
+ internal fun loadCandidates(): List {
+ val definingLoader = IoProvider::class.java.classLoader
+ val contextLoader = Thread.currentThread().contextClassLoader
+ val byClass = LinkedHashMap, IoProvider>()
+ for (loader in loaderSearchOrder(contextLoader, definingLoader)) {
+ for (candidate in ServiceLoader.load(IoProvider::class.java, loader)) {
+ byClass.getOrPut(candidate.javaClass) { candidate }
+ }
+ }
+ return byClass.values.toList()
+ }
+
+ /**
+ * Ordered, duplicate-free classloader search order for SPI discovery: the [contextLoader]
+ * (thread-context classloader) first when it is set and distinct from [definingLoader], then
+ * the [definingLoader] (the loader that defined [IoProvider]). When the context loader is
+ * absent or is the same instance as the defining loader, only the defining loader is returned
+ * so the classpath is not scanned twice. The defining loader may itself be `null` (the
+ * bootstrap loader), which [ServiceLoader.load] accepts.
+ *
+ * Internal (not private) so the ordering can be asserted directly from tests.
+ */
+ internal fun loaderSearchOrder(
+ contextLoader: ClassLoader?,
+ definingLoader: ClassLoader?,
+ ): List =
+ if (contextLoader == null || contextLoader === definingLoader) {
+ listOf(definingLoader)
+ } else {
+ listOf(contextLoader, definingLoader)
+ }
/**
* Installs [provider] as the global I/O provider.
@@ -61,7 +192,14 @@ public object Io {
* All installs are serialized under a [ReentrantLock]: the first install succeeds
* unconditionally; subsequent installs of the same instance are no-ops; subsequent
* installs of a different provider throw [IllegalStateException].
+ *
+ * A ServiceLoader-resolved provider does **not** block an explicit install (the explicit
+ * install always wins), but if that resolved provider was already handed out to a caller a
+ * WARN is logged, because objects may already have been created against it — see
+ * [warnIfReplacingResolvedProvider]. Any previously cached ServiceLoader-resolved provider is
+ * then cleared so the explicit install takes full effect on the next [provider] access.
*/
+ @JvmStatic
public fun installProvider(provider: IoProvider) {
lock.withLock {
val existing = installed
@@ -73,15 +211,57 @@ public object Io {
"($providerName). " +
"Use withProvider { ... } from org.dexpace.sdk.core.testing for scoped overrides."
}
+ warnIfReplacingResolvedProvider(provider)
installed = provider
+ resolved = null
+ resolvedReturned = false
}
}
+ /**
+ * Emits a WARN when this explicit install replaces a *different* provider that had already
+ * been auto-resolved via ServiceLoader **and handed out** to a caller. The explicit install
+ * still wins — the contract is intentional — but by this point live objects may have been
+ * created against the previously-resolved provider, so the mismatch is worth surfacing.
+ *
+ * No warning fires when nothing was resolved yet, when the resolved provider was never
+ * returned from [provider], or when [incoming] is the same instance that was resolved.
+ *
+ * Must be called while holding [lock].
+ */
+ private fun warnIfReplacingResolvedProvider(incoming: IoProvider) {
+ if (installed != null) return
+ val previouslyResolved = resolved ?: return
+ // Compare through underlying so a ServiceLoader-registered shim and the singleton it
+ // delegates to (installed explicitly) are recognised as the same implementation.
+ if (!resolvedReturned || previouslyResolved.underlying === incoming.underlying) return
+ logger.atWarning()
+ .event("io.provider.install_after_resolve")
+ .field("resolved", previouslyResolved::class.qualifiedName ?: previouslyResolved::class.toString())
+ .field("installed", incoming::class.qualifiedName ?: incoming::class.toString())
+ .field(
+ "message",
+ "An IoProvider was auto-resolved via ServiceLoader and already handed out before " +
+ "Io.installProvider(...) was called; objects created earlier may reference the " +
+ "previously-resolved provider. Install the provider at application startup, before " +
+ "any I/O, to avoid mixed-provider state.",
+ ).log()
+ }
+
/**
* Swaps the installed provider without checking for conflicts. Intended as an `internal`
* seam for the test-fixtures `org.dexpace.sdk.core.testing.withProvider` helper; not part
* of the public API.
+ *
+ * Also clears any cached ServiceLoader-resolved provider (and its handed-out flag) so tests
+ * that reset state to null trigger a fresh resolution on the next [provider] access.
*/
internal fun swapProvider(provider: IoProvider?): IoProvider? =
- lock.withLock { installed.also { installed = provider } }
+ lock.withLock {
+ val previous = installed
+ installed = provider
+ resolved = null
+ resolvedReturned = false
+ previous
+ }
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/IoProvider.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/IoProvider.kt
index 1d4667c2..0b50e014 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/IoProvider.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/io/IoProvider.kt
@@ -63,4 +63,16 @@ public interface IoProvider {
* Wraps an existing primitive [Sink] with the typed write surface of [BufferedSink].
*/
public fun bufferedSink(sink: Sink): BufferedSink
+
+ /**
+ * The canonical provider instance this one ultimately delegates to, or `this` when it does not
+ * delegate.
+ *
+ * `ServiceLoader` cannot instantiate a Kotlin `object`, so a singleton provider is registered
+ * through a thin `class`-based shim that forwards every call to the singleton. Such a shim
+ * overrides this property to return the singleton it wraps, so [Io] can recognise that an
+ * auto-resolved shim and an explicitly installed singleton are the *same* implementation and
+ * suppress a spurious mixed-provider warning. Non-delegating providers keep the default.
+ */
+ public val underlying: IoProvider get() = this
}
diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginator.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginator.kt
index b0be8dec..9ae44c85 100644
--- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginator.kt
+++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginator.kt
@@ -9,6 +9,7 @@ package org.dexpace.sdk.core.pagination
import org.dexpace.sdk.core.client.AsyncHttpClient
import org.dexpace.sdk.core.http.request.Request
+import org.dexpace.sdk.core.http.request.RequestOptions
import org.dexpace.sdk.core.http.response.Response
import org.dexpace.sdk.core.util.Futures
import java.util.concurrent.CompletableFuture
@@ -122,6 +123,10 @@ import java.util.function.Consumer
* @property strategy Strategy that parses each response into a [PageInfo].
* @property maxPages Safety cap on the total number of pages (HTTP exchanges) the walk will
* fetch. Defaults to `Long.MAX_VALUE` (unbounded). Must be positive.
+ * @property options Per-call overrides applied to every page fetch (passed to
+ * `AsyncHttpClient.executeAsync(request, options)`). Defaults to [RequestOptions.EMPTY], i.e. no
+ * overrides. Set a timeout, retry budget, or tags here to have them reach each page request —
+ * without it a caller's per-operation options could never influence the paginator's own exchanges.
*/
public class AsyncPaginator
@JvmOverloads
@@ -130,6 +135,7 @@ public class AsyncPaginator