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 private val initialRequest: Request, private val strategy: PaginationStrategy, private val maxPages: Long = Long.MAX_VALUE, + private val options: RequestOptions = RequestOptions.EMPTY, ) { init { requirePositiveMaxPages(maxPages) @@ -456,7 +462,7 @@ public class AsyncPaginator pagesFetched++ val transportFuture: CompletableFuture = try { - asyncHttpClient.executeAsync(request) + asyncHttpClient.executeAsync(request, options) } catch (t: Throwable) { // An eager throw from executeAsync (a contract violation, but be // defensive) becomes an exceptional future so the driver stays uniform. diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CloseablePages.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CloseablePages.kt index e34179a1..9a2e6c14 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CloseablePages.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CloseablePages.kt @@ -21,6 +21,20 @@ import java.util.stream.Stream * - The terminal `hasNext()` (source exhausted) closes the last page. * - [close] closes the page currently held open. * + * ## Prefetch and the close-on-abandon guarantee + * + * The underlying walk is page-lazy but its `hasNext()` is *eager*: probing for the next page runs + * the exchange that fetches it (still exactly one HTTP exchange per page yielded — `next()` returns + * the page the preceding `hasNext()` already fetched). This view buffers that fetched-but-not-yet- + * returned page in a field it owns, so a `hasNext()` that is never followed by `next()` — an + * emptiness probe, a peeking-iterator wrapper, or a `break` right after `hasNext()` — cannot strand + * a live page. [close] releases **both** the page currently held open and any such buffered page, + * so wrapping the view in `use { }` / try-with-resources always drains every page it fetched. + * + * Because probing prefetches the next page while the current one is still held, up to two live + * pages can be open at once (the one being inspected and the one just fetched); [close] releases + * both. + * * Because an early `break`/`return` leaves the current page open, consumers MUST wrap the view in * `use { }` (Kotlin) or try-with-resources (Java) so that path still releases the held page: * @@ -43,29 +57,51 @@ public class CloseablePages private val source: Iterator>, ) : AutoCloseable, Iterable> { private var current: Page? = null + + // A page fetched by hasNext() but not yet returned by next(). The source's hasNext() runs + // the fetch eagerly, so we pull that page into a field we own — otherwise a hasNext() with + // no matching next() would strand it (unreachable by close()). Held until next() promotes + // it to `current`, or close() releases it. + private var prefetched: Page? = null private var iterated: Boolean = false /** * Returns the single auto-closing iterator over the underlying walk. The page returned by - * `next()` stays open until the next `next()`, exhaustion, or [close]. + * `next()` stays open until the next `next()`, exhaustion, or [close]. A page fetched by a + * `hasNext()` probe but never returned by `next()` is retained and released by [close], so + * an abandoned probe never strands a connection. + * + * The underlying walker is a lazy sequence whose `hasNext()` performs the page fetch, so the + * returned iterator's `hasNext()` deliberately pulls that fetched page into a field we own — + * that prefetch is what lets [close] release a page from an abandoned probe (hence the + * `IteratorHasNextCallsNextMethod` suppression). * * @throws IllegalStateException if called more than once on this view. */ + @Suppress("IteratorHasNextCallsNextMethod") override fun iterator(): Iterator> { check(!iterated) { "CloseablePages is single-use; call byPage() again to restart pagination." } iterated = true return object : Iterator> { override fun hasNext(): Boolean { - val has = source.hasNext() - if (!has) closeCurrent() // close the last page when iteration is exhausted - return has + if (prefetched != null) return true // already fetched by a prior probe + if (!source.hasNext()) { + closeCurrent() // close the last page when iteration is exhausted + return false + } + // source.hasNext() has already run the fetch; pull the page into a field we own + // so close() can release it even if next() is never called. + prefetched = source.next() + return true } override fun next(): Page { - // hasNext() also closes the held page when the source is exhausted. if (!hasNext()) throw NoSuchElementException() closeCurrent() // close the previous page as we advance past it - return source.next().also { current = it } + val page = prefetched ?: throw NoSuchElementException() + prefetched = null // promoted to current; no longer separately held + current = page + return page } } } @@ -103,14 +139,38 @@ public class CloseablePages ) /** - * Closes the page currently held open (the one being inspected). Safe to call repeatedly. + * Closes the page currently held open (the one being inspected) **and** any page fetched by + * a `hasNext()` probe but never returned by `next()`. Safe to call repeatedly. If both + * closes fail, the first failure propagates with the second attached as a suppressed + * exception, so neither page's connection is leaked. * - * @throws IOException If the held page's [Page.close] (and thus its [Page.response] close) + * @throws IOException If a held page's [Page.close] (and thus its [Page.response] close) * fails. */ @Throws(IOException::class) override fun close() { - closeCurrent() + val held = current + val pending = prefetched + // Null out first so a page is never double-closed if close() runs again. + current = null + prefetched = null + + var failure: IOException? = null + if (held != null) { + try { + held.close() + } catch (e: IOException) { + failure = e + } + } + if (pending != null) { + try { + pending.close() + } catch (e: IOException) { + if (failure == null) failure = e else failure.addSuppressed(e) + } + } + failure?.let { throw it } } private fun closeCurrent() { diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationStrategy.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationStrategy.kt index 93002d5b..ab6840b6 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationStrategy.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationStrategy.kt @@ -10,6 +10,25 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.response.Response +/** + * Single-pass extractor for [CursorPaginationStrategy]: reads a page's items and its next cursor + * out of one [Response] read, returned together as a [CursorResult]. + * + * A dedicated functional interface (rather than a raw `(Response) -> CursorResult` function type) + * gives Java callers a clean SAM to implement — `new CursorExtractor<>() { ... }` or a lambda — + * instead of `kotlin.jvm.functions.Function1`. Kotlin callers pass a lambda unchanged via SAM + * conversion. + * + * @param T Element type carried in the produced [CursorResult]. + */ +public fun interface CursorExtractor { + /** + * Reads [response] once, returning both the page's items and the next cursor as a + * [CursorResult]. Must drain the response body synchronously; must not close it. + */ + public fun extract(response: Response): CursorResult +} + /** * Cursor-based [PaginationStrategy]. The server returns an opaque cursor (typically in * the response body) which the client echoes back as a query parameter on the next @@ -44,14 +63,14 @@ import org.dexpace.sdk.core.http.response.Response public class CursorPaginationStrategy @JvmOverloads constructor( - private val extractor: (Response) -> CursorResult, + private val extractor: CursorExtractor, private val cursorQueryParam: String = "cursor", ) : PaginationStrategy { override fun parse( response: Response, initialRequest: Request, ): PageInfo { - val result: CursorResult = extractor(response) + val result: CursorResult = extractor.extract(response) val nextCursor: String? = result.nextCursor val nextRequest: Request? = if (!nextCursor.isNullOrEmpty()) { diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/ItemsExtractor.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/ItemsExtractor.kt new file mode 100644 index 00000000..a71d87ea --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/ItemsExtractor.kt @@ -0,0 +1,31 @@ +/* + * 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.pagination + +import org.dexpace.sdk.core.http.response.Response + +/** + * Items extractor shared by the paging strategies whose next-page state comes from somewhere other + * than the items themselves: [PageNumberPaginationStrategy] (page number derived from the request + * URL) and [LinkHeaderPaginationStrategy] (next-page URL taken from the `Link` header). Both need + * only "read this page's items out of the response", so they share this single functional type. + * + * A dedicated functional interface (rather than a raw `(Response) -> List` function type) gives + * Java callers a clean SAM to implement instead of `kotlin.jvm.functions.Function1`. Kotlin callers + * pass a lambda unchanged via SAM conversion. + * + * [CursorPaginationStrategy] intentionally does NOT use this type: its cursor lives in the same + * payload as the items, so it reads both in one pass via [CursorExtractor] (returning a + * [CursorResult]) to avoid a double-drain of the single-use body. + * + * @param T Element type read from the response body. + */ +public fun interface ItemsExtractor { + /** Reads the list of items from [response]. Must drain the response body synchronously. */ + public fun extract(response: Response): List +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt index 63538c04..82e11cd8 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt @@ -46,14 +46,14 @@ import java.net.URL public class LinkHeaderPaginationStrategy @JvmOverloads constructor( - private val itemsExtractor: (Response) -> List, + private val itemsExtractor: ItemsExtractor, private val linkHeader: String = "Link", ) : PaginationStrategy { override fun parse( response: Response, initialRequest: Request, ): PageInfo { - val items: List = itemsExtractor(response) + val items: List = itemsExtractor.extract(response) // Some servers emit multiple `Link` headers (one per link-value) instead of a single // comma-separated header. Joining the values with ',' normalizes both wire shapes into // one string so a single parser handles either. An empty header list joins to "", which diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationStrategy.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationStrategy.kt index 8d1baaa2..980674c4 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationStrategy.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationStrategy.kt @@ -38,7 +38,7 @@ import org.dexpace.sdk.core.http.response.Response public class PageNumberPaginationStrategy @JvmOverloads constructor( - private val itemsExtractor: (Response) -> List, + private val itemsExtractor: ItemsExtractor, private val pageParam: String = "page", private val startPage: Int = 1, ) : PaginationStrategy { @@ -46,7 +46,7 @@ public class PageNumberPaginationStrategy response: Response, initialRequest: Request, ): PageInfo { - val items: List = itemsExtractor(response) + val items: List = itemsExtractor.extract(response) // Empty page → end of stream. Defensive against servers that "succeed" with an // empty list rather than 404 / a sentinel field once you've paged off the end. if (items.isEmpty()) { diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/Paginator.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/Paginator.kt index a0772ff5..320307db 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/Paginator.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/Paginator.kt @@ -9,6 +9,7 @@ package org.dexpace.sdk.core.pagination 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.util.stream.Stream @@ -81,6 +82,10 @@ import java.util.stream.Stream * @property strategy Strategy that parses each response into a [PageInfo]. * @property maxPages Safety cap on the total number of pages (HTTP exchanges) the iterator * will fetch. Defaults to `Long.MAX_VALUE` (unbounded). Must be positive. + * @property options Per-call overrides applied to every page fetch (passed to + * `HttpClient.execute(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 Paginator @JvmOverloads @@ -89,6 +94,7 @@ public class Paginator private val initialRequest: Request, private val strategy: PaginationStrategy, private val maxPages: Long = Long.MAX_VALUE, + private val options: RequestOptions = RequestOptions.EMPTY, ) { init { requirePositiveMaxPages(maxPages) @@ -113,7 +119,7 @@ public class Paginator source = { val request = nextRequest ?: return@PageWalker null nextRequest = null - val response: Response = httpClient.execute(request) + val response: Response = httpClient.execute(request, options) val info: PageInfo = strategy.parseOrClose(response, initialRequest) nextRequest = info.nextRequest Page(response, info.items) diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ExecutionPipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt similarity index 71% rename from sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ExecutionPipeline.kt rename to sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt index 91bea356..2352ce65 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ExecutionPipeline.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt @@ -13,9 +13,9 @@ import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.response.Response /** - * Top-level pipeline that ties together the request chain, the transport, and the recovery-aware + * Top-level chain that ties together the request chain, the transport, and the recovery-aware * response chain. This is the single entry point an SDK consumer calls to dispatch a request: - * `executionPipeline.execute(request, context)`. + * `recoveryChain.recover(request, context)`. * * ## Execution flow * @@ -23,50 +23,50 @@ import org.dexpace.sdk.core.http.response.Response * Request * │ * ▼ - * RequestPipeline ──── throws ─────────────────────┐ + * RequestRecoveryChain ──── throws ────────────────┐ * │ │ * ▼ │ * HttpClient.execute ──── throws ────────────────┐ │ * │ │ │ * ▼ ▼ ▼ - * ResponseOutcome.Success ─────► ResponsePipeline (recovery chain runs uniformly) + * ResponseOutcome.Success ─────► ResponseRecoveryChain (recovery chain runs uniformly) * │ * ▼ * unwrap: Success → Response, Failure → throw * ``` * * **All** exceptions raised by request steps and the transport are caught and converted into - * a [ResponseOutcome.Failure], then handed to the [ResponsePipeline]. Recovery steps see every - * failure regardless of where in the pipeline it originated — this fixes Airbyte's design + * a [ResponseOutcome.Failure], then handed to the [ResponseRecoveryChain]. Recovery steps see + * every failure regardless of where in the pipeline it originated — this fixes Airbyte's design * defect where a `BeforeRequest` exception bypassed `AfterError`. * * ## Thread-safety - * The pipeline is immutable after construction (the underlying [requestPipeline], - * [responsePipeline], and [httpClient] references are final). Concurrent calls to [execute] + * The chain is immutable after construction (the underlying [requestChain], + * [responseChain], and [httpClient] references are final). Concurrent calls to [recover] * are safe provided each component is thread-safe. * * ## Exception semantics - * [execute] returns the [Response] from a [ResponseOutcome.Success] outcome, or rethrows the + * [recover] returns the [Response] from a [ResponseOutcome.Success] outcome, or rethrows the * [Throwable] from a [ResponseOutcome.Failure] outcome unchanged. Recovery steps that wish to * surface a typed exception should construct it themselves and return * [ResponseOutcome.Failure]. * - * @property requestPipeline Pre-transport request chain. + * @property requestChain Pre-transport request chain. * @property httpClient Transport that produces the raw [Response]. - * @property responsePipeline Recovery-aware response chain. + * @property responseChain Recovery-aware response chain. */ -public class ExecutionPipeline +public class RecoveryChain @JvmOverloads constructor( public val httpClient: HttpClient, - public val requestPipeline: RequestPipeline = RequestPipeline(), - public val responsePipeline: ResponsePipeline = ResponsePipeline(), + public val requestChain: RequestRecoveryChain = RequestRecoveryChain(), + public val responseChain: ResponseRecoveryChain = ResponseRecoveryChain(), ) { /** * Dispatches [request] through the full pipeline: - * 1. Runs [requestPipeline] (catching throwables). + * 1. Runs [requestChain] (catching throwables). * 2. On success, invokes [HttpClient.execute] (catching throwables). - * 3. Folds the resulting [ResponseOutcome] through [responsePipeline]. + * 3. Folds the resulting [ResponseOutcome] through [responseChain]. * 4. Unwraps the final outcome: [ResponseOutcome.Success] returns the [Response]; * [ResponseOutcome.Failure] rethrows. * @@ -76,12 +76,12 @@ public class ExecutionPipeline * @throws Throwable The terminal failure if no recovery step rescued it. The throwable is * rethrown unchanged — wrapping is the recovery chain's job. */ - public fun execute( + public fun recover( request: Request, context: DispatchContext, ): Response { val outcome = produceOutcome(request, context) - val finalOutcome = responsePipeline.apply(outcome, context) + val finalOutcome = responseChain.apply(outcome, context) return when (finalOutcome) { is ResponseOutcome.Success -> finalOutcome.response is ResponseOutcome.Failure -> throw finalOutcome.error @@ -89,7 +89,7 @@ public class ExecutionPipeline } /** - * Runs the [requestPipeline] and transport to produce a [ResponseOutcome]. Any throwable + * Runs the [requestChain] and transport to produce a [ResponseOutcome]. Any throwable * raised by a request step or the transport is converted to a [ResponseOutcome.Failure]; * the recovery chain receives them uniformly. */ @@ -99,7 +99,7 @@ public class ExecutionPipeline ): ResponseOutcome { val transformedRequest = try { - requestPipeline.execute(request, context) + requestChain.recover(request, context) } catch (t: Throwable) { return failureOf(t) } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RequestPipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RequestRecoveryChain.kt similarity index 83% rename from sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RequestPipeline.kt rename to sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RequestRecoveryChain.kt index 3037914f..dc86eb76 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RequestPipeline.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RequestRecoveryChain.kt @@ -17,7 +17,7 @@ import org.dexpace.sdk.core.pipeline.step.RequestPipelineStep * the `BeforeRequest` chain. * * The pipeline preserves the original [Request] when the list is empty — an empty pipeline - * is a no-op. Steps may throw; the [ExecutionPipeline] catches throwables from this pipeline + * is a no-op. Steps may throw; the [RecoveryChain] catches throwables from this pipeline * and routes them through the recovery chain rather than propagating them to the caller. * * ## Thread-safety @@ -25,28 +25,28 @@ import org.dexpace.sdk.core.pipeline.step.RequestPipelineStep * are safe to share across threads provided the steps themselves are thread-safe. * * ## Exception semantics - * If any step throws, [execute] propagates the throwable to the caller without invoking the - * remaining steps. The wider [ExecutionPipeline] is responsible for translating that throwable + * If any step throws, [recover] propagates the throwable to the caller without invoking the + * remaining steps. The wider [RecoveryChain] is responsible for translating that throwable * into a [ResponseOutcome.Failure] so it can be observed by recovery steps. * * @property steps Ordered list of [RequestPipelineStep]s applied left-to-right. */ -public class RequestPipeline +public class RequestRecoveryChain @JvmOverloads constructor( public val steps: List = emptyList(), ) { /** * Applies every step in [steps] to [request] sequentially under [context] and returns the - * final transformed [Request]. Empty pipelines return [request] unchanged. + * final transformed [Request]. An empty chain returns [request] unchanged. * * @param request The initial request to feed into the chain. * @param context The shared dispatch context. * @return The request produced by the last step (or [request] if [steps] is empty). * @throws Throwable Any exception thrown by a step is rethrown unchanged. Callers should - * funnel this throwable through the recovery chain (see [ExecutionPipeline]). + * funnel this throwable through the recovery chain (see [RecoveryChain]). */ - public fun execute( + public fun recover( request: Request, context: DispatchContext, ): Request = steps.fold(request) { current, step -> step.execute(current, context) } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseOutcome.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseOutcome.kt index 2a140ebd..733b4426 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseOutcome.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseOutcome.kt @@ -11,9 +11,9 @@ import org.dexpace.sdk.core.http.response.Response /** * Sealed sum type representing the result of a request/response exchange after it has passed - * through the [ExecutionPipeline]. + * through the [RecoveryChain]. * - * The recovery-aware [ResponsePipeline] threads an outcome through its fold rather than a bare + * The recovery-aware [ResponseRecoveryChain] threads an outcome through its fold rather than a bare * [Response]: this lets [org.dexpace.sdk.core.pipeline.step.ResponseRecoveryStep]s observe and * rescue failures uniformly, regardless of whether the throwable originated in a request step, * the transport, or a downstream response step. @@ -26,7 +26,7 @@ import org.dexpace.sdk.core.http.response.Response * Instances are immutable; equality is structural via [equals]/[hashCode] inherited from the * data-class variants. Safe to share across threads. * - * @see ResponsePipeline + * @see ResponseRecoveryChain * @see org.dexpace.sdk.core.pipeline.step.ResponseRecoveryStep */ public sealed class ResponseOutcome { @@ -93,7 +93,7 @@ public sealed class ResponseOutcome { * Wraps [t] in a [ResponseOutcome.Failure]. When [t] is an [InterruptedException] the interrupt * flag is restored on the current thread before wrapping, honouring the SDK's cancellation * contract so a thread blocked on the surfaced outcome still observes the cancellation. Shared by - * [ExecutionPipeline], [ResponsePipeline], and [org.dexpace.sdk.core.pipeline.step.retry.RetryStep] + * [RecoveryChain], [ResponseRecoveryChain], and [org.dexpace.sdk.core.pipeline.step.retry.RetryRecovery] * so the interrupt-aware wrapper has exactly one definition. */ internal fun failureOf(t: Throwable): ResponseOutcome.Failure { diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponsePipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChain.kt similarity index 99% rename from sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponsePipeline.kt rename to sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChain.kt index 1f544264..899c2c09 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponsePipeline.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChain.kt @@ -64,7 +64,7 @@ import org.dexpace.sdk.core.pipeline.step.ResponseRecoveryStep * @property responseSteps Steps applied on the success path; skipped on failures. * @property recoverySteps Recovery steps applied to every outcome. */ -public class ResponsePipeline +public class ResponseRecoveryChain @JvmOverloads constructor( responseSteps: List = emptyList(), diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStep.kt index 4751190d..6f53868a 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStep.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStep.kt @@ -23,20 +23,21 @@ import org.dexpace.sdk.core.util.SdkInfo * * ## Modes * - * - [Mode.Append] (default): if the chosen header already carries a value, the SDK token - * line is appended after a single space. This preserves caller-set identifiers such as - * `MyApp/1.0`, producing `MyApp/1.0 dexpace-sdk/X jvm/Y`. If the header is absent the - * token line is set as the sole value. - * - [Mode.Replace]: the existing header value (if any) is overwritten with the SDK token + * - [Mode.Append] (default): if the chosen header already carries one or more values, the SDK + * token line is appended to the first value after a single space, and every other pre-existing + * value is preserved. This keeps caller-set identifiers such as `MyApp/1.0` intact, producing + * `MyApp/1.0 dexpace-sdk/X jvm/Y`. If the header is absent the token line is set as the sole + * value. + * - [Mode.Replace]: any existing header value(s) are overwritten with the SDK token * line. Use when the SDK is the sole producer of the header and a caller-set value * would conflict with fleet conventions. * - * ## Empty token lists + * ## Empty or blank token lists * - * If the configured token list is empty, the step is a no-op — the request is returned - * unchanged. The step deliberately does not emit a blank header in this case (an empty - * `User-Agent` is a wire-protocol violation, and an empty custom header carries no - * information). + * If the configured token list is empty — or every token is blank, so the joined line is blank — + * the step is a no-op and the request is returned unchanged. The step deliberately never emits a + * blank or whitespace-only header (an empty `User-Agent` is a wire-protocol violation, and a blank + * custom header carries no information). * * ## Thread-safety * @@ -75,13 +76,21 @@ public class ClientIdentityStep } /** - * Pipeline entry point — delegates to [apply], which carries the full step logic and - * is also the Java-friendly direct-call form. + * The single public way to apply this step's transform: emits the composite + * client-identity token line onto [input] and returns the resulting request. [context] + * is unused — the transform depends only on the request's existing headers. + * + * Resolution rules: + * - empty or all-blank tokens -> no change (request returned unchanged) + * - header absent -> header set to the joined token line + * - header present + Append -> joined line appended to the first existing value; all + * other pre-existing values preserved + * - header present + Replace -> all existing values replaced with the joined line */ override fun execute( input: Request, context: DispatchContext, - ): Request = apply(input) + ): Request = applyTokens(input) /** * Returns a [ClientIdentityStepBuilder] pre-filled with this instance's token list, @@ -98,32 +107,35 @@ public class ClientIdentityStep */ public fun newBuilder(): ClientIdentityStepBuilder = ClientIdentityStepBuilder(this) - /** - * Applies the step to [request] and returns the resulting request. If the - * configured token list is empty the request is returned unchanged. - * - * Resolution rules: - * - empty tokens -> no change - * - header absent -> header set to the joined token line - * - header present + Append -> header set to `existing + " " + joined` - * - header present + Replace -> header set to joined - */ - public fun apply(request: Request): Request { + /** Private transform carrying the full step logic; the sole caller is [execute]. */ + private fun applyTokens(request: Request): Request { if (tokens.isEmpty()) return request val tokenLine = tokens.joinToString(" ") - val existing = request.headers.get(headerName) + // A list of blank/whitespace-only tokens joins to a blank line; emitting it would write a + // useless (and, for User-Agent, protocol-violating) blank header. Treat it as a no-op. + if (tokenLine.isBlank()) return request + + // Read ALL existing values, not just the first: reading only get() (the first value) then + // replacing with a single value would silently drop any additional pre-existing values. + val existingValues: List = request.headers.values(headerName) - val newValue = + val newValues: List = when { - existing.isNullOrEmpty() -> tokenLine - mode == Mode.Replace -> tokenLine - else -> "$existing $tokenLine" + existingValues.isEmpty() -> listOf(tokenLine) + mode == Mode.Replace -> listOf(tokenLine) + else -> { + // Append the token line to the first existing value and keep every other + // value intact. An empty first value is treated as "absent" (no leading space). + val head = existingValues.first() + val appendedHead = if (head.isEmpty()) tokenLine else "$head $tokenLine" + listOf(appendedHead) + existingValues.drop(1) + } } return request .newBuilder() - .setHeader(headerName, newValue) + .setHeader(headerName, newValues) .build() } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/PipelineStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/PipelineStep.kt index 7d85d841..7faf61bd 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/PipelineStep.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/PipelineStep.kt @@ -23,8 +23,8 @@ import org.dexpace.sdk.core.http.context.DispatchContext * transformed, not on the step instance. * * ## Exception semantics - * If a step throws, the surrounding pipeline (e.g. [org.dexpace.sdk.core.pipeline.RequestPipeline] - * or [org.dexpace.sdk.core.pipeline.ResponsePipeline]) is responsible for routing the + * If a step throws, the surrounding chain (e.g. [org.dexpace.sdk.core.pipeline.RequestRecoveryChain] + * or [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain]) is responsible for routing the * throwable into the recovery chain rather than propagating it directly. Steps should not * swallow exceptions silently. * diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/RequestPipelineStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/RequestPipelineStep.kt index 7b346e33..718e5234 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/RequestPipelineStep.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/RequestPipelineStep.kt @@ -19,7 +19,7 @@ import org.dexpace.sdk.core.http.request.Request * multiple threads. * * ## Exception semantics - * If [execute] throws, [org.dexpace.sdk.core.pipeline.ExecutionPipeline] catches the throwable + * If [execute] throws, [org.dexpace.sdk.core.pipeline.RecoveryChain] catches the throwable * and routes it through the recovery chain — the failure does not bypass * [org.dexpace.sdk.core.pipeline.step.ResponseRecoveryStep]s. This is a deliberate departure * from Airbyte's hook model, where a `BeforeRequest` exception skips `AfterError`. diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponsePipelineStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponsePipelineStep.kt index e33d7d88..594f1031 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponsePipelineStep.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponsePipelineStep.kt @@ -14,7 +14,7 @@ import org.dexpace.sdk.core.http.response.Response * Airbyte-equivalent of `AfterSuccess`: typical uses include status validation, body decoration, * header extraction, deserialization, and response logging. * - * Applied by [org.dexpace.sdk.core.pipeline.ResponsePipeline] **only on the success path** — + * Applied by [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain] **only on the success path** — * the response-step chain is skipped when the current [org.dexpace.sdk.core.pipeline.ResponseOutcome] * is a [org.dexpace.sdk.core.pipeline.ResponseOutcome.Failure]. * @@ -23,7 +23,7 @@ import org.dexpace.sdk.core.http.response.Response * multiple threads. * * ## Exception semantics - * If [execute] throws, [org.dexpace.sdk.core.pipeline.ResponsePipeline] converts the throwable + * If [execute] throws, [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain] converts the throwable * into a [org.dexpace.sdk.core.pipeline.ResponseOutcome.Failure] and routes it through any * subsequent [ResponseRecoveryStep]s — failures inside the response chain participate in the * same recovery pathway as transport failures. diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponseRecoveryStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponseRecoveryStep.kt index 3f64cd7f..4fe71bcc 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponseRecoveryStep.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ResponseRecoveryStep.kt @@ -11,7 +11,7 @@ import org.dexpace.sdk.core.pipeline.ResponseOutcome /** * Recovery hook applied to every [ResponseOutcome] produced by the - * [org.dexpace.sdk.core.pipeline.ExecutionPipeline]. The Airbyte-equivalent of `AfterError`, + * [org.dexpace.sdk.core.pipeline.RecoveryChain]. The Airbyte-equivalent of `AfterError`, * but with broader scope: this step is invoked uniformly on both successes and failures, and * on failures originating from any phase of the pipeline (request steps, transport, response * steps). @@ -25,15 +25,15 @@ import org.dexpace.sdk.core.pipeline.ResponseOutcome * - **Pass-through.** Return the input outcome unchanged when the step does not apply. * - **Retry (delegated).** Re-invoke the underlying transport via a captured * [org.dexpace.sdk.core.client.HttpClient] reference and return the resulting outcome. - * See `RetryStep` (WU-3) for the canonical implementation. + * See `RetryRecovery` for the canonical implementation. * * Recovery steps **must not** throw. If a recovery step itself raises an exception, the - * surrounding [org.dexpace.sdk.core.pipeline.ResponsePipeline] wraps the throwable into a + * surrounding [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain] wraps the throwable into a * [ResponseOutcome.Failure] and feeds it to the next recovery step. Implementations should * still avoid this path — surface errors as [ResponseOutcome.Failure] explicitly. * * ## Close ownership when discarding a Success - * The [org.dexpace.sdk.core.pipeline.ResponsePipeline] closes the in-hand + * The [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain] closes the in-hand * [ResponseOutcome.Success] response on exactly one path: when a step *throws*. A step that is * handed a [ResponseOutcome.Success] and instead deliberately **returns** a different outcome * — a [ResponseOutcome.Failure] (the Success→Failure transform, e.g. status-to-typed-exception @@ -50,7 +50,7 @@ import org.dexpace.sdk.core.pipeline.ResponseOutcome * multiple threads. * * @see org.dexpace.sdk.core.pipeline.ResponseOutcome - * @see org.dexpace.sdk.core.pipeline.ResponsePipeline + * @see org.dexpace.sdk.core.pipeline.ResponseRecoveryChain */ public fun interface ResponseRecoveryStep { /** diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecovery.kt similarity index 54% rename from sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStep.kt rename to sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecovery.kt index 2c501c85..a7cd08b6 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStep.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecovery.kt @@ -7,14 +7,11 @@ package org.dexpace.sdk.core.pipeline.step -import org.dexpace.sdk.core.http.common.MediaType import org.dexpace.sdk.core.http.context.DispatchContext import org.dexpace.sdk.core.http.response.Response -import org.dexpace.sdk.core.http.response.ResponseBody +import org.dexpace.sdk.core.http.response.bufferErrorBody import org.dexpace.sdk.core.http.response.exception.HttpException import org.dexpace.sdk.core.http.response.exception.HttpExceptionFactory -import org.dexpace.sdk.core.io.BufferedSource -import org.dexpace.sdk.core.io.Io /** * Response step that turns an HTTP error response into the matching typed [HttpException]. @@ -22,7 +19,7 @@ import org.dexpace.sdk.core.io.Io * This is the error path for the recovery-aware pipeline: a transport hands back a `Response` * for every completed exchange — including 4xx and 5xx — so a 4xx/5xx is a [Response], not a * thrown exception, until something maps it. Placed in a - * [org.dexpace.sdk.core.pipeline.ResponsePipeline.responseSteps] list, this step performs that + * [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain.responseSteps] list, this step performs that * mapping via [HttpExceptionFactory.fromResponse], so the typed [HttpException] family is * produced consistently from one seam rather than re-derived ad hoc at each call site. * @@ -30,10 +27,10 @@ import org.dexpace.sdk.core.io.Io * * - **2xx (and 1xx/3xx) response** — returned unchanged; the success chain continues. * - **4xx / 5xx response** — [HttpExceptionFactory.fromResponse] is invoked and the resulting - * [HttpException] is thrown. [org.dexpace.sdk.core.pipeline.ResponsePipeline] catches it, + * [HttpException] is thrown. [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain] catches it, * closes the in-hand response, and wraps it into a * [org.dexpace.sdk.core.pipeline.ResponseOutcome.Failure] — which then flows through the - * recovery chain (e.g. [org.dexpace.sdk.core.pipeline.step.retry.RetryStep]) exactly like a + * recovery chain (e.g. [org.dexpace.sdk.core.pipeline.step.retry.RetryRecovery]) exactly like a * transport failure. Because the thrown [HttpException] implements * [org.dexpace.sdk.core.http.response.exception.Retryable], retry classification keys off it * uniformly. @@ -45,13 +42,17 @@ import org.dexpace.sdk.core.io.Io * ## Error-body buffering * * The pipeline closes the in-hand response as soon as this step throws (close-before-propagate), - * which would also close the live [ResponseBody]. To keep the error body readable on the + * which would also close the live response body. To keep the error body readable on the * resulting [org.dexpace.sdk.core.pipeline.ResponseOutcome.Failure] — for a log-line * [HttpException.bodySnapshot] or downstream typed-error deserialization — this step buffers the - * error body into a small, replayable in-memory body before constructing the exception. The - * buffer is bounded at [HttpException.DEFAULT_SNAPSHOT_BYTES], consistent with the - * `bodySnapshot` cap, so a multi-megabyte 5xx body cannot OOM the process. Mapping the buffered - * body into a typed error value is still left to the generated layer (see [HttpException.value]). + * error body into a small, replayable in-memory body before constructing the exception, via the + * shared [org.dexpace.sdk.core.http.response.bufferErrorBody] helper (the same routine that backs + * [org.dexpace.sdk.core.http.response.throwOnError], so the two error-mapping paths share one + * bounded, replayable implementation and cannot drift). The buffer is bounded at + * [HttpException.DEFAULT_SNAPSHOT_BYTES], consistent with the `bodySnapshot` cap (smaller than the + * standalone `throwOnError` cap, since this copy is primarily a log-line snapshot), so a + * multi-megabyte 5xx body cannot OOM the process. Mapping the buffered body into a typed error + * value is still left to the generated layer (see [HttpException.value]). * * The cap is a hard truncation, not just an OOM guard: an error body larger than * [HttpException.DEFAULT_SNAPSHOT_BYTES] is preserved only up to that many bytes, and the excess @@ -65,7 +66,7 @@ import org.dexpace.sdk.core.io.Io * * Stateless; safe to share across concurrent requests and reuse as a singleton. */ -public object ThrowOnHttpErrorStep : ResponsePipelineStep { +public object ThrowOnHttpErrorRecovery : ResponsePipelineStep { /** * Returns [input] unchanged for a non-error status; throws the * [HttpExceptionFactory.fromResponse] mapping for a 4xx / 5xx status. @@ -79,60 +80,8 @@ public object ThrowOnHttpErrorStep : ResponsePipelineStep { if (!HttpExceptionFactory.isErrorStatus(input.status.code)) { return input } - throw HttpExceptionFactory.fromResponse(bufferErrorBody(input)) - } - - /** - * Returns a copy of [response] whose body has been drained into a bounded, replayable - * in-memory body, so it survives the pipeline closing the original response. Bodies that are - * absent are left as-is. - */ - private fun bufferErrorBody(response: Response): Response { - val body = response.body ?: return response - val buffered = replayableBody(body) - return response.newBuilder().body(buffered).build() - } - - /** - * Reads up to [HttpException.DEFAULT_SNAPSHOT_BYTES] from [body] into memory and returns a - * [ResponseBody] that can be read repeatedly from those bytes. The original [body] is read - * here (the byte budget is bounded), so the caller must not also stream it. - */ - private fun replayableBody(body: ResponseBody): ResponseBody { - val cap = HttpException.DEFAULT_SNAPSHOT_BYTES - val mediaType = body.mediaType() - val bytes = - body.source().use { source -> - readUpTo(source, cap) - } - return 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() { - // Bytes are held in memory; nothing transport-owned to release. - } - } - } - - /** Reads at most [cap] bytes from [source], returning however many were available. */ - private fun readUpTo( - source: BufferedSource, - cap: Int, - ): ByteArray { - if (cap == 0) return ByteArray(0) - val out = ByteArray(cap) - var read = 0 - source.inputStream().use { stream -> - while (read < cap) { - val n = stream.read(out, read, cap - read) - if (n < 0) break - read += n - } - } - return if (read == cap) out else out.copyOf(read) + throw HttpExceptionFactory.fromResponse( + bufferErrorBody(input, HttpException.DEFAULT_SNAPSHOT_BYTES.toLong()), + ) } } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/BackoffCalculator.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/BackoffCalculator.kt index fc0cbb3c..5d5d8042 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/BackoffCalculator.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/BackoffCalculator.kt @@ -12,7 +12,7 @@ import java.util.concurrent.ThreadLocalRandom import kotlin.math.pow /** - * Pure delay computation for [RetryStep]. Combines: + * Pure delay computation for [RetryRecovery]. Combines: * - Square's exponential backoff with symmetric jitter (`delay = initial * multiplier^(n-1)`, * then sampled uniformly from `[delay * (1 - j/2), delay * (1 + j/2)]`). * - gax's per-attempt deadline shrinking — the computed delay is capped so the next diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery.kt similarity index 88% rename from sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStep.kt rename to sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery.kt index 4ccb3ba6..b96c55d1 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStep.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery.kt @@ -9,8 +9,11 @@ package org.dexpace.sdk.core.pipeline.step.retry import org.dexpace.sdk.core.client.HttpClient import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.core.http.response.MAX_BUFFERED_ERROR_BODY_BYTES import org.dexpace.sdk.core.http.response.Response +import org.dexpace.sdk.core.http.response.bufferErrorBody import org.dexpace.sdk.core.http.response.exception.HttpException +import org.dexpace.sdk.core.http.response.exception.HttpExceptionFactory import org.dexpace.sdk.core.http.response.exception.NetworkException import org.dexpace.sdk.core.http.response.exception.Retryable import org.dexpace.sdk.core.pipeline.ResponseOutcome @@ -80,7 +83,7 @@ import java.util.concurrent.TimeUnit * * ## Lifecycle & thread-safety * - * A `RetryStep` instance is **stateless across calls** and therefore safe to share, reuse, + * A `RetryRecovery` instance is **stateless across calls** and therefore safe to share, reuse, * and invoke concurrently — honouring the [ResponseRecoveryStep] contract ("steps are shared * across concurrent requests"). The per-call retry state (attempt count, start instant) is * not held on the instance; it is allocated fresh inside each top-level [invoke]/[attempt] @@ -92,17 +95,17 @@ import java.util.concurrent.TimeUnit * * The [httpClient] and the originating [request] are captured at construction; a single * instance therefore retries exactly that one request template. Construct a distinct - * `RetryStep` per logical request you intend to retry. + * `RetryRecovery` per logical request you intend to retry. * - * Wire it into a pipeline by adding it to the `recoverySteps` of a - * [org.dexpace.sdk.core.pipeline.ResponsePipeline], or invoke [attempt] directly to obtain + * Wire it into a chain by adding it to the `recoverySteps` of a + * [org.dexpace.sdk.core.pipeline.ResponseRecoveryChain], or invoke [attempt] directly to obtain * the final response without the rest of the pipeline machinery. * * @property httpClient Transport used to send retry attempts. Captured at construction. * @property settings Retry configuration; see [RetrySettings] for defaults. * @property request Originating request — used as the template for each retry attempt. */ -public class RetryStep +public class RetryRecovery @JvmOverloads constructor( public val httpClient: HttpClient, @@ -112,7 +115,7 @@ public class RetryStep ) : ResponseRecoveryStep { /** * Per-call retry bookkeeping. Allocated fresh at the top of each [invoke]/[attempt] so - * a shared/reused [RetryStep] never inherits a stale attempt budget and concurrent + * a shared/reused [RetryRecovery] never inherits a stale attempt budget and concurrent * invocations cannot clobber each other's state. * * @property attempt 1-indexed number for the **next** retry; starts at 1 (the delay @@ -156,12 +159,7 @@ public class RetryStep public fun attempt(): Response { // The original send is attempt ordinal 1 — stamp it when the feature is enabled so // the very first request carries the same observable counter the retries will. - val initial: ResponseOutcome = - try { - ResponseOutcome.Success(httpClient.execute(stampAttempt(request, attemptOrdinal = 1))) - } catch (t: Throwable) { - ResponseOutcome.Failure(t) - } + val initial: ResponseOutcome = executeOnce(attemptOrdinal = 1) return when (val final = invoke(initial)) { is ResponseOutcome.Success -> final.response is ResponseOutcome.Failure -> throw final.error @@ -289,11 +287,30 @@ public class RetryStep */ private fun executeOnce(attemptOrdinal: Int): ResponseOutcome = try { - ResponseOutcome.Success(httpClient.execute(stampAttempt(request, attemptOrdinal))) + classify(httpClient.execute(stampAttempt(request, attemptOrdinal))) } catch (t: Throwable) { failureOf(t) } + /** + * Classifies a freshly-sent [response]. Transports return an error-status response rather + * than throwing, so a re-sent response whose status is in [RetrySettings.retryableStatuses] + * is re-mapped to a [ResponseOutcome.Failure] carrying the matching [HttpException] — exactly + * the mapping that turned the first attempt into the failure that triggered this retry. This + * keeps the retry loop re-evaluating the budget (so a `503, 503, 200` sequence reaches the + * `200`) and makes the terminal outcome a typed exception rather than a raw error response. + * The body is buffered into a bounded, replayable in-memory copy so the transport connection + * is released before the next attempt. All other responses pass through as a success. + */ + private fun classify(response: Response): ResponseOutcome = + if (response.status.isError && settings.retryableStatuses.contains(response.status.code)) { + ResponseOutcome.Failure( + HttpExceptionFactory.fromResponse(bufferErrorBody(response, MAX_BUFFERED_ERROR_BODY_BYTES)), + ) + } else { + ResponseOutcome.Success(response) + } + /** * Returns a per-attempt copy of [request] carrying the configured * [RetrySettings.attemptHeaderName] set to [attemptOrdinal]. When no attempt header is @@ -372,8 +389,12 @@ public class RetryStep * (no response, hence no status) passes once its flag is set. */ private fun isClassifiedRetryable(error: Throwable): Boolean { - if (error !is Retryable || !error.isRetryable) return false - return error !is HttpException || settings.retryableStatuses.contains(error.status.code) + // For an HttpException the configured [RetrySettings.retryableStatuses] is authoritative: + // it both widens (a status the built-in classifier does not mark retryable, e.g. 425, is + // retried when configured) and narrows. Non-HttpException throwables (e.g. NetworkException) + // fall back to the [Retryable] flag. + if (error is HttpException) return settings.retryableStatuses.contains(error.status.code) + return error is Retryable && error.isRetryable } /** diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetrySettings.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetrySettings.kt index 9a9c1605..3847848a 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetrySettings.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetrySettings.kt @@ -19,7 +19,7 @@ import java.util.concurrent.ScheduledExecutorService private val MAX_NANO_REPRESENTABLE_DELAY: Duration = Duration.ofNanos(Long.MAX_VALUE) /** - * Immutable configuration for [RetryStep]. + * Immutable configuration for [RetryRecovery]. * * Combines the configurations of two reference implementations: * - Square's `RetryInterceptor` — exponential backoff, `Retry-After` parsing, jitter. @@ -40,7 +40,7 @@ private val MAX_NANO_REPRESENTABLE_DELAY: Duration = Duration.ofNanos(Long.MAX_V * - [retryableStatuses] = `{408, 429, 500, 502, 503, 504}` — canonical retryable codes. * - [retryableMethods] = `{GET, HEAD, OPTIONS, PUT, DELETE}` — safe-by-method per RFC 9110. * `POST`/`PATCH`/etc. retry only when the request body is replayable. - * - [scheduler] = `null` — fall back to the lazy daemon scheduler created by [RetryStep]. + * - [scheduler] = `null` — fall back to the lazy daemon scheduler created by [RetryRecovery]. * - [attemptHeaderName] = `null` — no per-attempt header is stamped (opt-in; see the property). * * ## Thread-safety @@ -64,9 +64,9 @@ private val MAX_NANO_REPRESENTABLE_DELAY: Duration = Duration.ofNanos(Long.MAX_V * [org.dexpace.sdk.core.http.response.exception.HttpException] surfaces with one of them. * @property retryableMethods HTTP methods that may be retried unconditionally (idempotent by * RFC). Non-idempotent methods (`POST`, `PATCH`) only retry when the body is replayable. - * @property scheduler Optional caller-provided scheduler. When `null` [RetryStep] uses a + * @property scheduler Optional caller-provided scheduler. When `null` [RetryRecovery] uses a * process-wide lazy daemon scheduler. - * @property attemptHeaderName Optional request header stamped on each attempt [RetryStep] + * @property attemptHeaderName Optional request header stamped on each attempt [RetryRecovery] * dispatches, carrying the 1-based attempt ordinal (`1` for the original send, `2` for the * first retry, and so on) so servers and proxies can observe the retry count. `null` (the * default) disables the header entirely. The header is set on a per-attempt copy of the @@ -198,9 +198,9 @@ public class RetrySettings } /** - * Sets [RetrySettings.scheduler]. When `null` [RetryStep] uses a process-wide + * Sets [RetrySettings.scheduler]. When `null` [RetryRecovery] uses a process-wide * lazy daemon scheduler. Caller-supplied schedulers are **not** shut down by - * [RetryStep]. + * [RetryRecovery]. */ public fun scheduler(scheduler: ScheduledExecutorService?): RetrySettingsBuilder = apply { @@ -208,7 +208,7 @@ public class RetrySettings } /** - * Sets [RetrySettings.attemptHeaderName]. When non-null, [RetryStep] stamps this + * Sets [RetrySettings.attemptHeaderName]. When non-null, [RetryRecovery] stamps this * header (carrying the 1-based attempt ordinal) on each attempt's request copy. * `null` (the default) leaves attempts unstamped. */ @@ -242,7 +242,7 @@ public class RetrySettings /** * Canonical exponential-backoff multiplier shared by both retry stacks: each delay is * `previous * 2.0`. Exposed so the stage-based [DefaultRetryStep][org.dexpace.sdk.core.http.pipeline.steps.DefaultRetryStep] - * and the recovery-aware [RetryStep] compute the same schedule from one constant. + * and the recovery-aware [RetryRecovery] compute the same schedule from one constant. */ public const val DEFAULT_DELAY_MULTIPLIER: Double = 2.0 @@ -255,7 +255,7 @@ public class RetrySettings /** * Canonical retry budget shared by both stacks: **3 total attempts**, i.e. the initial - * send plus 2 retries. The recovery-aware [RetryStep] counts total attempts directly + * send plus 2 retries. The recovery-aware [RetryRecovery] counts total attempts directly * via [maxAttempts]; the stage-based step counts retries, so its equivalent default is * [DefaultRetryStep.DEFAULT_MAX_RETRIES][org.dexpace.sdk.core.http.pipeline.steps.DefaultRetryStep.DEFAULT_MAX_RETRIES] * `= 2`. Both yield the same 3 sends. @@ -279,7 +279,7 @@ public class RetrySettings * (internal), `502` (bad gateway), `503` (service unavailable), `504` (gateway * timeout). * - * This is the recovery-aware step's allow-list; [RetryStep] intersects it with + * This is the recovery-aware step's allow-list; [RetryRecovery] intersects it with * [HttpException.isRetryable][org.dexpace.sdk.core.http.response.exception.HttpException.isRetryable] * (which is itself derived from [RetryUtils.isRetryable][org.dexpace.sdk.core.util.RetryUtils.isRetryable]). * With this default set the recovery-aware step retries the same common statuses the @@ -301,7 +301,7 @@ public class RetrySettings /** * Default retryable HTTP methods: idempotent methods per RFC 9110 §9.2.2. * POST/PATCH are intentionally excluded — they only retry when the request body - * is replayable (the orthogonal axis checked by [RetryStep.canRetry]). + * is replayable (the orthogonal axis checked by [RetryRecovery.canRetry]). */ @JvmField public val DEFAULT_RETRYABLE_METHODS: Set = diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Deserializer.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Deserializer.kt index 7144aefe..9970407a 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Deserializer.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Deserializer.kt @@ -17,12 +17,13 @@ import java.io.InputStream * runtime — without it, the method-level `` is erased and the only conformant implementation is * an unchecked cast to `Any`, which silently returns a `LinkedHashMap`/`List` and throws * `ClassCastException` at the first field access (heap pollution). The reified - * [deserialize] extension forwards `T::class.java` for the common "the caller already knows `T` at - * compile time" case. + * [deserialize] extension captures `T` as a [TypeRef], so a parametric `T` (`List`) reifies + * its full type and routes through the [TypeRef] overloads rather than erasing to a raw [Class]. * * For parametric targets (`List`, `Map`) the raw [Class] token is - * insufficient; adapter modules expose their own type-reference entry points (e.g. - * `sdk-serde-jackson`'s `JacksonSerde.deserializeAs`). + * insufficient; the [TypeRef] overloads carry the full generic type, and adapter modules that + * resolve generics (e.g. `sdk-serde-jackson`) override them to decode parametric targets. A + * format-agnostic deserializer throws [SerdeException] for a genuinely parametric [TypeRef]. * * Implementations surface decode failures as [DeserializationException] (a [SerdeException] * subtype), chaining the backing codec's error as the cause, so callers catch a single stable SDK @@ -60,17 +61,87 @@ public interface Deserializer { inputStream: InputStream, type: Class, ): T + + /** + * Decode a complete document of the parametric type captured by [ref] from the in-memory [input] + * string. This is the entry point for generic targets (`List`, `Map`) whose + * element types a raw [Class] token would erase. + * + * The default implementation only supports a [ref] whose [TypeRef.type] is a plain [Class] (no + * type arguments), delegating to the [Class] overload; for a genuinely parametric [ref] it throws + * [SerdeException], because a format-agnostic [Deserializer] cannot resolve type arguments without + * codec support. Implementations backed by a codec that resolves generics (e.g. Jackson) override + * this to decode parametric targets. + * + * @throws SerdeException if this deserializer cannot resolve the generic [ref], or (from an + * overriding implementation) [DeserializationException] if [input] is malformed or mismatched. + */ + public fun deserialize( + input: String, + ref: TypeRef, + ): T = deserializeViaRawClass(ref) { rawType -> deserialize(input, rawType) } + + /** + * Decode a complete document of the parametric type captured by [ref] by streaming from + * [inputStream]. The implementation owns reading to EOF but **does not** close the stream — the + * caller retains ownership. See the string overload for the generic-resolution contract. + * + * @throws SerdeException if this deserializer cannot resolve the generic [ref], or (from an + * overriding implementation) [DeserializationException] if the payload is malformed or + * mismatched. + */ + public fun deserialize( + inputStream: InputStream, + ref: TypeRef, + ): T = deserializeViaRawClass(ref) { rawType -> deserialize(inputStream, rawType) } + + /** + * Decode a complete document of the parametric type captured by [ref] from the in-memory [input] + * byte array. See the string overload for the generic-resolution contract. + * + * @throws SerdeException if this deserializer cannot resolve the generic [ref], or (from an + * overriding implementation) [DeserializationException] if [input] is malformed or mismatched. + */ + public fun deserialize( + input: ByteArray, + ref: TypeRef, + ): T = deserializeViaRawClass(ref) { rawType -> deserialize(input, rawType) } +} + +/** + * Shared default-path helper for the [Deserializer] `TypeRef` overloads: if [ref] wraps a plain + * [Class] (no type arguments) invoke [decodeAsClass] with that raw class; otherwise fail, since a + * format-agnostic [Deserializer] has no way to resolve generic type arguments. + */ +@Suppress("UNCHECKED_CAST") +private inline fun deserializeViaRawClass( + ref: TypeRef, + decodeAsClass: (Class) -> T, +): T { + val captured = ref.type + if (captured is Class<*>) { + return decodeAsClass(captured as Class) + } + throw SerdeException( + "This Deserializer does not resolve generic types (${captured.typeName}); use a serde that " + + "overrides the TypeRef overloads to decode parametric targets (e.g. JacksonSerde).", + ) } +// The reified extensions build an anonymous TypeRef rather than forwarding T::class.java, so a +// parametric target (List) reifies its full type and routes through the TypeRef overloads +// instead of erasing to the raw class. A concrete T still decodes exactly as before. + /** Decode a complete document from the in-memory [input] string into the reified type [T]. */ -public inline fun Deserializer.deserialize(input: String): T = deserialize(input, T::class.java) +public inline fun Deserializer.deserialize(input: String): T = deserialize(input, object : TypeRef() {}) /** Decode a complete document from the in-memory [input] byte array into the reified type [T]. */ -public inline fun Deserializer.deserialize(input: ByteArray): T = deserialize(input, T::class.java) +public inline fun Deserializer.deserialize(input: ByteArray): T = + deserialize(input, object : TypeRef() {}) /** * Decode a complete document by streaming from [inputStream] into the reified type [T]. The stream * is read to EOF but **not** closed — the caller retains ownership. */ public inline fun Deserializer.deserialize(inputStream: InputStream): T = - deserialize(inputStream, T::class.java) + deserialize(inputStream, object : TypeRef() {}) diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt index 9679eea4..ea7c677c 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt @@ -7,6 +7,8 @@ package org.dexpace.sdk.core.serde +import org.dexpace.sdk.core.http.common.MediaType + /** * Bundle of serialization / deserialization strategies for a single wire format (JSON, XML, etc.). * @@ -21,4 +23,15 @@ public interface Serde { /** Decoder used for reading values out of wire bytes / strings / streams. */ public val deserializer: Deserializer + + /** + * The media type produced by this serde's serializer. Used as the default `Content-Type` when + * creating a [org.dexpace.sdk.core.http.request.RequestBody] via + * [org.dexpace.sdk.core.http.request.RequestBody.Companion.create]. + * + * Each implementation declares its own wire type (e.g. `application/json` for a JSON serde, + * `application/xml` for an XML serde). This is intentionally not defaulted: a format-agnostic + * default would let a non-JSON serde silently stamp the wrong `Content-Type` on every body. + */ + public fun contentType(): MediaType } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/TypeRef.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/TypeRef.kt new file mode 100644 index 00000000..5b76cadf --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/TypeRef.kt @@ -0,0 +1,98 @@ +/* + * 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.serde + +import java.lang.reflect.GenericArrayType +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.lang.reflect.TypeVariable +import java.lang.reflect.WildcardType +import java.lang.reflect.Array as ReflectArray + +/** + * Captures a full generic [Type] at compile time so a [Deserializer] can materialize parametric + * targets (`List`, `Map`) that a bare [Class] token would erase. + * + * Usage — always as an anonymous subclass, so the type argument is reified into the class's + * generic superclass: + * + * ``` + * val users: List = deserializer.deserialize(json, object : TypeRef>() {}) + * ``` + * + * This is the [Gson](https://github.com/google/gson) `TypeToken` / Jackson `TypeReference` pattern: + * an empty anonymous subclass records `TypeRef>` as its `genericSuperclass`, and the + * constructor reads the `List` type argument back out via reflection. `sdk-core` stays + * dependency-free — this uses only `java.lang.reflect`, no serde library types leak into core. + * + * `sdk-core`'s default [Deserializer.deserialize] can honour a `TypeRef` only when it wraps a plain + * [Class] (no type arguments); a serde that resolves generics (e.g. `JacksonSerde`) overrides the + * `TypeRef` overloads to decode parametric targets. + * + * @param T The type captured by this reference. + */ +public abstract class TypeRef protected constructor() { + /** + * The captured generic [Type]. For `object : TypeRef>() {}` this is the + * [ParameterizedType] `List`; for `object : TypeRef() {}` it is the [Class] `User`. + */ + public val type: Type + + /** + * The erased raw [Class] of [type] — `List` for `List`, `User` for `User`. Useful for + * `instanceof`-style checks and for a [Deserializer] to detect the non-parametric fast path. + */ + public val rawClass: Class<*> + + init { + val superClass = javaClass.genericSuperclass + require(superClass is ParameterizedType) { + "TypeRef must be created as an anonymous subclass with a type argument, " + + "e.g. object : TypeRef>() {}." + } + val captured = superClass.actualTypeArguments.first() + require(!containsTypeVariable(captured)) { + "TypeRef captured an unresolved type variable ($captured). Build it with a concrete " + + "type argument at the call site (e.g. object : TypeRef>() {}); a TypeRef " + + "created inside a generic function or a generic subclass erases its argument to the " + + "bound and would silently decode into the wrong type." + } + type = captured + rawClass = rawClassOf(type) + } + + private companion object { + /** + * True when [type] is, or transitively contains, an unresolved [TypeVariable]. Such a type + * was captured where the argument is erased (a generic function or subclass), so decoding it + * would resolve to `Object` rather than the intended type. + */ + private fun containsTypeVariable(type: Type): Boolean = + when (type) { + is TypeVariable<*> -> true + is ParameterizedType -> type.actualTypeArguments.any(::containsTypeVariable) + is GenericArrayType -> containsTypeVariable(type.genericComponentType) + is WildcardType -> + type.upperBounds.any(::containsTypeVariable) || + type.lowerBounds.any(::containsTypeVariable) + else -> false + } + + /** Resolves the erased raw [Class] backing any reflective [Type]. */ + private fun rawClassOf(type: Type): Class<*> = + when (type) { + is Class<*> -> type + is ParameterizedType -> type.rawType as Class<*> + is GenericArrayType -> + ReflectArray.newInstance(rawClassOf(type.genericComponentType), 0).javaClass + is WildcardType -> rawClassOf(type.upperBounds.first()) + is TypeVariable<*> -> rawClassOf(type.bounds.first()) + else -> Any::class.java + } + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Closeables.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Closeables.kt new file mode 100644 index 00000000..ac608777 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Closeables.kt @@ -0,0 +1,25 @@ +/* + * 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.util + +/** + * Best-effort close that swallows any failure. Use on a discard path — where the value is already + * being dropped (a computed result that lost a completion race, a response being released before a + * retry), so a failure to close it has nothing actionable to surface. Null-safe. + * + * This is the shared silent-drop helper; call sites that need to *log* a dropped close error, or to + * attach it as suppressed onto a primary throwable, deliberately keep their own local variant. + */ +internal fun closeQuietly(closeable: AutoCloseable?) { + if (closeable == null) return + try { + closeable.close() + } catch (ignored: Throwable) { + // Intentionally ignored: the value is already being discarded. + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Futures.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Futures.kt index e47e30b1..0111093b 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Futures.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Futures.kt @@ -9,8 +9,11 @@ package org.dexpace.sdk.core.util import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException +import java.util.concurrent.Executor import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import java.time.Duration as JDuration /** @@ -88,3 +91,107 @@ public object Futures { return future } } + +/** + * 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 InterruptibleFuture : CompletableFuture() { + private val worker = AtomicReference() + + // True only for the brief window in which cancel(true) is delivering the interrupt to the + // worker. unbindWorker() spins on it so the worker cannot return to its pool (and pick up an + // unrelated task) while an interrupt aimed at this call is still in flight — the FutureTask + // handshake that prevents a stale interrupt from poisoning a pooled thread. + private val interrupting = AtomicBoolean(false) + + fun bindWorker(thread: Thread) { + worker.set(thread) + } + + fun unbindWorker() { + worker.set(null) + while (interrupting.get()) { + Thread.yield() + } + } + + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + val cancelled = super.cancel(mayInterruptIfRunning) + if (cancelled && mayInterruptIfRunning) { + val thread = worker.getAndSet(null) + if (thread != null) { + interrupting.set(true) + try { + thread.interrupt() + } finally { + interrupting.set(false) + } + } + } + return cancelled + } +} + +/** + * Submits [task] to [executor] on a [CompletableFuture] whose `cancel(true)` interrupts the worker + * thread running the in-flight [task], while `cancel(false)` cancels without interrupting. A task + * not yet started (still queued on [executor]) or already finished is never interrupted, and the + * worker's interrupt flag is cleared before it returns to the pool so a pooled thread is handed + * back clean. + * + * This is the single interrupting-future construction shared by the sync→async bridges + * ([org.dexpace.sdk.core.client.asAsync] and + * [HttpPipeline.toAsync][org.dexpace.sdk.core.http.pipeline.toAsync]), so both deliver identical + * cancellation semantics. For the interrupt to actually abort blocking I/O, [task] must honour + * `Thread.interrupt()` (the shipped transports do — see the cancellation contract in + * `docs/architecture.md`). + */ +internal fun interruptibleFuture( + executor: Executor, + task: () -> T, +): CompletableFuture { + val result = InterruptibleFuture() + val worker = + Runnable { + // Don't start if the caller already cancelled while we were queued. + if (result.isDone) return@Runnable + 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 task entirely. + if (result.isDone) return@Runnable + val value = task() + // If we lost the completion race — the future was already cancelled (via the narrow + // window of cancel(true), or deterministically via cancel(false)'s run-to-completion + // path) — `complete` returns false and nothing downstream will ever hand `value` + // back to the caller. A `Closeable` value (e.g. a Response holding an open body / + // pooled connection) would then leak, so close it here on the discard path. Mirrors + // the native transports' `if (!future.complete(adapted)) closeQuietly(adapted)`. + if (!result.complete(value) && value is AutoCloseable) { + closeQuietly(value) + } + } 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() + } + } + // A shut-down or saturated executor rejects the task synchronously with an unchecked + // RejectedExecutionException. Surface it through the returned future — never as a synchronous + // throw — so callers that assume async-only failure delivery (the AsyncHttpClient contract) do + // not have the exception escape a method that promised a future. + try { + executor.execute(worker) + } catch (t: Throwable) { + result.completeExceptionally(t) + } + return result +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ProxyOptions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ProxyOptions.kt index 4cf5131f..c2801b9e 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ProxyOptions.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ProxyOptions.kt @@ -28,12 +28,16 @@ import java.util.regex.Pattern * ## Proxy authentication * * Proxy auth is driven by [username] / [password]. **Both shipped transports authenticate the - * proxy with the Basic scheme only:** + * proxy with the Basic scheme only,** but they differ over HTTPS CONNECT tunnels: * - OkHttp transport: its `proxyAuthenticator` emits `Proxy-Authorization: Basic …` from - * [username] / [password]. + * [username] / [password], including on the HTTPS CONNECT tunnel. * - JDK transport: installs a `java.net.Authenticator` on the `java.net.http` client. That * built-in integration answers Basic proxy challenges only; it does not implement Digest - * proxy auth. + * proxy auth. **Caveat:** for an HTTPS origin the `java.net.http` client refuses to send Basic + * proxy credentials on the `CONNECT` tunnel by default — `jdk.http.auth.tunneling.disabledSchemes` + * defaults to `Basic` (JDK-8168839) — so Basic proxy auth silently fails against an `https://` + * origin until the JVM is launched with `-Djdk.http.auth.tunneling.disabledSchemes=` (empty). The + * OkHttp transport has no such restriction. * * Neither transport performs Digest (or any other non-Basic scheme) proxy authentication. To * authenticate against a Digest-only proxy, supply your own pre-configured client — a @@ -177,6 +181,30 @@ public class ProxyOptions return parseProxyUrl(envProxyUrl, nonProxyHosts) } + /** + * Resolves proxy settings from the process-wide [Configuration.getGlobalConfiguration], + * returning the configured [ProxyOptions] or `null` when the environment configures no + * proxy (or bypasses it entirely via `NO_PROXY=*`). A convenience over + * [fromConfiguration] that supplies the global configuration, so callers — chiefly the + * shipped transports' `proxyFromEnvironment()` builder hooks — don't repeat the lookup. + * + * Resolution follows [fromConfiguration]: system properties win over environment + * variables (JDK convention). Recognised sources: + * - System property `https.proxyHost` / `https.proxyPort` / `https.proxyUser` / + * `https.proxyPassword` (preferred), falling back to `http.proxyHost` / + * `http.proxyPort`. + * - System property `http.nonProxyHosts` (pipe-separated, backslash-escapable). + * - Environment variable `HTTPS_PROXY` / `HTTP_PROXY` — full URL form + * `http://user:pass@proxy:8080`. + * - Environment variable `NO_PROXY` — comma-separated; a bare `*` bypasses the proxy for + * every host, in which case this returns `null`. + * + * Strictly opt-in from a transport's perspective: nothing consults the environment until + * this (or [fromConfiguration]) is called. + */ + @JvmStatic + public fun fromEnvironment(): ProxyOptions? = fromConfiguration(Configuration.getGlobalConfiguration()) + /** * Parses a full proxy URL (`http://user:pass@proxy:8080`) into a [ProxyOptions]. * Returns null on any parse/validation failure (proxies are optional — never throw). diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/client/AsyncHttpClientTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/client/AsyncHttpClientTest.kt index 6c19dfad..f99e6350 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/client/AsyncHttpClientTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/client/AsyncHttpClientTest.kt @@ -10,6 +10,7 @@ package org.dexpace.sdk.core.client 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 java.io.IOException @@ -25,6 +26,8 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue class AsyncHttpClientTest { @@ -114,6 +117,119 @@ class AsyncHttpClientTest { assertTrue(never.isCancelled, "the in-flight future must be cancelled on interruption") } + @Test + fun `asAsync future cancel(true) interrupts the in-flight blocking send`() { + // The blocking transport parks on a latch that never fires; cancelling the async future + // with mayInterruptIfRunning=true must interrupt the worker so the send aborts, matching + // the pipeline-level `toAsync` cancellation semantics. + val started = CountDownLatch(1) + val interrupted = AtomicBoolean(false) + val released = CountDownLatch(1) + val neverFires = CountDownLatch(1) + + val blockingClient = + HttpClient { request -> + started.countDown() + try { + neverFires.await() + mockResponse(request, 200) + } catch (e: InterruptedException) { + interrupted.set(true) + Thread.currentThread().interrupt() + released.countDown() + throw IOException("interrupted", e) + } + } + + val future = blockingClient.asAsync(executor).executeAsync(getRequest()) + + assertTrue(started.await(2, TimeUnit.SECONDS), "send should have started") + future.cancel(true) + + assertTrue(released.await(2, TimeUnit.SECONDS), "worker must observe the interrupt") + assertTrue(interrupted.get(), "the in-flight sync send was interrupted") + assertTrue(future.isCancelled, "the returned future reports cancelled") + } + + @Test + fun `asAsync future cancel(false) does not interrupt the worker`() { + // cancel(false) cancels the future without interrupting; a blocking send that ignores + // interrupts therefore runs to completion in the background. + val started = CountDownLatch(1) + val cancelObserved = CountDownLatch(1) + val proceed = CountDownLatch(1) + val finished = CountDownLatch(1) + val sawInterrupt = AtomicBoolean(false) + + val client = + HttpClient { request -> + started.countDown() + cancelObserved.await(2, TimeUnit.SECONDS) + proceed.await(2, TimeUnit.SECONDS) + sawInterrupt.set(Thread.currentThread().isInterrupted) + finished.countDown() + mockResponse(request, 200) + } + + val future = client.asAsync(executor).executeAsync(getRequest()) + + assertTrue(started.await(2, TimeUnit.SECONDS), "send should have started") + future.cancel(false) + cancelObserved.countDown() + proceed.countDown() + + assertTrue(finished.await(2, TimeUnit.SECONDS), "worker should run to completion") + assertTrue(future.isCancelled, "the future reports cancelled") + assertFalse(sawInterrupt.get(), "cancel(false) must not interrupt the worker") + } + + @Test + fun `asAsync threads per-call RequestOptions into the wrapped sync client`() { + // The SPI's 2-arg overload has an options-ignoring default; the bridge must override it so + // a per-call override survives instead of collapsing to RequestOptions.EMPTY. + val seen = AtomicReference(null) + 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(3).tag("k", "v").build() + + syncClient.asAsync(executor).executeAsync(getRequest(), options).get(2, TimeUnit.SECONDS) + + assertSame(options, seen.get(), "per-call options must reach the wrapped sync client") + } + + @Test + fun `asBlocking threads per-call RequestOptions into the wrapped async client`() { + val seen = AtomicReference(null) + val asyncClient = + 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)) + } + } + val options = RequestOptions.builder().maxRetries(0).tag("k", "v").build() + + asyncClient.asBlocking().execute(getRequest(), options) + + assertSame(options, seen.get(), "per-call options must reach the wrapped async client") + } + @Test fun `round-trip via asAsync then asBlocking preserves the response`() { val syncClient = HttpClient { request -> mockResponse(request, 201) } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/ETagTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/ETagTest.kt index c6944fc1..8fb64737 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/ETagTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/ETagTest.kt @@ -153,7 +153,7 @@ class ETagTest { } @Test - fun `equal inline-class instances compare equal and share hashCode`() { + fun `equal instances compare equal and share hashCode`() { val a = ETag.strong("xyz") val b = ETag.strong("xyz") assertEquals(a, b) @@ -164,7 +164,7 @@ class ETagTest { fun `weak and strong with the same opaque are not equal`() { val weak = ETag.weak("xyz") val strong = ETag.strong("xyz") - // Touch the equality path for the inline-class — the underlying raw values differ. + // Touch the equality path — the underlying raw values differ. assertEquals(false, weak == strong) } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeDxTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeDxTest.kt new file mode 100644 index 00000000..fce80dfd --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeDxTest.kt @@ -0,0 +1,73 @@ +/* + * 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.common + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MediaTypeDxTest { + @Test + fun `APPLICATION_JSON companion val has correct fullType`() { + assertEquals("application/json", MediaType.APPLICATION_JSON.fullType) + } + + @Test + fun `APPLICATION_JSON companion val equals CommonMediaTypes APPLICATION_JSON`() { + assertEquals(CommonMediaTypes.APPLICATION_JSON, MediaType.APPLICATION_JSON) + } + + @Test + fun `TEXT_PLAIN companion val has correct fullType`() { + assertEquals("text/plain", MediaType.TEXT_PLAIN.fullType) + } + + @Test + fun `TEXT_PLAIN companion val equals CommonMediaTypes TEXT_PLAIN`() { + assertEquals(CommonMediaTypes.TEXT_PLAIN, MediaType.TEXT_PLAIN) + } + + @Test + fun `APPLICATION_OCTET_STREAM companion val has correct fullType`() { + assertEquals("application/octet-stream", MediaType.APPLICATION_OCTET_STREAM.fullType) + } + + @Test + fun `APPLICATION_OCTET_STREAM companion val equals CommonMediaTypes APPLICATION_OCTET_STREAM`() { + assertEquals(CommonMediaTypes.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_OCTET_STREAM) + } + + @Test + fun `APPLICATION_FORM_URLENCODED companion val has correct fullType`() { + assertEquals("application/x-www-form-urlencoded", MediaType.APPLICATION_FORM_URLENCODED.fullType) + } + + @Test + fun `APPLICATION_FORM_URLENCODED companion val equals CommonMediaTypes APPLICATION_FORM_URLENCODED`() { + assertEquals(CommonMediaTypes.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_FORM_URLENCODED) + } + + @Test + fun `TEXT_HTML companion val has correct fullType`() { + assertEquals("text/html", MediaType.TEXT_HTML.fullType) + } + + @Test + fun `TEXT_HTML companion val equals CommonMediaTypes TEXT_HTML`() { + assertEquals(CommonMediaTypes.TEXT_HTML, MediaType.TEXT_HTML) + } + + @Test + fun `APPLICATION_XML companion val has correct fullType`() { + assertEquals("application/xml", MediaType.APPLICATION_XML.fullType) + } + + @Test + fun `APPLICATION_XML companion val equals CommonMediaTypes APPLICATION_XML`() { + assertEquals(CommonMediaTypes.APPLICATION_XML, MediaType.APPLICATION_XML) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeTest.kt index dc52befe..e2334b39 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeTest.kt @@ -16,6 +16,27 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class MediaTypeTest { + // ---- companion constants ---------------------------------------------------- + + @Test + fun `common constants are populated and match their canonical parsed form`() { + // Guards the class-initialization contract: these @JvmField constants build themselves via + // of(...) and must never be null (a cross-init cycle with CommonMediaTypes once left them so). + assertEquals(MediaType.of("application", "json"), MediaType.APPLICATION_JSON) + assertEquals(MediaType.of("text", "plain"), MediaType.TEXT_PLAIN) + assertEquals(MediaType.of("application", "octet-stream"), MediaType.APPLICATION_OCTET_STREAM) + assertEquals(MediaType.of("application", "x-www-form-urlencoded"), MediaType.APPLICATION_FORM_URLENCODED) + assertEquals(MediaType.of("text", "html"), MediaType.TEXT_HTML) + assertEquals(MediaType.of("application", "xml"), MediaType.APPLICATION_XML) + } + + @Test + fun `common constants equal their CommonMediaTypes counterparts`() { + assertEquals(CommonMediaTypes.APPLICATION_JSON, MediaType.APPLICATION_JSON) + assertEquals(CommonMediaTypes.TEXT_PLAIN, MediaType.TEXT_PLAIN) + assertEquals(CommonMediaTypes.APPLICATION_XML, MediaType.APPLICATION_XML) + } + // ---- parse ------------------------------------------------------------------ @Test diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/RequestConditionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/RequestConditionsTest.kt index 3284bc0e..8582b880 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/RequestConditionsTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/RequestConditionsTest.kt @@ -11,6 +11,7 @@ import org.dexpace.sdk.core.util.DateTimeRfc1123 import java.time.Instant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -263,6 +264,90 @@ class RequestConditionsTest { assertEquals(second, rc.ifUnmodifiedSince) } + @Test + fun `ifMatch String overload parses the raw header value like the typed path`() { + val typed = Headers.builder() + RequestConditions.builder().ifMatch(ETag.strong("abc")).build().applyTo(typed) + + val fromString = Headers.builder() + RequestConditions.builder().ifMatch("\"abc\"").build().applyTo(fromString) + + assertEquals( + typed.build().get(HttpHeaderName.IF_MATCH), + fromString.build().get(HttpHeaderName.IF_MATCH), + ) + } + + @Test + fun `ifMatch String overload produces the same ETag entries as the typed overload`() { + val fromString = RequestConditions.builder().ifMatch("\"abc\"").build() + val typed = RequestConditions.builder().ifMatch(ETag.strong("abc")).build() + assertEquals(typed.ifMatch, fromString.ifMatch) + assertEquals(ETag.strong("abc"), fromString.ifMatch.single()) + } + + @Test + fun `ifNoneMatch String overload parses weak and star forms`() { + val weak = RequestConditions.builder().ifNoneMatch("W/\"v1\"").build() + assertEquals(listOf(ETag.weak("v1")), weak.ifNoneMatch) + + val star = RequestConditions.builder().ifNoneMatch("*").build() + assertEquals(listOf(ETag.ANY), star.ifNoneMatch) + } + + @Test + fun `build rejects mixing star with a concrete entity-tag`() { + assertFailsWith { + RequestConditions.builder() + .ifNoneMatch("W/\"v1\"") + .ifNoneMatch("*") + .build() + } + assertFailsWith { + RequestConditions.builder() + .ifMatch(ETag.ANY) + .ifMatch(ETag.strong("v1")) + .build() + } + } + + @Test + fun `build collapses repeated star to a single star`() { + val rc = RequestConditions.builder().ifMatch(ETag.ANY).ifMatch(ETag.ANY).build() + assertEquals(listOf(ETag.ANY), rc.ifMatch) + } + + @Test + fun `String and typed ifMatch overloads interleave in call order`() { + val rc = + RequestConditions.builder() + .ifMatch("\"a\"") + .ifMatch(ETag.weak("b")) + .build() + assertEquals(listOf(ETag.strong("a"), ETag.weak("b")), rc.ifMatch) + } + + @Test + fun `ifMatch String overload rejects a blank value`() { + assertFailsWith { + RequestConditions.builder().ifMatch(" ") + } + } + + @Test + fun `ifMatch String overload rejects a malformed value`() { + assertFailsWith { + RequestConditions.builder().ifMatch("malformed") + } + } + + @Test + fun `ifNoneMatch String overload rejects a blank value`() { + assertFailsWith { + RequestConditions.builder().ifNoneMatch("") + } + } + @Test fun `companion builder returns an empty Builder`() { val rc = RequestConditions.builder().build() diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineTest.kt index 72e1648a..61db771e 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineTest.kt @@ -12,8 +12,12 @@ 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.dexpace.sdk.core.pagination.AsyncPaginator +import org.dexpace.sdk.core.pagination.PageInfo +import org.dexpace.sdk.core.pagination.PaginationStrategy import java.io.IOException import java.io.InterruptedIOException import java.net.URL @@ -28,6 +32,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertSame import kotlin.test.assertTrue @@ -48,6 +53,35 @@ class AsyncHttpPipelineTest { assertEquals(200, response.status.code) } + @Test + fun `sendAsync threads RequestOptions to the transport and steps read them`() { + val client = OptionsRecordingAsyncClient() + val seenByStep = AtomicReference(null) + val step = + TestStep(Stage.PRE_SEND) { _, next -> + seenByStep.set(next.options) + next.processAsync() + } + val pipeline = AsyncHttpPipelineBuilder(client).append(step).build() + val options = RequestOptions.builder().maxRetries(0).tag("k", "v").build() + + pipeline.sendAsync(getRequest(), options).join() + + assertSame(options, seenByStep.get(), "the step must read options via next.options") + assertSame(options, client.seenOptions.single(), "options must reach the terminal dispatch") + } + + @Test + fun `empty async pipeline threads options straight to the transport`() { + val client = OptionsRecordingAsyncClient() + val pipeline = AsyncHttpPipelineBuilder(client).build() + val options = RequestOptions.builder().maxRetries(2).build() + + pipeline.sendAsync(getRequest(), options).join() + + assertSame(options, client.seenOptions.single()) + } + @Test fun `sync transport throw inside an empty pipeline becomes a failed future`() { // A sync `Exception` thrown by a misbehaving AsyncHttpClient must surface as a @@ -165,6 +199,31 @@ class AsyncHttpPipelineTest { assertEquals(204, response.status.code) } + @Test + fun `toAsync threads per-call RequestOptions into the wrapped sync pipeline`() { + // sendAsync(request, options) through the async facade must reach the sync pipeline's + // terminal dispatch as `execute(request, options)`, not collapse to RequestOptions.EMPTY. + val seen = AtomicReference(null) + val client = + 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 sync = HttpPipelineBuilder(client).build() + val options = RequestOptions.builder().maxRetries(0).tag("k", "v").build() + + sync.toAsync(executor).sendAsync(getRequest(), options).get(2, TimeUnit.SECONDS) + + assertSame(options, seen.get(), "per-call options must survive the sync→async bridge") + } + @Test fun `toAsync future cancel(true) interrupts the in-flight sync send`() { // A blocking transport awaits an (interruptible) latch. Cancelling the async future with @@ -250,6 +309,19 @@ class AsyncHttpPipelineTest { assertEquals("oops", thrown.message) } + @Test + fun `toBlocking threads per-call RequestOptions into the wrapped async pipeline`() { + // send(request, options) through the blocking facade must reach the async pipeline's + // terminal dispatch as `executeAsync(request, options)`, not collapse to RequestOptions.EMPTY. + val client = OptionsRecordingAsyncClient() + val async = AsyncHttpPipelineBuilder(client).build() + val options = RequestOptions.builder().maxRetries(2).tag("k", "v").build() + + async.toBlocking().send(getRequest(), options) + + assertSame(options, client.seenOptions.single(), "per-call options must survive the async→sync bridge") + } + @Test fun `toBlocking honours interruption, throwing InterruptedIOException with the flag set`() { // A future that never completes, so toBlocking parks in get() until interrupted. @@ -347,15 +419,87 @@ class AsyncHttpPipelineTest { } @Test - fun `installing two pillars at the same stage emits a replacement warning and keeps the latest`() { - val firstAuth = TestPillarStep(Stage.AUTH) - val secondAuth = TestPillarStep(Stage.AUTH) + fun `appending a distinct second pillar step throws IllegalStateException`() { + val builder = + AsyncHttpPipelineBuilder(constantClient(200)) + .append(TestPillarStep(Stage.AUTH)) + + val ex = assertFailsWith { builder.append(TestPillarStep(Stage.AUTH)) } + assertTrue(ex.message!!.contains("AUTH"), "message names the pillar stage: ${ex.message}") + assertTrue(ex.message!!.contains("replace"), "message points at replace(): ${ex.message}") + } + + @Test + fun `same pillar instance re-appended is idempotent`() { + val auth = TestPillarStep(Stage.AUTH) val pipeline = AsyncHttpPipelineBuilder(constantClient(200)) - .append(firstAuth) - .append(secondAuth) + .append(auth) + .append(auth) .build() - assertSame(secondAuth, pipeline.steps.single()) + assertSame(auth, pipeline.steps.single()) + } + + @Test + fun `replace swaps a pillar step in place`() { + val replacement = TestPillarStep(Stage.AUTH) + val pipeline = + AsyncHttpPipelineBuilder(constantClient(200)) + .append(TestPillarStep(Stage.AUTH)) + .replace(replacement) + .build() + assertSame(replacement, pipeline.steps.single()) + } + + @Test + fun `executeAsync conforms to the AsyncHttpClient SPI and routes through the steps`() { + val recorded = AtomicReference() + val transportClient = + AsyncHttpClient { request -> + recorded.set(request) + CompletableFuture.completedFuture(mockResponse(request, 200)) + } + val headerStep = + TestStep(Stage.PRE_SEND) { req, next -> + next.processAsync(req.newBuilder().setHeader("X-Pipeline", "on").build()) + } + // Typed as the transport SPI to prove AsyncHttpPipeline conforms to AsyncHttpClient. + val transport: AsyncHttpClient = AsyncHttpPipelineBuilder(transportClient).append(headerStep).build() + + val response = transport.executeAsync(getRequest()).join() + + assertEquals(200, response.status.code) + assertEquals("on", recorded.get().headers.get("X-Pipeline")) + } + + @Test + fun `AsyncPaginator over the pipeline as AsyncHttpClient walks two pages through the steps`() { + val fetches = AtomicInteger(0) + val transportClient = + AsyncHttpClient { request -> + fetches.incrementAndGet() + CompletableFuture.completedFuture(mockResponse(request, 200)) + } + val stepRuns = AtomicInteger(0) + val countingStep = + TestStep(Stage.PRE_SEND) { _, next -> + stepRuns.incrementAndGet() + next.processAsync() + } + val transport: AsyncHttpClient = AsyncHttpPipelineBuilder(transportClient).append(countingStep).build() + + val pageCount = AtomicInteger(0) + val strategy = + PaginationStrategy { _, initial -> + val n = pageCount.incrementAndGet() + PageInfo(listOf("item-$n"), if (n < 2) initial else null) + } + + val items = AsyncPaginator(transport, getRequest(), strategy).collectAllAsync().join() + + assertEquals(listOf("item-1", "item-2"), items) + assertEquals(2, fetches.get(), "two page fetches routed through the pipeline") + assertEquals(2, stepRuns.get(), "the pipeline step ran on each page") } @Test @@ -424,6 +568,22 @@ class AsyncHttpPipelineTest { private fun constantClient(code: Int): AsyncHttpClient = AsyncHttpClient { request -> CompletableFuture.completedFuture(mockResponse(request, code)) } + /** Async transport recording the [RequestOptions] threaded into the per-call overload. */ + private inner class OptionsRecordingAsyncClient : AsyncHttpClient { + val seenOptions: MutableList = ArrayList() + + override fun executeAsync(request: Request): CompletableFuture = + executeAsync(request, RequestOptions.EMPTY) + + override fun executeAsync( + request: Request, + options: RequestOptions, + ): CompletableFuture { + seenOptions.add(options) + return CompletableFuture.completedFuture(mockResponse(request, 200)) + } + } + private fun getRequest(): Request = Request.builder() .method(Method.GET) diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlersTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlersTest.kt new file mode 100644 index 00000000..39468b91 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlersTest.kt @@ -0,0 +1,134 @@ +/* + * 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 + +import org.dexpace.sdk.core.client.AsyncHttpClient +import org.dexpace.sdk.core.http.common.MediaType +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.response.Response +import org.dexpace.sdk.core.http.response.ResponseBody +import org.dexpace.sdk.core.http.response.ResponseHandler +import org.dexpace.sdk.core.http.response.Status +import org.dexpace.sdk.core.http.response.exception.NetworkException +import org.dexpace.sdk.core.io.BufferedSource +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.util.Futures +import org.dexpace.sdk.io.OkioIoProvider +import java.io.IOException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class AsyncResponseHandlersTest { + @BeforeTest + fun installProvider() { + Io.installProvider(OkioIoProvider) + } + + @Test + fun `handleWith applies the handler on success and closes the response`() { + val body = FlagBody("hello world") + val future = CompletableFuture.completedFuture(responseWith(body)) + + val result = future.handleWith(ResponseHandler.string()).join() + + assertEquals("hello world", result) + assertTrue(body.closed.get(), "the response must be closed after handling") + } + + @Test + fun `handleWith closes the response even when the handler does not`() { + val body = FlagBody("ignored") + val future = CompletableFuture.completedFuture(responseWith(body)) + // A handler that reads nothing and does not close — handleWith must still close. + val nonClosing = ResponseHandler { 42 } + + val result = future.handleWith(nonClosing).join() + + assertEquals(42, result) + assertTrue(body.closed.get(), "handleWith must guarantee the response is closed") + } + + @Test + fun `handleWith surfaces the unwrapped cause on failure, not the CompletionException wrapper`() { + val boom = NetworkException("connection reset") + val future = Futures.failed(boom) + + val ee = assertFailsWith { future.handleWith(ResponseHandler.string()).get() } + + // get() unwraps one CompletionException layer; the cause must be the original network + // failure, not a nested CompletionException wrapper. + assertSame(boom, ee.cause) + assertSame(boom, Futures.unwrap(ee)) + } + + @Test + fun `handleWith propagates a handler IOException as an exceptional completion`() { + val body = FlagBody("data") + val future = CompletableFuture.completedFuture(responseWith(body)) + val failing = ResponseHandler { throw IOException("parse failed") } + + val ee = assertFailsWith { future.handleWith(failing).get() } + + assertTrue(ee.cause is IOException) + assertEquals("parse failed", ee.cause?.message) + assertTrue(body.closed.get(), "the response must still be closed when the handler throws") + } + + @Test + fun `sendAsync with handler pipes the response through handleWith`() { + val body = FlagBody("payload") + val client = AsyncHttpClient { CompletableFuture.completedFuture(responseWith(body)) } + val pipeline = AsyncHttpPipelineBuilder(client).build() + + val result = pipeline.sendAsync(request(), ResponseHandler.string()).join() + + assertEquals("payload", result) + assertTrue(body.closed.get(), "the response must be closed after handling") + } + + // -- Helpers ----------------------------------------------------------------------------- + + private class FlagBody(payload: String) : ResponseBody() { + val closed = AtomicBoolean(false) + private val source = Io.provider.source(payload.toByteArray()) + + override fun mediaType(): MediaType? = null + + override fun contentLength(): Long = -1L + + override fun source(): BufferedSource = source + + override fun close() { + closed.set(true) + source.close() + } + } + + private fun request(): Request = + Request.builder() + .url("https://api.example.test/p") + .method(Method.GET) + .build() + + private fun responseWith(body: ResponseBody): Response = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .body(body) + .build() +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineTest.kt index 2b0dc8d3..3defb071 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineTest.kt @@ -9,14 +9,23 @@ package org.dexpace.sdk.core.http.pipeline import org.dexpace.sdk.core.client.HttpClient import org.dexpace.sdk.core.http.common.Protocol +import org.dexpace.sdk.core.http.pipeline.steps.DefaultRetryStep +import org.dexpace.sdk.core.http.pipeline.steps.RetryStep 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.dexpace.sdk.core.pagination.PageInfo +import org.dexpace.sdk.core.pagination.PaginationStrategy +import org.dexpace.sdk.core.pagination.Paginator +import java.time.Duration import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -37,6 +46,28 @@ private class RecordingHttpClient : HttpClient { } } +/** + * Transport that records the [RequestOptions] threaded into the per-call `execute` overload — the + * seam a pipeline uses to hand a caller's options to the transport. + */ +private class OptionsRecordingHttpClient : HttpClient { + val seenOptions: MutableList = ArrayList() + + override fun execute(request: Request): Response = execute(request, RequestOptions.EMPTY) + + override fun execute( + request: Request, + options: RequestOptions, + ): Response { + seenOptions.add(options) + return Response.builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .build() + } +} + /** Records the order in which steps run by appending its tag to a shared list. */ private class TaggingStep( override val stage: Stage, @@ -67,6 +98,118 @@ class HttpPipelineTest { assertTrue(pipeline.steps.isEmpty()) } + @Test + fun `empty pipeline threads RequestOptions to the transport per-call overload`() { + val client = OptionsRecordingHttpClient() + val pipeline = HttpPipelineBuilder(client).build() + val options = RequestOptions.builder().timeout(Duration.ofSeconds(2)).maxRetries(1).build() + + pipeline.send(request(), options) + + assertEquals(1, client.seenOptions.size) + assertSame(options, client.seenOptions[0], "empty pipeline must pass options straight through") + } + + @Test + fun `send without options threads EMPTY to the transport`() { + val client = OptionsRecordingHttpClient() + val pipeline = HttpPipelineBuilder(client).build() + + pipeline.send(request()) + + assertEquals(RequestOptions.EMPTY, client.seenOptions.single()) + } + + @Test + fun `steps see the caller's options and the terminal dispatch threads them through`() { + val client = OptionsRecordingHttpClient() + val seenByStep = AtomicReference(null) + val readingStep = + object : HttpStep { + override val stage: Stage = Stage.PRE_SEND + + override fun process( + request: Request, + next: PipelineNext, + ): Response { + seenByStep.set(next.options) + return next.process() + } + } + val pipeline = HttpPipelineBuilder(client).append(readingStep).build() + val options = RequestOptions.builder().maxRetries(0).tag("k", "v").build() + + pipeline.send(request(), options) + + assertSame(options, seenByStep.get(), "the step must read the caller's options via next.options") + assertSame(options, client.seenOptions.single(), "options must reach the terminal dispatch") + } + + @Test + fun `execute request through the HttpClient SPI threads EMPTY options`() { + val client = OptionsRecordingHttpClient() + val transport: HttpClient = HttpPipelineBuilder(client).build() + + transport.execute(request()) + + assertNull(client.seenOptions.single().maxRetries) + assertEquals(RequestOptions.EMPTY, client.seenOptions.single()) + } + + @Test + fun `execute conforms to the HttpClient SPI and routes through the pipeline steps`() { + val client = RecordingHttpClient() + val headerStep = + object : HttpStep { + override val stage: Stage = Stage.PRE_SEND + + override fun process( + request: Request, + next: PipelineNext, + ): Response = next.process(request.newBuilder().setHeader("X-Pipeline", "on").build()) + } + // Typed as the transport SPI to prove HttpPipeline conforms to HttpClient. + val transport: HttpClient = HttpPipelineBuilder(client).append(headerStep).build() + + val response = transport.execute(request()) + + assertEquals(Status.OK, response.status) + assertEquals(1, client.requests.size) + assertEquals("on", client.requests[0].headers.get("X-Pipeline")) + } + + @Test + fun `Paginator over the pipeline as HttpClient walks two pages through the steps`() { + val client = RecordingHttpClient() + val stepRuns = AtomicInteger(0) + val countingStep = + object : HttpStep { + override val stage: Stage = Stage.PRE_SEND + + override fun process( + request: Request, + next: PipelineNext, + ): Response { + stepRuns.incrementAndGet() + return next.process() + } + } + val transport: HttpClient = HttpPipelineBuilder(client).append(countingStep).build() + + val pageCount = AtomicInteger(0) + val strategy = + PaginationStrategy { _, initial -> + val n = pageCount.incrementAndGet() + PageInfo(listOf("item-$n"), if (n < 2) initial else null) + } + + val items = Paginator(transport, request(), strategy).iterateAll().toList() + + assertEquals(listOf("item-1", "item-2"), items) + assertEquals(2, client.requests.size, "two page fetches routed through the pipeline") + assertEquals(2, stepRuns.get(), "the pipeline step ran on each page") + } + @Test fun `single step runs then HttpClient`() { val client = RecordingHttpClient() @@ -130,23 +273,30 @@ class HttpPipelineTest { } @Test - fun `pillar replace at same stage emits warning and keeps newest`() { + fun `appending a distinct second pillar step throws IllegalStateException`() { val client = RecordingHttpClient() - val order = mutableListOf() - val firstRetry = TaggingStep(Stage.RETRY, "retry-A", order) - val secondRetry = TaggingStep(Stage.RETRY, "retry-B", order) + val builder = + HttpPipelineBuilder(client) + .append(DefaultRetryStep()) + + val ex = assertFailsWith { builder.append(DefaultRetryStep()) } + assertTrue(ex.message!!.contains("RETRY"), "message names the pillar stage: ${ex.message}") + assertTrue(ex.message!!.contains("replace"), "message points at replace(): ${ex.message}") + } + + @Test + fun `replace swaps a pillar step in place`() { + val client = RecordingHttpClient() + val replacement = DefaultRetryStep() val pipeline = HttpPipelineBuilder(client) - .append(firstRetry) - .append(secondRetry) + .append(DefaultRetryStep()) + .replace(replacement) .build() - pipeline.send(request()) - - // Only the second retry-step is present; the first is replaced. assertEquals(1, pipeline.steps.size) - assertEquals(listOf("retry-B"), order) + assertSame(replacement, pipeline.steps[0]) } @Test @@ -600,6 +750,51 @@ class HttpPipelineTest { assertEquals(2, copy.steps.size) } + @Test + fun `surgical insert that would duplicate a pillar throws and leaves the builder intact`() { + // insertAfter re-buckets by reloading the flattened step list. A second distinct RETRY-stage + // step would create a duplicate RETRY pillar; the reload is atomic, so the collision throws + // and the builder's prior steps survive — build() still yields exactly the original pillar. + val client = RecordingHttpClient() + val originalRetry = DefaultRetryStep() + val builder = HttpPipelineBuilder(client).append(originalRetry) + + assertFailsWith { + builder.insertAfter(DefaultRetryStep()) + } + + val pipeline = builder.build() + assertEquals(1, pipeline.steps.size, "the rejected insert must not leave a duplicate pillar behind") + assertSame(originalRetry, pipeline.steps[0], "the original retry pillar must remain intact") + } + + @Test + fun `HttpPipelineBuilder of an HttpPipeline flattens rather than nesting`() { + // A statically-typed HttpPipeline argument resolves to the flattening constructor: the + // source pipeline's steps are copied into the new builder (same count/order as from()), NOT + // nested as an opaque transport — which would leave the outer builder with zero steps. + val client = RecordingHttpClient() + val order = mutableListOf() + val retryStep = TaggingStep(Stage.RETRY, "retry", order) + val preAuthStep = TaggingStep(Stage.PRE_AUTH, "pre-auth", order) + val base = HttpPipelineBuilder(client).append(retryStep).append(preAuthStep).build() + + val viaConstructor = HttpPipelineBuilder(base).build() + val viaFrom = HttpPipelineBuilder.from(base).build() + + // Same step count/order as from() — proving flatten, not nest. + assertEquals(viaFrom.steps.size, viaConstructor.steps.size) + assertEquals(2, viaConstructor.steps.size) + assertSame(retryStep, viaConstructor.steps[0]) + assertSame(preAuthStep, viaConstructor.steps[1]) + // The transport is the base's transport, not the base pipeline nested as a transport. + assertSame(client, viaConstructor.httpClient) + + // Both steps actually run when sent (a nested pipeline would run zero outer steps). + viaConstructor.send(request()) + assertEquals(listOf("retry", "pre-auth"), order) + } + @Test fun `appendAll with an empty list is a no-op`() { val client = RecordingHttpClient() @@ -626,26 +821,17 @@ class HttpPipelineTest { } @Test - fun `pillar prepend at a pillar stage replaces with a warning event`() { + fun `prepending a distinct second pillar step throws IllegalStateException`() { // Branch coverage on HttpPipelineBuilder.prepend: pillar stages route through - // installPillar regardless of head/tail intent. Two distinct retry steps must - // collapse to the second one with a warning log. + // installPillar regardless of head/tail intent, so a distinct second retry step + // must fail fast rather than silently collapse. val client = RecordingHttpClient() - val order = mutableListOf() - val first = TaggingStep(Stage.RETRY, "retry-1", order) - val second = TaggingStep(Stage.RETRY, "retry-2", order) - - val pipeline = + val builder = HttpPipelineBuilder(client) - .prepend(first) - .prepend(second) - .build() + .prepend(DefaultRetryStep()) - pipeline.send(request()) - - // Second prepend replaces the first via installPillar. - assertEquals(1, pipeline.steps.size) - assertEquals(listOf("retry-2"), order) + val ex = assertFailsWith { builder.prepend(DefaultRetryStep()) } + assertTrue(ex.message!!.contains("RETRY"), "message names the pillar stage: ${ex.message}") } @Test diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StagedStepsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StagedStepsTest.kt index 8fdbbbfb..5575737a 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StagedStepsTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StagedStepsTest.kt @@ -27,60 +27,40 @@ class StagedStepsTest { */ private class FakeStep(val stage: Stage, val tag: String) - private fun stagedSteps( - onPillarReplaced: (Stage, FakeStep, FakeStep) -> Unit = { _, _, _ -> }, - ): StagedSteps = StagedSteps(stageOf = { it.stage }, onPillarReplaced = onPillarReplaced) + private fun stagedSteps(): StagedSteps = StagedSteps(stageOf = { it.stage }) - // -------- reload: dup-pillar warning -------- + // -------- reload: pillar occupancy is enforced -------- @Test - fun `reload with two pillar steps for the same stage fires onPillarReplaced`() { - val replacements = mutableListOf>() - val staged = - stagedSteps { stage, old, new -> - replacements.add(Triple(stage, old, new)) - } + fun `reload with two distinct pillar steps for the same stage throws`() { + val staged = stagedSteps() val firstRetry = FakeStep(Stage.RETRY, "retry-A") val secondRetry = FakeStep(Stage.RETRY, "retry-B") - staged.reload(listOf(firstRetry, secondRetry)) - - assertEquals(1, replacements.size, "onPillarReplaced should fire exactly once") - val (stage, old, new) = replacements[0] - assertEquals(Stage.RETRY, stage) - assertEquals("retry-A", old.tag) - assertEquals("retry-B", new.tag) - - // The second (newer) pillar wins. - assertEquals("retry-B", staged.pillarAt(Stage.RETRY)?.tag) + val ex = + assertFailsWith { + staged.reload(listOf(firstRetry, secondRetry)) + } + assertTrue(ex.message!!.contains("RETRY"), "message names the pillar stage: ${ex.message}") } @Test - fun `reload with single pillar step does not fire onPillarReplaced`() { - val replacements = mutableListOf>() - val staged = - stagedSteps { stage, old, new -> - replacements.add(Triple(stage, old, new)) - } + fun `reload with single pillar step does not throw`() { + val staged = stagedSteps() staged.reload(listOf(FakeStep(Stage.RETRY, "retry-A"))) - assertTrue(replacements.isEmpty(), "onPillarReplaced must not fire on first install") + assertEquals("retry-A", staged.pillarAt(Stage.RETRY)?.tag) } @Test - fun `reload same pillar instance twice does not fire onPillarReplaced`() { - val replacements = mutableListOf>() - val staged = - stagedSteps { stage, old, new -> - replacements.add(Triple(stage, old, new)) - } + fun `reload same pillar instance twice does not throw`() { + val staged = stagedSteps() val retry = FakeStep(Stage.RETRY, "retry") staged.reload(listOf(retry, retry)) - assertTrue(replacements.isEmpty(), "same-instance re-install must not fire onPillarReplaced") assertEquals("retry", staged.pillarAt(Stage.RETRY)?.tag) } @@ -107,24 +87,46 @@ class StagedStepsTest { assertTrue(staged.pillarAt(Stage.PRE_AUTH) == null) } + @Test + fun `reload that hits a pillar collision leaves prior state intact`() { + // reload commits atomically: it assembles the new state into scratch collections and only + // reassigns the live storage once the whole rebuild succeeds. A mid-rebuild pillar collision + // must therefore throw with the pre-reload state completely untouched — never half-reloaded. + val staged = stagedSteps() + staged.append(FakeStep(Stage.RETRY, "retry")) + staged.append(FakeStep(Stage.PRE_AUTH, "pre")) + + assertFailsWith { + staged.reload(listOf(FakeStep(Stage.RETRY, "retry-A"), FakeStep(Stage.RETRY, "retry-B"))) + } + + assertEquals("retry", staged.pillarAt(Stage.RETRY)?.tag, "the original pillar must survive the failed reload") + assertEquals(listOf("pre"), staged.nonPillarAt(Stage.PRE_AUTH).map { it.tag }, "non-pillar steps must survive") + } + // -------- append / prepend pillar routing -------- @Test - fun `append two distinct pillar steps fires onPillarReplaced`() { - val replacements = mutableListOf>() - val staged = - stagedSteps { stage, old, new -> - replacements.add(Triple(stage, old, new)) - } + fun `append two distinct pillar steps throws IllegalStateException`() { + val staged = stagedSteps() val a = FakeStep(Stage.AUTH, "auth-A") val b = FakeStep(Stage.AUTH, "auth-B") staged.append(a) - staged.append(b) - assertEquals(1, replacements.size) - assertEquals("auth-A", replacements[0].second.tag) - assertEquals("auth-B", replacements[0].third.tag) + val ex = assertFailsWith { staged.append(b) } + assertTrue(ex.message!!.contains("AUTH"), "message names the pillar stage: ${ex.message}") + } + + @Test + fun `append same pillar instance twice does not throw`() { + val staged = stagedSteps() + + val auth = FakeStep(Stage.AUTH, "auth") + staged.append(auth) + staged.append(auth) + + assertEquals("auth", staged.pillarAt(Stage.AUTH)?.tag) } // -------- nonPillarAt / pillarAt return defensive copies -------- diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StandardResilienceTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StandardResilienceTest.kt new file mode 100644 index 00000000..729f747e --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StandardResilienceTest.kt @@ -0,0 +1,185 @@ +/* + * 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 + +import org.dexpace.sdk.core.client.AsyncHttpClient +import org.dexpace.sdk.core.http.common.Protocol +import org.dexpace.sdk.core.http.pipeline.steps.DefaultAsyncInstrumentationStep +import org.dexpace.sdk.core.http.pipeline.steps.DefaultAsyncRetryStep +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.request.Method +import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.core.http.response.Response +import org.dexpace.sdk.core.http.response.Status +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.testing.FakeHttpClient +import org.dexpace.sdk.io.OkioIoProvider +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class StandardResilienceTest { + private lateinit var scheduler: ScheduledExecutorService + + @BeforeTest + fun setUp() { + Io.installProvider(OkioIoProvider) + scheduler = Executors.newSingleThreadScheduledExecutor() + } + + @AfterTest + fun tearDown() { + scheduler.shutdownNow() + } + + @Test + fun `HttpPipeline standard wires redirect retry and instrumentation pillars`() { + val pipeline = HttpPipeline.standard(FakeHttpClient()) + + assertEquals(3, pipeline.steps.size) + assertTrue(pipeline.steps.any { it is DefaultRedirectStep }, "redirect pillar present") + assertTrue(pipeline.steps.any { it is DefaultRetryStep }, "retry pillar present") + assertTrue(pipeline.steps.any { it is DefaultInstrumentationStep }, "instrumentation pillar present") + } + + @Test + fun `HttpPipeline standard retries a retryable 503 then succeeds`() { + val fake = + FakeHttpClient() + .enqueue { status(503) } + .enqueue { status(200).body("ok") } + + val response = HttpPipeline.standard(fake).send(getRequest()) + + assertEquals(200, response.status.code) + assertEquals(2, fake.callCount) + response.close() + } + + @Test + fun `appendStandardResilience preserves the pillars' stage order`() { + val pipeline = HttpPipelineBuilder(FakeHttpClient()).appendStandardResilience().build() + + // Stage order: REDIRECT (100) < RETRY (200) < LOGGING (700). + assertTrue(pipeline.steps[0] is DefaultRedirectStep) + assertTrue(pipeline.steps[1] is DefaultRetryStep) + assertTrue(pipeline.steps[2] is DefaultInstrumentationStep) + } + + @Test + fun `AsyncHttpPipeline standard wires retry and instrumentation but no redirect`() { + val client = + AsyncHttpClient { request -> + CompletableFuture.completedFuture( + Response.builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .build(), + ) + } + + val pipeline = AsyncHttpPipeline.standard(client, scheduler) + + assertEquals(2, pipeline.steps.size) + assertTrue(pipeline.steps.any { it is DefaultAsyncRetryStep }, "async retry pillar present") + assertTrue(pipeline.steps.any { it is DefaultAsyncInstrumentationStep }, "async instrumentation pillar present") + } + + @Test + fun `appendStandardResilience over an occupied retry pillar throws naming RETRY without mutating`() { + val preexistingRetry = DefaultRetryStep() + val builder = HttpPipelineBuilder(FakeHttpClient()).append(preexistingRetry) + + val ex = assertFailsWith { builder.appendStandardResilience() } + assertTrue("RETRY" in ex.message.orEmpty(), "message names the RETRY pillar: ${ex.message}") + + // Atomic all-or-nothing: the redirect/instrumentation pillars were never installed, and the + // pre-existing retry step is left exactly as it was. + val steps = builder.build().steps + assertEquals(1, steps.size, "builder is unchanged after the rejected call") + assertSame(preexistingRetry, steps.single(), "the original retry step survives untouched") + assertFalse(steps.any { it is DefaultRedirectStep }, "no redirect pillar was partially installed") + } + + @Test + fun `appendStandardResilience called twice throws on the second call`() { + val builder = HttpPipelineBuilder(FakeHttpClient()).appendStandardResilience() + + val ex = assertFailsWith { builder.appendStandardResilience() } + // Every standard pillar is occupied on the second call; the message enumerates them. + assertTrue("REDIRECT" in ex.message.orEmpty(), ex.message) + assertTrue("RETRY" in ex.message.orEmpty(), ex.message) + assertTrue("LOGGING" in ex.message.orEmpty(), ex.message) + + // Still exactly the first standard stack — the failed second call added nothing. + assertEquals(3, builder.build().steps.size) + } + + @Test + fun `appendStandardResilience on a fresh builder installs all three pillars`() { + val steps = HttpPipelineBuilder(FakeHttpClient()).appendStandardResilience().build().steps + + assertEquals(3, steps.size) + assertTrue(steps.any { it is DefaultRedirectStep }) + assertTrue(steps.any { it is DefaultRetryStep }) + assertTrue(steps.any { it is DefaultInstrumentationStep }) + } + + @Test + fun `async appendStandardResilience over an occupied retry pillar throws naming RETRY without mutating`() { + val client = okAsyncClient() + val preexistingRetry = DefaultAsyncRetryStep(scheduler) + val builder = AsyncHttpPipelineBuilder(client).append(preexistingRetry) + + val ex = assertFailsWith { builder.appendStandardResilience(scheduler) } + assertTrue("RETRY" in ex.message.orEmpty(), "message names the RETRY pillar: ${ex.message}") + + val steps = builder.build().steps + assertEquals(1, steps.size, "builder is unchanged after the rejected call") + assertSame(preexistingRetry, steps.single(), "the original retry step survives untouched") + assertFalse( + steps.any { it is DefaultAsyncInstrumentationStep }, + "no instrumentation pillar was partially installed", + ) + } + + @Test + fun `async appendStandardResilience called twice throws on the second call`() { + val builder = AsyncHttpPipelineBuilder(okAsyncClient()).appendStandardResilience(scheduler) + + val ex = assertFailsWith { builder.appendStandardResilience(scheduler) } + assertTrue("RETRY" in ex.message.orEmpty(), ex.message) + assertTrue("LOGGING" in ex.message.orEmpty(), ex.message) + + assertEquals(2, builder.build().steps.size) + } + + private fun okAsyncClient(): AsyncHttpClient = + AsyncHttpClient { request -> + CompletableFuture.completedFuture( + Response.builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .build(), + ) + } + + private fun getRequest(): Request = Request.builder().method(Method.GET).url("https://api.example.com/").build() +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStepTest.kt new file mode 100644 index 00000000..957856ab --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStepTest.kt @@ -0,0 +1,97 @@ +/* + * 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.client.AsyncHttpClient +import org.dexpace.sdk.core.http.common.MediaType +import org.dexpace.sdk.core.http.pipeline.AsyncHttpPipelineBuilder +import org.dexpace.sdk.core.http.pipeline.Stage +import org.dexpace.sdk.core.http.request.Method +import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.core.http.response.Response +import org.dexpace.sdk.core.http.response.exception.NotFoundException +import org.dexpace.sdk.core.http.response.exception.ServerErrorException +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.testing.MockResponse +import org.dexpace.sdk.core.util.Futures +import org.dexpace.sdk.io.OkioIoProvider +import java.util.concurrent.CompletableFuture +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class AsyncThrowOnHttpErrorStepTest { + @BeforeTest + fun setUp() { + Io.installProvider(OkioIoProvider) + } + + @Test + fun `stage is PRE_REDIRECT`() { + assertEquals(Stage.PRE_REDIRECT, AsyncThrowOnHttpErrorStep().stage) + } + + @Test + fun `404 completes exceptionally with the mapped NotFoundException and a readable body`() { + val client = cannedClient(status = 404, body = "{\"error\":\"missing\"}") + + val pipeline = AsyncHttpPipelineBuilder(client).throwOnHttpError().build() + + val future = pipeline.sendAsync(getRequest()) + val thrown = runCatching { future.join() }.exceptionOrNull() + val cause = Futures.unwrap(assertNotNull(thrown, "future must complete exceptionally")) + val notFound = cause as NotFoundException + assertEquals(404, notFound.status.code) + assertEquals("{\"error\":\"missing\"}", notFound.body!!.source().readUtf8()) + } + + @Test + fun `non-canonical 5xx completes exceptionally with a mapped ServerErrorException`() { + // The step branches on Status.isError directly (a plain 400..599 range check), so a + // vendor-specific 5xx code with no canonical Status constant is still mapped. + val client = cannedClient(status = 599, body = "boom") + + val pipeline = AsyncHttpPipelineBuilder(client).throwOnHttpError().build() + + val thrown = runCatching { pipeline.sendAsync(getRequest()).join() }.exceptionOrNull() + val cause = Futures.unwrap(assertNotNull(thrown, "future must complete exceptionally")) + val serverError = cause as ServerErrorException + assertEquals(599, serverError.status.code) + } + + @Test + fun `200 completes with the response untouched and the body intact`() { + val client = cannedClient(status = 200, body = "hello") + + val pipeline = AsyncHttpPipelineBuilder(client).throwOnHttpError().build() + + val response = pipeline.sendAsync(getRequest()).join() + assertEquals(200, response.status.code) + assertEquals("hello", response.body!!.source().readUtf8()) + response.close() + } + + /** An [AsyncHttpClient] that completes with a single canned [status] + [body] per call. */ + private fun cannedClient( + status: Int, + body: String, + ): AsyncHttpClient = + AsyncHttpClient { request -> + val response: Response = + MockResponse.Builder() + .status(status) + .body(body, MediaType.parse("application/json")) + .build() + .toResponse(request) + CompletableFuture.completedFuture(response) + } + + private fun getRequest(): Request = + Request.builder().method(Method.GET).url("https://api.example.com/thing").build() +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStepTest.kt index 3f8dfc43..43126ac1 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStepTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStepTest.kt @@ -589,6 +589,34 @@ class AuthStepTest { assertEquals("refreshed-key", fake.requests[1].headers.get(HttpHeaderName.AUTHORIZATION)) } + @Test + fun `401 challenge whose replacement carries a non-replayable body surfaces the real 401`() { + // The challenge handler returns a replacement request whose body cannot be replayed. Driving + // it through the chain would trip the body's consume-once guard and surface an + // IOException("HTTP pipeline failure") that masks the real 401. The step must instead skip + // the replay (like the retry/redirect steps' replayability gate) and return the original 401. + val step = + object : KeyCredentialAuthStep(KeyCredential("k")) { + override fun authorizeRequestOnChallenge( + request: Request, + response: Response, + ): Request = + request.newBuilder() + .method(Method.POST) + .body(NonReplayableRequestBody()) + .build() + } + val fake = + FakeHttpClient() + .enqueue { status(401).header("WWW-Authenticate", "Bearer realm=\"x\"") } + + val pipeline = HttpPipelineBuilder(fake).append(step).build() + val response = pipeline.send(getHttpsRequest()) + + assertEquals(401, response.status.code, "the real 401 must surface, not a masked IOException") + assertEquals(1, fake.callCount, "a non-replayable replacement body must skip the challenge replay") + } + @Test fun `challenge retry closes the 401 response before driving the retry`() { val closes = AtomicInteger(0) @@ -976,6 +1004,20 @@ class AuthStepTest { .url("https://api.example.com/x") .build() + /** Single-use request body: isReplayable() is false and writeTo streams fixed bytes. */ + private class NonReplayableRequestBody : org.dexpace.sdk.core.http.request.RequestBody() { + override fun mediaType(): org.dexpace.sdk.core.http.common.MediaType? = + org.dexpace.sdk.core.http.common.MediaType.parse("text/plain") + + override fun contentLength(): Long = 5 + + override fun isReplayable(): Boolean = false + + override fun writeTo(sink: org.dexpace.sdk.core.io.BufferedSink) { + sink.write("hello".toByteArray(Charsets.UTF_8)) + } + } + /** ResponseBody that increments [closes] on close() and refuses source() reads. */ private class CloseTrackingResponseBody( private val closes: AtomicInteger, diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStepTest.kt index d8702214..0fe26110 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStepTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStepTest.kt @@ -17,6 +17,7 @@ import org.dexpace.sdk.core.http.pipeline.Stage import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.request.RequestBody +import org.dexpace.sdk.core.http.request.RequestOptions import org.dexpace.sdk.core.http.response.Response import org.dexpace.sdk.core.http.response.ResponseBody import org.dexpace.sdk.core.http.response.Status @@ -27,6 +28,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.time.Instant import java.util.concurrent.CompletableFuture @@ -76,6 +78,58 @@ class DefaultAsyncRetryStepTest { assertEquals(1, client.callCount) } + @Test + fun `per-call maxRetries=0 suppresses a retry the configured budget would allow`() { + // Configured budget is 3, but the caller caps this call at 0 retries → one attempt. + val client = QueueClient().enqueue(503).enqueue(200) + val options = RequestOptions.builder().maxRetries(0).build() + val future = + pipeline(client, HttpRetryOptions.fixed(maxRetries = 3, delay = Duration.ofMillis(50))) + .sendAsync(getRequest(), options) + scheduler.runAll() + assertEquals(503, future.join().status.code) + assertEquals(1, client.callCount, "per-call maxRetries=0 must fail fast") + } + + @Test + fun `per-call maxRetries overrides the configured budget for this call`() { + // Configured budget is 5; the caller caps this call at 1 retry → 2 attempts total. + val client = QueueClient().enqueue(503).enqueue(503).enqueue(200) + val options = RequestOptions.builder().maxRetries(1).build() + val future = + pipeline(client, HttpRetryOptions.fixed(maxRetries = 5, delay = Duration.ofMillis(50))) + .sendAsync(getRequest(), options) + scheduler.runAll() + assertEquals(503, future.join().status.code) + assertEquals(2, client.callCount, "per-call cap of 1 retry → initial + 1 = 2 attempts") + } + + @Test + fun `null per-call maxRetries falls back to the configured budget`() { + // EMPTY options (maxRetries == null) leave the configured budget of 3 in force. + val client = QueueClient().enqueue(503).enqueue(503).enqueue(503).enqueue(200) + val future = + pipeline(client, HttpRetryOptions.fixed(maxRetries = 3, delay = Duration.ofMillis(50))) + .sendAsync(getRequest(), RequestOptions.EMPTY) + scheduler.runAll() + assertEquals(200, future.join().status.code) + assertEquals(4, client.callCount, "null override keeps the configured budget of 3 retries") + } + + @Test + fun `negative per-call maxRetries falls back to the configured budget, not zero retries`() { + // A negative per-call override is clamped to "use default" — mirroring the configured-path + // clamp — so the configured budget of 3 stays in force rather than collapsing to 0 retries. + val client = QueueClient().enqueue(503).enqueue(503).enqueue(503).enqueue(200) + val options = RequestOptions.builder().maxRetries(-1).build() + val future = + pipeline(client, HttpRetryOptions.fixed(maxRetries = 3, delay = Duration.ofMillis(50))) + .sendAsync(getRequest(), options) + scheduler.runAll() + assertEquals(200, future.join().status.code) + assertEquals(4, client.callCount, "negative override must fall back to the configured budget of 3") + } + @Test fun `retries a 503 until a 200 within the budget`() { val client = QueueClient().enqueue(503).enqueue(503).enqueue(200) @@ -137,6 +191,22 @@ class DefaultAsyncRetryStepTest { assertEquals(3, client.callCount) } + @Test + fun `SocketTimeoutException is retried as a transient failure not treated as cancellation`() { + // SocketTimeoutException extends InterruptedIOException, but it is a read timeout, not a + // cancellation — it must retry via the normal classifier, unlike the InterruptedIOException + // and bare-InterruptedException cases which abort after one attempt and restore the + // interrupt flag. + val client = FailNTimesClient(failures = 2) { SocketTimeoutException("read timed out") } + val future = + pipeline(client, HttpRetryOptions.fixed(maxRetries = 3, delay = Duration.ZERO)) + .sendAsync(getRequest()) + scheduler.runAll() + assertEquals(200, future.join().status.code) + assertEquals(3, client.callCount, "SocketTimeoutException must be retried, not surfaced as cancellation") + assertFalse(Thread.interrupted(), "a read timeout must not restore the interrupt flag") + } + @Test fun `non-retryable exception is surfaced immediately`() { val client = FailNTimesClient(failures = 5) { IllegalArgumentException("nope") } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptionsBuilderTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptionsBuilderTest.kt new file mode 100644 index 00000000..e85ceab6 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptionsBuilderTest.kt @@ -0,0 +1,41 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals + +class HttpInstrumentationOptionsBuilderTest { + @Test + fun `builder sets logLevel and leaves other fields at defaults`() { + val defaults = HttpInstrumentationOptions() + val opts = HttpInstrumentationOptions.builder().logLevel(HttpLogLevel.HEADERS).build() + + assertEquals(HttpLogLevel.HEADERS, opts.logLevel) + assertEquals(defaults.allowedHeaderNames, opts.allowedHeaderNames) + assertEquals(defaults.allowedQueryParamNames, opts.allowedQueryParamNames) + assertEquals(defaults.isRedactedHeaderNamesLoggingEnabled, opts.isRedactedHeaderNamesLoggingEnabled) + assertEquals(defaults.bodyPreviewMaxBytes, opts.bodyPreviewMaxBytes) + } + + @Test + fun `newBuilder round-trips scalar fields`() { + val original = + HttpInstrumentationOptions + .builder() + .logLevel(HttpLogLevel.BODY_AND_HEADERS) + .bodyPreviewMaxBytes(4096) + .isRedactedHeaderNamesLoggingEnabled(false) + .build() + val copy = original.newBuilder().build() + + assertEquals(HttpLogLevel.BODY_AND_HEADERS, copy.logLevel) + assertEquals(4096, copy.bodyPreviewMaxBytes) + assertEquals(false, copy.isRedactedHeaderNamesLoggingEnabled) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptionsBuilderTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptionsBuilderTest.kt new file mode 100644 index 00000000..de5685dd --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptionsBuilderTest.kt @@ -0,0 +1,47 @@ +/* + * 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.request.Method +import java.util.EnumSet +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class HttpRedirectOptionsBuilderTest { + @Test + fun `builder sets maxHops and leaves other fields at defaults`() { + val defaults = HttpRedirectOptions() + val opts = HttpRedirectOptions.builder().maxHops(5).build() + + assertEquals(5, opts.maxHops) + assertEquals(defaults.allowedMethods, opts.allowedMethods) + assertEquals(defaults.locationHeader, opts.locationHeader) + assertEquals(defaults.follow303, opts.follow303) + assertEquals(defaults.allowSchemeDowngrade, opts.allowSchemeDowngrade) + assertNull(opts.shouldRedirect) + } + + @Test + fun `newBuilder round-trips scalar fields`() { + val original = + HttpRedirectOptions + .builder() + .maxHops(5) + .follow303(true) + .allowSchemeDowngrade(true) + .allowedMethods(EnumSet.of(Method.GET, Method.HEAD, Method.POST)) + .build() + val copy = original.newBuilder().build() + + assertEquals(5, copy.maxHops) + assertEquals(true, copy.follow303) + assertEquals(true, copy.allowSchemeDowngrade) + assertEquals(setOf(Method.GET, Method.HEAD, Method.POST), copy.allowedMethods) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptionsBuilderTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptionsBuilderTest.kt new file mode 100644 index 00000000..6d4e9cf3 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptionsBuilderTest.kt @@ -0,0 +1,60 @@ +/* + * 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.config.Configuration +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals + +class HttpRetryOptionsBuilderTest { + @Test + fun `builder sets maxRetries and leaves other fields at defaults`() { + val defaults = HttpRetryOptions() + val opts = HttpRetryOptions.builder().maxRetries(5).build() + + assertEquals(5, opts.maxRetries) + assertEquals(defaults.baseDelay, opts.baseDelay) + assertEquals(defaults.maxDelay, opts.maxDelay) + assertEquals(defaults.fixedDelay, opts.fixedDelay) + assertEquals(defaults.retryAfterHeaders, opts.retryAfterHeaders) + } + + @Test + fun `newBuilder round-trips scalar fields`() { + val original = + HttpRetryOptions + .builder() + .maxRetries(7) + .baseDelay(Duration.ofSeconds(1)) + .maxDelay(Duration.ofSeconds(30)) + .fixedDelay(Duration.ofMillis(500)) + .build() + val copy = original.newBuilder().build() + + assertEquals(7, copy.maxRetries) + assertEquals(Duration.ofSeconds(1), copy.baseDelay) + assertEquals(Duration.ofSeconds(30), copy.maxDelay) + assertEquals(Duration.ofMillis(500), copy.fixedDelay) + } + + @Test + fun `fromConfiguration reads max retry attempts`() { + val config = Configuration.builder().put(Configuration.MAX_RETRY_ATTEMPTS, "5").build() + val opts = HttpRetryOptions.fromConfiguration(config) + assertEquals(5, opts.maxRetries) + } + + @Test + fun `fromConfiguration defaults when key absent`() { + val config = Configuration.builder().build() + val defaults = HttpRetryOptions() + val opts = HttpRetryOptions.fromConfiguration(config) + assertEquals(defaults.maxRetries, opts.maxRetries) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupportTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupportTest.kt new file mode 100644 index 00000000..c372c270 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupportTest.kt @@ -0,0 +1,61 @@ +/* + * 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.request.RequestOptions +import org.dexpace.sdk.core.instrumentation.ClientLogger +import kotlin.test.Test +import kotlin.test.assertEquals + +class RetryPolicySupportTest { + private val logger = ClientLogger(RetryPolicySupportTest::class) + + private fun support(configuredMaxRetries: Int): RetryPolicySupport = + RetryPolicySupport(HttpRetryOptions(maxRetries = configuredMaxRetries), logger) + + private fun options(maxRetries: Int?): RequestOptions = RequestOptions.builder().maxRetries(maxRetries).build() + + @Test + fun `null per-call override falls back to the configured budget`() { + // RequestOptions.EMPTY carries a null maxRetries — no per-call override, so the + // configured budget applies. + assertEquals(5, support(configuredMaxRetries = 5).effectiveMaxRetries(RequestOptions.EMPTY)) + } + + @Test + fun `negative per-call override falls back to the configured budget, not zero retries`() { + // A negative per-call value means "use the configured default", mirroring how a negative + // CONFIGURED maxRetries is clamped — it must NOT collapse to 0 retries. + assertEquals(5, support(configuredMaxRetries = 5).effectiveMaxRetries(options(-1))) + assertEquals(5, support(configuredMaxRetries = 5).effectiveMaxRetries(options(-100))) + } + + @Test + fun `zero per-call override yields zero retries`() { + // 0 is a real, honoured value: fail fast, exactly one attempt. It is NOT treated as + // "unset". + assertEquals(0, support(configuredMaxRetries = 5).effectiveMaxRetries(options(0))) + } + + @Test + fun `positive per-call override is used verbatim`() { + assertEquals(1, support(configuredMaxRetries = 5).effectiveMaxRetries(options(1))) + assertEquals(9, support(configuredMaxRetries = 5).effectiveMaxRetries(options(9))) + } + + @Test + fun `null override returns the already-clamped configured default when the configured value is negative`() { + // A negative CONFIGURED maxRetries is clamped to DEFAULT_MAX_RETRIES at construction, so a + // null per-call override resolves to the clamp — not to the raw negative value. + val support = support(configuredMaxRetries = -5) + assertEquals( + DefaultRetryStep.DEFAULT_MAX_RETRIES, + support.effectiveMaxRetries(RequestOptions.EMPTY), + ) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryStepTest.kt index 41a4ab58..f9c29c79 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryStepTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryStepTest.kt @@ -18,6 +18,7 @@ import org.dexpace.sdk.core.http.pipeline.PipelineNext import org.dexpace.sdk.core.http.pipeline.Stage 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.ResponseBody import org.dexpace.sdk.core.http.response.Status @@ -34,6 +35,7 @@ import java.io.ByteArrayInputStream import java.io.IOException import java.io.InputStream import java.io.InterruptedIOException +import java.net.SocketTimeoutException import java.time.Duration import java.time.Instant import java.util.concurrent.TimeoutException @@ -90,6 +92,91 @@ class RetryStepTest { assertEquals(1, fake.callCount) } + // ----------------- per-call RequestOptions.maxRetries override ----------------- + + @Test + fun `per-call maxRetries=0 suppresses a retry the configured budget would allow`() { + // Configured budget is 3, but the caller passes maxRetries=0 for THIS call, so the 503 is + // returned after exactly one attempt — no retry, fail fast. + val fake = + FakeHttpClient() + .enqueue { status(503) } + .enqueue { status(200) } // must never be reached + + val pipeline = + HttpPipelineBuilder(fake) + .append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3), zeroDelayClock())) + .build() + + val options = RequestOptions.builder().maxRetries(0).build() + val response = pipeline.send(getRequest(), options) + assertEquals(503, response.status.code) + assertEquals(1, fake.callCount, "per-call maxRetries=0 must fail fast") + } + + @Test + fun `per-call maxRetries overrides the configured budget for this call`() { + // Configured budget is 5; the caller caps this call at 1 retry → 2 attempts total. + val fake = + FakeHttpClient() + .enqueue { status(503) } + .enqueue { status(503) } + .enqueue { status(200) } // must never be reached (budget is 1 retry) + + val pipeline = + HttpPipelineBuilder(fake) + .append(DefaultRetryStep(HttpRetryOptions(maxRetries = 5), zeroDelayClock())) + .build() + + val options = RequestOptions.builder().maxRetries(1).build() + val response = pipeline.send(getRequest(), options) + assertEquals(503, response.status.code) + assertEquals(2, fake.callCount, "per-call cap of 1 retry → initial + 1 = 2 attempts") + } + + @Test + fun `null per-call maxRetries falls back to the configured budget`() { + // EMPTY options (maxRetries == null) leave the configured budget of 3 in force. + val fake = + FakeHttpClient() + .enqueue { status(503) } + .enqueue { status(503) } + .enqueue { status(503) } + .enqueue { status(200) } + + val pipeline = + HttpPipelineBuilder(fake) + .append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3), zeroDelayClock())) + .build() + + val response = pipeline.send(getRequest(), RequestOptions.EMPTY) + assertEquals(200, response.status.code) + assertEquals(4, fake.callCount, "null override keeps the configured budget of 3 retries") + } + + @Test + fun `negative per-call maxRetries falls back to the configured budget, not zero retries`() { + // A negative per-call override is clamped to "use default" — mirroring the configured-path + // clamp in RetryPolicySupport — so the configured budget of 3 stays in force rather than + // collapsing to 0 retries. + val fake = + FakeHttpClient() + .enqueue { status(503) } + .enqueue { status(503) } + .enqueue { status(503) } + .enqueue { status(200) } + + val pipeline = + HttpPipelineBuilder(fake) + .append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3), zeroDelayClock())) + .build() + + val options = RequestOptions.builder().maxRetries(-1).build() + val response = pipeline.send(getRequest(), options) + assertEquals(200, response.status.code) + assertEquals(4, fake.callCount, "negative override must fall back to the configured budget of 3") + } + @Test fun `first attempt succeeds returns immediately`() { val fake = FakeHttpClient().enqueue { status(200) } @@ -418,6 +505,32 @@ class RetryStepTest { assertEquals(2, attempts.get()) } + @Test + fun `SocketTimeoutException is retried as a transient failure not treated as cancellation`() { + // SocketTimeoutException extends InterruptedIOException, but it is a read timeout — NOT a + // cancellation. It must be routed through the normal retry classifier (retried up to + // maxRetries), NOT the interrupt path that rethrows immediately and re-sets the interrupt + // flag (contrast `InterruptedIOException from the transport is rethrown immediately`). + val attempts = AtomicInteger(0) + val client = + object : HttpClient { + override fun execute(request: Request): Response { + val n = attempts.incrementAndGet() + if (n < 3) throw SocketTimeoutException("read timed out") + return okResponse(request) + } + } + val pipeline = + HttpPipelineBuilder(client) + .append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3), zeroDelayClock())) + .build() + + val response = pipeline.send(getRequest()) + assertEquals(200, response.status.code) + assertEquals(3, attempts.get(), "SocketTimeoutException must be retried, not surfaced as cancellation") + assertTrue(!Thread.currentThread().isInterrupted, "a read timeout must not set the interrupt flag") + } + @Test fun `nested cause chain RuntimeException-of-IOException is retried`() { val attempts = AtomicInteger(0) @@ -456,12 +569,12 @@ class RetryStepTest { .append(DefaultRetryStep(HttpRetryOptions(), zeroDelayClock())) .build() - // `process()` is declared `@Throws(IOException::class)`. A non-IO terminal failure - // is wrapped so Java callers' `catch (IOException)` is honored; the original is - // attached as cause. - val wrapped = assertFailsWith { pipeline.send(getRequest()) } - assertTrue(wrapped.cause is RuntimeException, "expected RuntimeException cause") - assertEquals("unrelated", wrapped.cause?.message) + // An unchecked (RuntimeException) terminal failure 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 exception (e.g. an HttpException from an inner pipeline) + // catchable rather than buried under IOException("HTTP pipeline failure"). + val thrown = assertFailsWith { pipeline.send(getRequest()) } + assertEquals("unrelated", thrown.message) assertEquals(1, attempts.get()) } @@ -692,7 +805,7 @@ class RetryStepTest { // retryable status — a second POST could duplicate a side effect the server already // applied. The 503 is returned as-is after exactly one attempt. This exercises the // body == null branch of isRetrySafe on a non-idempotent method. Mirrored by the - // `body-less POST is not retried` case in the pipeline.step.retry RetryStep suite. + // `body-less POST is not retried` case in the pipeline.step.retry RetryRecovery suite. val fake = FakeHttpClient() .enqueue { status(503) } @@ -718,7 +831,7 @@ class RetryStepTest { fun `body-less PUT IS retried because PUT is idempotent`() { // Control for the body == null branch: with no body the gate falls through to method // idempotency. PUT is idempotent, so a body-less PUT is retry-safe and retries normally. - // Mirrored by the `body-less PUT is retried` case in the pipeline.step.retry RetryStep suite. + // Mirrored by the `body-less PUT is retried` case in the pipeline.step.retry RetryRecovery suite. val fake = FakeHttpClient() .enqueue { status(503) } @@ -1637,10 +1750,11 @@ class RetryStepTest { .append(DefaultRetryStep(opts, zeroDelayClock())) .build() - // Non-IO terminal failure is wrapped as `IOException` (see `RuntimeException with no - // relevant cause is NOT retried`); the original is attached as cause. - val wrapped = assertFailsWith { pipeline.send(getRequest()) } - assertTrue(wrapped.cause is RuntimeException) + // An unchecked terminal failure propagates as-is (see `RuntimeException with no relevant + // cause is NOT retried`), so the original RuntimeException surfaces rather than an + // IOException wrapper. + val thrown = assertFailsWith { pipeline.send(getRequest()) } + assertEquals("no retry classifier match", thrown.message) // Initial + 2 retries → 2 calls to the exception predicate (after attempt 0 and 1). assertEquals(2, invocations.get(), "exception predicate must be consulted on each retry decision") } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStepTest.kt new file mode 100644 index 00000000..0ba7fa04 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStepTest.kt @@ -0,0 +1,198 @@ +/* + * 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.common.MediaType +import org.dexpace.sdk.core.http.pipeline.HttpPipelineBuilder +import org.dexpace.sdk.core.http.pipeline.Stage +import org.dexpace.sdk.core.http.request.Method +import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.core.http.response.exception.ClientErrorException +import org.dexpace.sdk.core.http.response.exception.HttpException +import org.dexpace.sdk.core.http.response.exception.NotFoundException +import org.dexpace.sdk.core.http.response.exception.ServerErrorException +import org.dexpace.sdk.core.http.response.exception.ServiceUnavailableException +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.testing.FakeHttpClient +import org.dexpace.sdk.core.testing.FixedClock +import org.dexpace.sdk.io.OkioIoProvider +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class ThrowOnHttpErrorStepTest { + @BeforeTest + fun setUp() { + Io.installProvider(OkioIoProvider) + } + + @Test + fun `stage is PRE_REDIRECT`() { + assertEquals(Stage.PRE_REDIRECT, ThrowOnHttpErrorStep().stage) + } + + @Test + fun `404 throws the mapped NotFoundException and the exception body is still readable`() { + val fake = + FakeHttpClient() + .enqueue { status(404).body("{\"error\":\"missing\"}", MediaType.parse("application/json")) } + + val pipeline = HttpPipelineBuilder(fake).throwOnHttpError().build() + + val ex = + assertFailsWith { + pipeline.send(getRequest("https://api.example.com/thing")) + } + assertEquals(404, ex.status.code) + val body = assertNotNull(ex.body, "mapped exception must carry the error body") + assertEquals("{\"error\":\"missing\"}", body.source().readUtf8()) + } + + @Test + fun `500 error body remains readable after the connection is buffered`() { + val fake = FakeHttpClient().enqueue { status(500).body("boom") } + + val pipeline = HttpPipelineBuilder(fake).throwOnHttpError().build() + + val ex = assertFailsWith { pipeline.send(getRequest("https://api.example.com/x")) } + assertEquals(500, ex.status.code) + assertEquals("boom", ex.body!!.source().readUtf8()) + } + + @Test + fun `error response without a body still maps to the typed exception`() { + val fake = FakeHttpClient().enqueue { status(404) } + + val pipeline = HttpPipelineBuilder(fake).throwOnHttpError().build() + + val ex = + assertFailsWith { + pipeline.send(getRequest("https://api.example.com/gone")) + } + assertEquals(404, ex.status.code) + } + + @Test + fun `non-canonical 4xx and 5xx codes still map to typed exceptions`() { + // The step now branches on Status.isError directly (a plain 400..599 range check), so a + // vendor-specific code with no canonical Status constant is still treated as an error and + // routed through the 4xx / 5xx fallbacks. + val client4xx = FakeHttpClient().enqueue { status(460) } + val ex4xx = + assertFailsWith { + HttpPipelineBuilder(client4xx).throwOnHttpError().build().send(getRequest("https://api.example.com/a")) + } + assertEquals(460, ex4xx.status.code) + + val client5xx = FakeHttpClient().enqueue { status(599) } + val ex5xx = + assertFailsWith { + HttpPipelineBuilder(client5xx).throwOnHttpError().build().send(getRequest("https://api.example.com/b")) + } + assertEquals(599, ex5xx.status.code) + } + + @Test + fun `a 3xx reaching the step without a redirect step is returned untouched`() { + // No redirect step is installed, so a bare 3xx reaches ThrowOnHttpErrorStep as the terminal + // response. It is NOT an error (Status.isError is false), so it passes through verbatim. + val fake = FakeHttpClient().enqueue { status(304) } + + val pipeline = HttpPipelineBuilder(fake).throwOnHttpError().build() + + val response = pipeline.send(getRequest("https://api.example.com/nm")) + assertEquals(304, response.status.code) + response.close() + } + + @Test + fun `200 is returned untouched with the body intact and readable`() { + val fake = FakeHttpClient().enqueue { status(200).body("hello", MediaType.parse("text/plain")) } + + val pipeline = HttpPipelineBuilder(fake).throwOnHttpError().build() + + val response = pipeline.send(getRequest("https://api.example.com/ok")) + assertEquals(200, response.status.code) + // Body was never consumed by the step, so it is still fully readable. + assertEquals("hello", response.body!!.source().readUtf8()) + response.close() + } + + @Test + fun `does not throw when a retry step recovers a 503-then-200 sequence`() { + // throwOnHttpError() is OUTER of the RETRY pillar (PRE_REDIRECT < RETRY), so it only sees + // the terminal 200 the retry step returns — it must NOT throw on the intermediate 503. + // If the step were placed inside the retry loop it would throw on the first 503 before the + // retry could recover. + val fake = + FakeHttpClient() + .enqueue { status(503) } + .enqueue { status(200).body("recovered") } + + val pipeline = + HttpPipelineBuilder(fake) + .throwOnHttpError() + // FixedClock advances instantly on sleep(), so backoff does not block the test. + .append(DefaultRetryStep(clock = FixedClock())) + .build() + + val response = pipeline.send(getRequest("https://api.example.com/retry")) + assertEquals(200, response.status.code) + assertEquals(2, fake.callCount) + assertEquals("recovered", response.body!!.source().readUtf8()) + response.close() + } + + @Test + fun `does not throw on an intermediate 3xx redirect hop`() { + // throwOnHttpError() is OUTER of the REDIRECT pillar, so it sees only the final 200 after + // the redirect is followed — never the intermediate 301. + val fake = + FakeHttpClient() + .enqueue { status(301).header("Location", "https://api.example.com/v2") } + .enqueue { status(200).body("final") } + + val pipeline = + HttpPipelineBuilder(fake) + .throwOnHttpError() + .append(DefaultRedirectStep()) + .build() + + val response = pipeline.send(getRequest("https://api.example.com/v1")) + assertEquals(200, response.status.code) + assertEquals(2, fake.callCount) + response.close() + } + + @Test + fun `still throws when retry exhausts and the terminal response is a 503`() { + // Retry-safe GET, all attempts fail: retry exhausts and returns the terminal 503, which + // the outer throwOnHttpError() maps to ServiceUnavailableException. + val fake = + FakeHttpClient() + .enqueue { status(503).body("unavailable") } + .enqueue { status(503).body("still unavailable") } + .enqueue { status(503).body("nope") } + + val pipeline = + HttpPipelineBuilder(fake) + .throwOnHttpError() + .append(DefaultRetryStep(clock = FixedClock())) + .build() + + val ex = + assertFailsWith { + pipeline.send(getRequest("https://api.example.com/down")) + } + assertEquals(503, ex.status.code) + } + + private fun getRequest(url: String): Request = Request.builder().method(Method.GET).url(url).build() +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MultipartBodyTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MultipartBodyTest.kt index 5cd25e41..65a93a4a 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MultipartBodyTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MultipartBodyTest.kt @@ -99,6 +99,8 @@ class MultipartBodyTest { type: Class, ): T = throw UnsupportedOperationException() } + + override fun contentType() = CommonMediaTypes.TEXT_PLAIN } private fun drain(body: RequestBody): ByteArray { @@ -319,6 +321,20 @@ class MultipartBodyTest { assertContains(wire, "raw-value") } + @Test + fun `serialized part without an explicit media type defaults its Content-Type to the serde's`() { + // Mirrors RequestBody.create(value, serde): when the caller omits a media type, the part's + // Content-Type falls back to Serde.contentType() (here text/plain) rather than being absent. + val body = + MultipartBody.builder() + .boundary("D") + .addPart("payload", "raw-value", StringSerde) + .build() + val wire = String(drain(body), Charsets.UTF_8) + assertContains(wire, "Content-Type: text/plain") + assertContains(wire, "raw-value") + } + @Test fun `parts is a defensive copy`() { val builder = MultipartBody.builder().boundary("B").addPart("a", "1", StringSerde) diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestBodyDxTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestBodyDxTest.kt new file mode 100644 index 00000000..9a3bdfb7 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestBodyDxTest.kt @@ -0,0 +1,116 @@ +/* + * 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 org.dexpace.sdk.core.http.common.CommonMediaTypes +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.Serializer +import org.dexpace.sdk.io.OkioIoProvider +import java.io.InputStream +import java.io.OutputStream +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class RequestBodyDxTest { + @BeforeTest + fun installProvider() { + Io.installProvider(OkioIoProvider) + } + + private val jsonPayload = """{"k":1}""" + + private val fakeSerde: Serde = + object : Serde { + override val serializer: Serializer = + object : Serializer { + override fun serialize(input: Any): String = jsonPayload + + override fun serializeToByteArray(input: Any): ByteArray = jsonPayload.toByteArray(Charsets.UTF_8) + + override fun serialize( + input: Any, + outputStream: OutputStream, + ) { + outputStream.write(serializeToByteArray(input)) + } + + override fun serialize( + input: Any, + buffer: ByteArray, + offset: Int, + ): Int { + val bytes = serializeToByteArray(input) + bytes.copyInto(buffer, offset) + return bytes.size + } + } + override val deserializer: Deserializer = + object : Deserializer { + override fun deserialize( + input: String, + type: Class, + ): T = throw UnsupportedOperationException() + + override fun deserialize( + input: ByteArray, + type: Class, + ): T = throw UnsupportedOperationException() + + override fun deserialize( + inputStream: InputStream, + type: Class, + ): T = throw UnsupportedOperationException() + } + + override fun contentType() = CommonMediaTypes.APPLICATION_JSON + } + + private fun drain(body: RequestBody): ByteArray { + val buf = Io.provider.buffer() + body.writeTo(buf) + return buf.snapshot() + } + + @Test + fun `create with serde produces body with application json mediaType by default`() { + val body = RequestBody.create(Any(), fakeSerde) + assertEquals(CommonMediaTypes.APPLICATION_JSON, body.mediaType()) + } + + @Test + fun `create with serde serializes value to expected bytes`() { + val body = RequestBody.create(Any(), fakeSerde) + assertContentEquals(jsonPayload.toByteArray(Charsets.UTF_8), drain(body)) + } + + @Test + fun `create with serde and explicit mediaType uses that mediaType`() { + val body = RequestBody.create(Any(), fakeSerde, CommonMediaTypes.TEXT_PLAIN) + assertEquals(CommonMediaTypes.TEXT_PLAIN, body.mediaType()) + } + + @Test + fun `create with serde and explicit mediaType still serializes via serde`() { + val body = RequestBody.create(Any(), fakeSerde, CommonMediaTypes.TEXT_PLAIN) + assertContentEquals(jsonPayload.toByteArray(Charsets.UTF_8), drain(body)) + } + + @Test + fun `create uses the serde's own contentType as the default mediaType`() { + val xmlSerde = + object : Serde by fakeSerde { + override fun contentType() = CommonMediaTypes.APPLICATION_XML + } + val body = RequestBody.create(Any(), xmlSerde) + assertEquals(CommonMediaTypes.APPLICATION_XML, body.mediaType()) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestDxTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestDxTest.kt new file mode 100644 index 00000000..88645e32 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestDxTest.kt @@ -0,0 +1,245 @@ +/* + * 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 org.dexpace.sdk.core.http.common.HttpHeaderName +import org.dexpace.sdk.core.http.common.MediaType +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class RequestDxTest { + // ------------------------------------------------------------------------- + // T5 — typed HttpHeaderName overloads on RequestBuilder + // ------------------------------------------------------------------------- + + @Test + fun `addHeader typed HttpHeaderName and String adds header`() { + val req = + Request.builder() + .url("https://x.example") + .addHeader(HttpHeaderName.ACCEPT, "application/json") + .build() + assertEquals("application/json", req.headers.get(HttpHeaderName.ACCEPT)) + } + + @Test + fun `addHeader typed HttpHeaderName and List adds all values`() { + val req = + Request.builder() + .url("https://x.example") + .addHeader(HttpHeaderName.ACCEPT, listOf("application/json", "text/plain")) + .build() + assertEquals(listOf("application/json", "text/plain"), req.headers.values(HttpHeaderName.ACCEPT)) + } + + @Test + fun `setHeader typed HttpHeaderName and String replaces existing`() { + val req = + Request.builder() + .url("https://x.example") + .addHeader(HttpHeaderName.ACCEPT, "text/plain") + .setHeader(HttpHeaderName.ACCEPT, "application/json") + .build() + assertEquals(listOf("application/json"), req.headers.values(HttpHeaderName.ACCEPT)) + } + + @Test + fun `setHeader typed HttpHeaderName and List replaces existing`() { + val req = + Request.builder() + .url("https://x.example") + .addHeader(HttpHeaderName.ACCEPT, "text/plain") + .setHeader(HttpHeaderName.ACCEPT, listOf("application/json", "application/xml")) + .build() + assertEquals(listOf("application/json", "application/xml"), req.headers.values(HttpHeaderName.ACCEPT)) + } + + @Test + fun `addHeader typed HttpHeaderName and MediaType sets header as string`() { + val req = + Request.builder() + .url("https://x.example") + .addHeader(HttpHeaderName.ACCEPT, MediaType.APPLICATION_JSON) + .build() + assertEquals("application/json", req.headers.get(HttpHeaderName.ACCEPT)) + } + + // ------------------------------------------------------------------------- + // T6 — url(String) throws IllegalArgumentException; url(URI) overload + // ------------------------------------------------------------------------- + + @Test + fun `url String throws IllegalArgumentException on bad url`() { + assertFailsWith { + Request.builder().url("::bad") + } + } + + @Test + fun `url String throws IllegalArgumentException not checked exception`() { + val ex = + assertFailsWith { + Request.builder().url("not-a-valid-url") + } + assertNotNull(ex.message) + } + + @Test + fun `url URI builds a request whose url matches`() { + val uri = URI.create("https://x.example/y") + val req = Request.builder().url(uri).build() + assertEquals(uri.toURL().toExternalForm(), req.url.toExternalForm()) + } + + @Test + fun `url URI with path and query builds correctly`() { + val uri = URI.create("https://api.example.com/v1/items?q=hello") + val req = Request.builder().url(uri).build() + assertEquals("https://api.example.com/v1/items?q=hello", req.url.toExternalForm()) + } + + // ------------------------------------------------------------------------- + // T7 — default GET, verb methods, companion factories + // ------------------------------------------------------------------------- + + @Test + fun `build without explicit method defaults to GET`() { + val req = Request.builder().url("https://example.test").build() + assertEquals(Method.GET, req.method) + } + + @Test + fun `verb get() sets method to GET and clears body`() { + val req = + Request.builder() + .url("https://example.test") + .get() + .build() + assertEquals(Method.GET, req.method) + assertNull(req.body) + } + + @Test + fun `verb post() sets method to POST and body`() { + val body = RequestBody.create("payload", null) + val req = + Request.builder() + .url("https://example.test") + .post(body) + .build() + assertEquals(Method.POST, req.method) + assertEquals(body, req.body) + } + + @Test + fun `verb put() sets method to PUT and body`() { + val body = RequestBody.create("payload", null) + val req = + Request.builder() + .url("https://example.test") + .put(body) + .build() + assertEquals(Method.PUT, req.method) + assertEquals(body, req.body) + } + + @Test + fun `verb delete() sets method to DELETE`() { + val req = + Request.builder() + .url("https://example.test") + .delete() + .build() + assertEquals(Method.DELETE, req.method) + } + + @Test + fun `verb delete() clears a previously-set body`() { + val body = RequestBody.create("payload", null) + val req = + Request.builder() + .url("https://example.test") + .post(body) + .delete() + .build() + assertEquals(Method.DELETE, req.method) + assertNull(req.body) + } + + @Test + fun `verb head() sets method to HEAD and clears body`() { + val req = + Request.builder() + .url("https://example.test") + .head() + .build() + assertEquals(Method.HEAD, req.method) + assertNull(req.body) + } + + @Test + fun `verb patch() sets method to PATCH and body`() { + val body = RequestBody.create("payload", null) + val req = + Request.builder() + .url("https://example.test") + .patch(body) + .build() + assertEquals(Method.PATCH, req.method) + assertEquals(body, req.body) + } + + @Test + fun `Request get companion factory builds a GET request`() { + val req = Request.get("https://example.test") + assertEquals(Method.GET, req.method) + assertNull(req.body) + } + + @Test + fun `Request post companion factory builds a POST request with body`() { + val body = RequestBody.create("x", null) + val req = Request.post("https://example.test", body) + assertEquals(Method.POST, req.method) + assertEquals(body, req.body) + } + + @Test + fun `Request put companion factory builds a PUT request with body`() { + val body = RequestBody.create("x", null) + val req = Request.put("https://example.test", body) + assertEquals(Method.PUT, req.method) + assertEquals(body, req.body) + } + + @Test + fun `Request patch companion factory builds a PATCH request with body`() { + val body = RequestBody.create("x", null) + val req = Request.patch("https://example.test", body) + assertEquals(Method.PATCH, req.method) + assertEquals(body, req.body) + } + + @Test + fun `Request delete companion factory builds a DELETE request with no body`() { + val req = Request.delete("https://example.test") + assertEquals(Method.DELETE, req.method) + assertNull(req.body) + } + + @Test + fun `Request head companion factory builds a HEAD request with no body`() { + val req = Request.head("https://example.test") + assertEquals(Method.HEAD, req.method) + assertNull(req.body) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestOptionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestOptionsTest.kt new file mode 100644 index 00000000..365247f4 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestOptionsTest.kt @@ -0,0 +1,176 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class RequestOptionsTest { + @Test + fun `EMPTY carries no overrides`() { + val empty = RequestOptions.EMPTY + assertNull(empty.timeout) + assertNull(empty.maxRetries) + assertTrue(empty.tags.isEmpty()) + } + + @Test + fun `builder sets every field`() { + val options = + RequestOptions.builder() + .timeout(Duration.ofSeconds(5)) + .maxRetries(0) + .tag("route", "checkout") + .tag("attempt", 3) + .build() + + assertEquals(Duration.ofSeconds(5), options.timeout) + assertEquals(0, options.maxRetries) + assertEquals(mapOf("route" to "checkout", "attempt" to 3), options.tags) + } + + @Test + fun `timeout rejects a zero duration`() { + assertFailsWith { + RequestOptions.builder().timeout(Duration.ZERO) + } + } + + @Test + fun `timeout rejects a negative duration`() { + assertFailsWith { + RequestOptions.builder().timeout(Duration.ofMillis(-1)) + } + } + + @Test + fun `timeout accepts null to clear the override`() { + val options = RequestOptions.builder().timeout(Duration.ofSeconds(1)).timeout(null).build() + assertNull(options.timeout) + } + + @Test + fun `build with no tags shares the empty map`() { + val a = RequestOptions.builder().maxRetries(2).build() + val b = RequestOptions.builder().timeout(Duration.ofSeconds(1)).build() + assertTrue(a.tags.isEmpty()) + assertSame(a.tags, b.tags) + } + + @Test + fun `tag insertion order is preserved`() { + val options = + RequestOptions.builder() + .tag("a", 1) + .tag("b", 2) + .tag("c", 3) + .build() + + assertEquals(listOf("a", "b", "c"), options.tags.keys.toList()) + } + + @Test + fun `later tag with same key replaces earlier value`() { + val options = + RequestOptions.builder() + .tag("k", "first") + .tag("k", "second") + .build() + + assertEquals("second", options.tags["k"]) + } + + @Test + fun `tags snapshot is decoupled from the builder after build`() { + val builder = RequestOptions.builder().tag("k", "v") + val built = builder.build() + // Mutating the builder after build must not leak into the already-built snapshot. + builder.tag("k2", "v2") + assertEquals(setOf("k"), built.tags.keys) + } + + @Test + fun `newBuilder round-trips all fields`() { + val original = + RequestOptions.builder() + .timeout(Duration.ofMillis(750)) + .maxRetries(4) + .tag("x", "y") + .build() + + val copy = original.newBuilder().build() + + assertEquals(original, copy) + assertNotSame(original, copy) + } + + @Test + fun `newBuilder allows overriding a single field`() { + val original = + RequestOptions.builder() + .timeout(Duration.ofSeconds(1)) + .maxRetries(2) + .build() + + val relaxed = original.newBuilder().maxRetries(null).build() + + assertEquals(Duration.ofSeconds(1), relaxed.timeout) + assertNull(relaxed.maxRetries) + } + + @Test + fun `timeout null clears a previously-set timeout`() { + val options = + RequestOptions.builder() + .timeout(Duration.ofSeconds(3)) + .timeout(null) + .build() + + assertNull(options.timeout) + } + + @Test + fun `equals and hashCode are value-based`() { + val a = + RequestOptions.builder() + .timeout(Duration.ofSeconds(2)) + .maxRetries(1) + .tag("t", "v") + .build() + val b = + RequestOptions.builder() + .timeout(Duration.ofSeconds(2)) + .maxRetries(1) + .tag("t", "v") + .build() + + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `differing fields are not equal`() { + val base = + RequestOptions.builder() + .timeout(Duration.ofSeconds(2)) + .maxRetries(1) + .build() + + assertFalse(base == base.newBuilder().timeout(Duration.ofSeconds(3)).build()) + assertFalse(base == base.newBuilder().maxRetries(2).build()) + assertFalse(base == base.newBuilder().tag("t", "v").build()) + assertEquals(RequestOptions.EMPTY, RequestOptions.builder().build()) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestTest.kt index aa4d12bd..edcbb29f 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestTest.kt @@ -9,7 +9,6 @@ package org.dexpace.sdk.core.http.request import org.dexpace.sdk.core.http.common.Headers import org.dexpace.sdk.core.http.common.HttpHeaderName -import java.net.MalformedURLException import java.net.URL import java.net.URLStreamHandler import kotlin.test.Test @@ -195,19 +194,16 @@ class RequestTest { } @Test - fun `url String setter throws MalformedURLException on bad input`() { - assertFailsWith { + fun `url String setter throws IllegalArgumentException on bad input`() { + assertFailsWith { Request.builder().url("not-a-valid-url") } } @Test - fun `build throws when method is missing`() { - val ex = - assertFailsWith { - Request.builder().url("https://example.test").build() - } - assertEquals("method is required", ex.message) + fun `build without explicit method defaults to GET`() { + val req = Request.builder().url("https://example.test").build() + assertEquals(Method.GET, req.method) } @Test @@ -219,6 +215,20 @@ class RequestTest { assertEquals("url is required", ex.message) } + @Test + fun `build without method but with a body reports the missing method`() { + // A body with no method is a forgotten verb (e.g. a dropped .post(body)); it must be named + // as a missing method, not defaulted to GET and then rejected as "GET must not carry a body". + val ex = + assertFailsWith { + Request.builder() + .url("https://example.test") + .body(RequestBody.create("x", null)) + .build() + } + assertEquals("method is required", ex.message) + } + // --------------------------------------------------------------------- // Body / method compatibility — a body on a body-forbidden method is // rejected at build time so transports never have to disagree on it. diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensionsTest.kt new file mode 100644 index 00000000..dbbf4bb2 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensionsTest.kt @@ -0,0 +1,372 @@ +/* + * 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.response + +import org.dexpace.sdk.core.http.common.Headers +import org.dexpace.sdk.core.http.common.MediaType +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.response.exception.HttpException +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.Serializer +import org.dexpace.sdk.io.OkioIoProvider +import java.io.InputStream +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ResponseExtensionsTest { + @BeforeTest + fun installProvider() { + Io.installProvider(OkioIoProvider) + } + + private fun request(): Request = + Request.builder() + .url("https://api.example.test/") + .method(Method.GET) + .build() + + private fun response( + status: Status, + bodyText: String? = null, + ): Response { + val builder = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(status) + if (bodyText != null) { + val source = Io.provider.source(bodyText.toByteArray(Charsets.UTF_8)) + builder.body(ResponseBody.create(source)) + } + return builder.build() + } + + // ---- throwOnError (T3) ----------------------------------------------------------- + + @Test + fun `throwOnError returns same instance for 2xx`() { + val r = response(Status.OK, "ok body") + val returned = r.throwOnError() + assertSame(r, returned) + } + + @Test + fun `throwOnError returns same instance for 204 No Content`() { + val r = response(Status.NO_CONTENT) + val returned = r.throwOnError() + assertSame(r, returned) + } + + @Test + fun `throwOnError returns same instance for 304 Not Modified`() { + val r = response(Status.fromCode(304)) + val returned = r.throwOnError() + assertSame(r, returned) + } + + @Test + fun `throwOnError returns 3xx unchanged without consuming the body`() { + val r = response(Status.fromCode(302), "redirect target body") + val returned = r.throwOnError() + assertSame(r, returned) + // The body must be intact — throwOnError must not drain a non-error response. + assertEquals("redirect target body", assertNotNull(returned.body).string()) + } + + @Test + fun `throwOnError returns same instance for 1xx informational`() { + val r = response(Status.fromCode(100)) + assertSame(r, r.throwOnError()) + } + + @Test + fun `throwOnError throws HttpException for 400`() { + val r = response(Status.BAD_REQUEST, """{"error":"bad"}""") + assertFailsWith { + r.throwOnError() + } + } + + @Test + fun `throwOnError throws HttpException for 404`() { + val r = response(Status.NOT_FOUND, """{"error":"not found"}""") + assertFailsWith { + r.throwOnError() + } + } + + @Test + fun `throwOnError throws HttpException for 500`() { + val r = response(Status.INTERNAL_SERVER_ERROR, "boom") + assertFailsWith { + r.throwOnError() + } + } + + @Test + fun `throwOnError exception body is still readable after throw`() { + val bodyText = """{"error":"rate limited"}""" + val r = response(Status.TOO_MANY_REQUESTS, bodyText) + val ex = + assertFailsWith { + r.throwOnError() + } + val exBody = assertNotNull(ex.body) + val bodyString = exBody.string() + assertEquals(bodyText, bodyString) + } + + @Test + fun `throwOnError buffered body is replayable and readable more than once`() { + // bufferErrorBody produces a replayable in-memory body: each source() call re-reads the full + // bytes. So the caught exception's body can be decoded via bodyAs AND then read again via + // string(), both seeing the complete buffered payload. + val bodyText = """{"error":"boom","detail":"buffered and replayable"}""" + val r = response(Status.INTERNAL_SERVER_ERROR, bodyText) + val ex = assertFailsWith { r.throwOnError() } + + // First read: decode via bodyAs using a deserializer that consumes the whole stream. + val firstRead = ex.bodyAs(streamReadingDeserializer(), String::class.java) + assertEquals(bodyText, firstRead) + + // Second read: the buffered body is replayable, so string() re-reads the full bytes. + val secondRead = assertNotNull(ex.body).string() + assertEquals(bodyText, secondRead) + } + + @Test + fun `throwOnError exception status matches original response status`() { + val r = response(Status.NOT_FOUND, "nope") + val ex = + assertFailsWith { + r.throwOnError() + } + assertEquals(Status.NOT_FOUND, ex.status) + } + + @Test + fun `throwOnError works when response body is null`() { + val r = response(Status.INTERNAL_SERVER_ERROR, null) + val ex = + assertFailsWith { + r.throwOnError() + } + assertEquals(Status.INTERNAL_SERVER_ERROR, ex.status) + } + + // ---- throwOnError truncation ------------------------------------------------- + + @Test + fun `throwOnError truncates body larger than 1 MiB`() { + val oneMib = 1 * 1024 * 1024 + val oversizedBody = ByteArray(oneMib + 512) { 'x'.code.toByte() } + val source = Io.provider.source(oversizedBody) + val r = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(Status.BAD_REQUEST) + .body(ResponseBody.create(source)) + .build() + val ex = assertFailsWith { r.throwOnError() } + val exBody = assertNotNull(ex.body) + val bytes = exBody.bytes() + assertEquals(oneMib, bytes.size) + } + + // ---- HttpException.bodyAs (T3) --------------------------------------------------- + + data class ErrorPayload(val code: String) + + @Test + fun `bodyAs decodes body with Deserializer`() { + val expected = ErrorPayload("err_code") + val fakeDeserializer = constantDeserializer(expected) + val r = response(Status.BAD_REQUEST, """{"code":"err_code"}""") + val ex = assertFailsWith { r.throwOnError() } + val result = ex.bodyAs(fakeDeserializer, ErrorPayload::class.java) + assertEquals(expected, result) + } + + @Test + fun `bodyAs returns null when exception body is null`() { + val neverDeserializer = throwingDeserializer() + val ex = + object : HttpException( + status = Status.BAD_REQUEST, + headers = Headers.Builder().build(), + body = null, + ) {} + assertNull(ex.bodyAs(neverDeserializer, ErrorPayload::class.java)) + } + + @Test + fun `bodyAs returns null when Deserializer throws`() { + val r = response(Status.INTERNAL_SERVER_ERROR, """{"x":1}""") + val ex = assertFailsWith { r.throwOnError() } + assertNull(ex.bodyAs(throwingDeserializer(), ErrorPayload::class.java)) + } + + @Test + fun `bodyAs closes the body on success`() { + var closed = false + val fakeBody = trackingBody { closed = true } + val ex = + object : HttpException( + status = Status.BAD_REQUEST, + headers = Headers.Builder().build(), + body = fakeBody, + ) {} + ex.bodyAs(constantDeserializer("ok"), String::class.java) + assertTrue(closed, "ResponseBody.close() must be called after successful decode") + } + + @Test + fun `bodyAs closes the body when Deserializer throws`() { + var closed = false + val fakeBody = trackingBody { closed = true } + val ex = + object : HttpException( + status = Status.INTERNAL_SERVER_ERROR, + headers = Headers.Builder().build(), + body = fakeBody, + ) {} + ex.bodyAs(throwingDeserializer(), String::class.java) + assertTrue(closed, "ResponseBody.close() must be called even when the deserializer throws") + } + + // ---- Response.deserialize (T9) --------------------------------------------------- + + data class User(val id: Int) + + @Test + fun `deserialize returns decoded value`() { + val expected = User(42) + val serde = fakeSerde(constantDeserializer(expected)) + val r = response(Status.OK, """{"id":42}""") + val result = r.deserialize(serde, User::class.java) + assertEquals(expected, result) + } + + @Test + fun `deserialize throws SerdeException when body is null`() { + val serde = fakeSerde(throwingDeserializer()) + val r = response(Status.OK, null) + assertFailsWith { + r.deserialize(serde, User::class.java) + } + } + + @Test + fun `deserialize reified decodes a concrete type`() { + val expected = User(99) + val serde = fakeSerde(constantDeserializer(expected)) + val r = response(Status.OK, """{"id":99}""") + val result = r.deserialize(serde) + assertEquals(expected, result) + } + + // ---- helpers ----------------------------------------------------------------------- + + /** Returns a [ResponseBody] backed by an empty in-memory source; [onClose] is invoked when closed. */ + private fun trackingBody(onClose: () -> Unit): ResponseBody { + val source = Io.provider.source(ByteArray(0)) + return object : ResponseBody() { + override fun mediaType(): MediaType? = null + + override fun contentLength(): Long = 0L + + override fun source(): BufferedSource = source + + override fun close() { + onClose() + source.close() + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun constantDeserializer(value: T): Deserializer = + object : Deserializer { + override fun deserialize( + input: String, + type: Class, + ): R = throw UnsupportedOperationException() + + override fun deserialize( + input: ByteArray, + type: Class, + ): R = throw UnsupportedOperationException() + + override fun deserialize( + inputStream: InputStream, + type: Class, + ): R = value as R + } + + private fun throwingDeserializer(): Deserializer = + object : Deserializer { + override fun deserialize( + input: String, + type: Class, + ): R = throw RuntimeException("decode failure") + + override fun deserialize( + input: ByteArray, + type: Class, + ): R = throw RuntimeException("decode failure") + + override fun deserialize( + inputStream: InputStream, + type: Class, + ): R = + throw RuntimeException( + "decode failure", + ) + } + + /** Deserializer that reads the whole input stream and returns it as a UTF-8 String. */ + @Suppress("UNCHECKED_CAST") + private fun streamReadingDeserializer(): Deserializer = + object : Deserializer { + override fun deserialize( + input: String, + type: Class, + ): R = throw UnsupportedOperationException() + + override fun deserialize( + input: ByteArray, + type: Class, + ): R = throw UnsupportedOperationException() + + override fun deserialize( + inputStream: InputStream, + type: Class, + ): R = inputStream.readBytes().toString(Charsets.UTF_8) as R + } + + private fun fakeSerde(deserializer: Deserializer): Serde = + object : Serde { + override val serializer: Serializer get() = throw UnsupportedOperationException() + override val deserializer: Deserializer = deserializer + + override fun contentType() = MediaType.APPLICATION_JSON + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseTest.kt index 34ee3aae..919754ce 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseTest.kt @@ -22,6 +22,7 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame @@ -345,6 +346,109 @@ class ResponseTest { resp.close() assertNull(resp.body) } + + // ---- isSuccessful (T2) ------------------------------------------------------------- + + @Test + fun `isSuccessful is true for 2xx status`() { + val resp = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .build() + assertTrue(resp.isSuccessful) + } + + @Test + fun `isSuccessful is true for 201 CREATED`() { + val resp = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(Status.CREATED) + .build() + assertTrue(resp.isSuccessful) + } + + @Test + fun `isSuccessful is false for 4xx status`() { + val resp = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(Status.NOT_FOUND) + .build() + assertFalse(resp.isSuccessful) + } + + @Test + fun `isSuccessful is false for 5xx status`() { + val resp = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(Status.INTERNAL_SERVER_ERROR) + .build() + assertFalse(resp.isSuccessful) + } + + @Test + fun `isSuccessful is false for 3xx redirect`() { + val resp = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(Status.MOVED_PERMANENTLY) + .build() + assertFalse(resp.isSuccessful) + } + + // ---- status-class delegation (isInformational / isRedirect / isClientError / …) --------- + + private fun responseWith(status: Status): Response = + Response.builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .status(status) + .build() + + @Test + fun `status-class predicates delegate to Status for representative codes`() { + // Each predicate must mirror Status's own classification. Representative codes: + // 100 informational, 301 redirect, 404 client error, 503 server error, 200 success. + val informational = responseWith(Status.fromCode(100)) + assertTrue(informational.isInformational) + assertFalse(informational.isRedirect) + assertFalse(informational.isClientError) + assertFalse(informational.isServerError) + assertFalse(informational.isError) + + val redirect = responseWith(Status.fromCode(301)) + assertFalse(redirect.isInformational) + assertTrue(redirect.isRedirect) + assertFalse(redirect.isClientError) + assertFalse(redirect.isServerError) + assertFalse(redirect.isError) + + val clientError = responseWith(Status.fromCode(404)) + assertFalse(clientError.isRedirect) + assertTrue(clientError.isClientError) + assertFalse(clientError.isServerError) + assertTrue(clientError.isError) + + val serverError = responseWith(Status.fromCode(503)) + assertFalse(serverError.isClientError) + assertTrue(serverError.isServerError) + assertTrue(serverError.isError) + + val success = responseWith(Status.fromCode(200)) + assertFalse(success.isInformational) + assertFalse(success.isRedirect) + assertFalse(success.isClientError) + assertFalse(success.isServerError) + assertFalse(success.isError) + } } class ResponseBodyTest { @@ -423,4 +527,71 @@ class ResponseBodyTest { // "close before read" path is supported. assertTrue(true) } + + // ---- string() and bytes() (T2) ---------------------------------------------------- + + @Test + fun `string() reads entire body as UTF-8 by default`() { + val payload = "Hello, World!" + val source = Io.provider.source(payload.toByteArray(Charsets.UTF_8)) + val body = ResponseBody.create(source) + assertEquals(payload, body.string()) + } + + @Test + fun `string() accepts an explicit charset`() { + val payload = "café" + val bytes = payload.toByteArray(Charsets.ISO_8859_1) + val source = Io.provider.source(bytes) + val body = ResponseBody.create(source) + assertEquals(payload, body.string(Charsets.ISO_8859_1)) + } + + @Test + fun `bytes() reads all raw bytes from body`() { + val payload = byteArrayOf(1, 2, 3, 4, 5) + val source = Io.provider.source(payload) + val body = ResponseBody.create(source) + assertContentEquals(payload, body.bytes()) + } + + @Test + fun `string() on empty body returns empty string`() { + val source = Io.provider.source(ByteArray(0)) + val body = ResponseBody.create(source) + assertEquals("", body.string()) + } + + @Test + fun `bytes() on empty body returns empty array`() { + val source = Io.provider.source(ByteArray(0)) + val body = ResponseBody.create(source) + assertContentEquals(ByteArray(0), body.bytes()) + } + + // ---- string() default charset derives from the media type ---------------------------- + + @Test + fun `no-arg string() decodes using the charset declared in the media type`() { + // 0xE9 is 'é' in ISO-8859-1 but an invalid lone byte in UTF-8. With the media type + // declaring charset=ISO-8859-1, the no-arg string() must latin-1-decode it to "é", + // not fall back to UTF-8. + val bytes = byteArrayOf(0xE9.toByte()) + val mediaType = MediaType.parse("text/plain; charset=ISO-8859-1") + val source = Io.provider.source(bytes) + val body = ResponseBody.create(source, mediaType, bytes.size.toLong()) + assertEquals("é", body.string()) + } + + @Test + fun `explicit charset argument overrides the media type charset`() { + // Bytes are the UTF-8 encoding of "é" (0xC3 0xA9). Even though the media type declares + // ISO-8859-1 (under which those bytes decode to "é"), an explicit string(UTF_8) must use + // UTF-8 and yield "é" — proving the argument takes precedence over the media type charset. + val bytes = "é".toByteArray(Charsets.UTF_8) + val mediaType = MediaType.parse("text/plain; charset=ISO-8859-1") + val source = Io.provider.source(bytes) + val body = ResponseBody.create(source, mediaType, bytes.size.toLong()) + assertEquals("é", body.string(Charsets.UTF_8)) + } } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/StatusTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/StatusTest.kt index fce86f6e..2cf2f86b 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/StatusTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/StatusTest.kt @@ -175,4 +175,94 @@ class StatusTest { assertEquals(Integer.MAX_VALUE, sMax.code) assertNull(sMax.statusName) } + + // ---- range predicates (T1) -------------------------------------------------------- + + @Test + fun `100 isInformational is true`() { + assertTrue(Status.fromCode(100).isInformational) + assertTrue(Status.CONTINUE.isInformational) + assertTrue(Status.SWITCHING_PROTOCOLS.isInformational) + assertFalse(Status.fromCode(99).isInformational) + assertFalse(Status.fromCode(200).isInformational) + } + + @Test + fun `200 isSuccess and not isError`() { + val s = Status.fromCode(200) + assertTrue(s.isSuccess) + assertFalse(s.isError) + } + + @Test + fun `301 isRedirect is true`() { + assertTrue(Status.fromCode(301).isRedirect) + assertTrue(Status.MOVED_PERMANENTLY.isRedirect) + assertFalse(Status.OK.isRedirect) + assertFalse(Status.NOT_FOUND.isRedirect) + } + + @Test + fun `404 isClientError and isError are true`() { + val s = Status.fromCode(404) + assertTrue(s.isClientError) + assertTrue(s.isError) + assertFalse(s.isServerError) + } + + @Test + fun `503 isServerError and isError are true`() { + val s = Status.fromCode(503) + assertTrue(s.isServerError) + assertTrue(s.isError) + assertFalse(s.isClientError) + } + + @Test + fun `isInformational covers full 1xx range`() { + for (code in 100..199) { + assertTrue(Status.fromCode(code).isInformational, "code $code should be informational") + } + assertFalse(Status.fromCode(99).isInformational) + assertFalse(Status.fromCode(200).isInformational) + } + + @Test + fun `isRedirect covers full 3xx range`() { + for (code in 300..399) { + assertTrue(Status.fromCode(code).isRedirect, "code $code should be redirect") + } + assertFalse(Status.fromCode(299).isRedirect) + assertFalse(Status.fromCode(400).isRedirect) + } + + @Test + fun `isClientError covers full 4xx range`() { + for (code in 400..499) { + assertTrue(Status.fromCode(code).isClientError, "code $code should be client error") + } + assertFalse(Status.fromCode(399).isClientError) + assertFalse(Status.fromCode(500).isClientError) + } + + @Test + fun `isServerError covers full 5xx range`() { + for (code in 500..599) { + assertTrue(Status.fromCode(code).isServerError, "code $code should be server error") + } + assertFalse(Status.fromCode(499).isServerError) + assertFalse(Status.fromCode(600).isServerError) + } + + @Test + fun `isError is true for 4xx and 5xx only`() { + for (code in 400..599) { + assertTrue(Status.fromCode(code).isError, "code $code should be error") + } + assertFalse(Status.fromCode(399).isError) + assertFalse(Status.fromCode(600).isError) + assertFalse(Status.OK.isError) + assertFalse(Status.MOVED_PERMANENTLY.isError) + assertFalse(Status.CONTINUE.isError) + } } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactoryTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactoryTest.kt index d05ef86e..96eee207 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactoryTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/exception/HttpExceptionFactoryTest.kt @@ -156,6 +156,26 @@ class HttpExceptionFactoryTest { } } + // ---- 3b. isErrorStatus range check (raw-Int callers) -------------------------------- + + @Test + fun `isErrorStatus is a plain 400 to 599 range check including non-canonical codes`() { + // Below the error range. + assertEquals(false, HttpExceptionFactory.isErrorStatus(100)) + assertEquals(false, HttpExceptionFactory.isErrorStatus(200)) + assertEquals(false, HttpExceptionFactory.isErrorStatus(304)) + assertEquals(false, HttpExceptionFactory.isErrorStatus(399)) + // Error range — canonical and non-canonical codes alike (no Status allocation, no lookup). + assertEquals(true, HttpExceptionFactory.isErrorStatus(400)) + assertEquals(true, HttpExceptionFactory.isErrorStatus(404)) + assertEquals(true, HttpExceptionFactory.isErrorStatus(460), "non-canonical 4xx must be an error") + assertEquals(true, HttpExceptionFactory.isErrorStatus(500)) + assertEquals(true, HttpExceptionFactory.isErrorStatus(599), "non-canonical 5xx must be an error") + // Above the error range. + assertEquals(false, HttpExceptionFactory.isErrorStatus(600)) + assertEquals(false, HttpExceptionFactory.isErrorStatus(0)) + } + // ---- 4. Retryable flag per subclass ------------------------------------------------- @Test diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactorTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactorTest.kt index dfbc73c9..be9b5279 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactorTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/instrumentation/UrlRedactorTest.kt @@ -181,4 +181,37 @@ class UrlRedactorTest { // The trailing '&' is intentionally dropped — the output must NOT end with '&'. assertTrue(!out.endsWith("&"), "trailing '&' must be dropped; got: $out") } + + // ---- redactUrlValue: absolute vs relative header-value redaction --------------------- + + @Test + fun `redactUrlValue redacts a disallowed query credential on an absolute URL`() { + // An OAuth authorization code in the query of an absolute (e.g. Location) header value + // must be redacted just like redact(URL) does. + val out = UrlRedactor.redactUrlValue("https://cb/?code=SECRET") + assertTrue(out.contains("code="), "the query key should be kept: $out") + assertTrue(out.contains("***"), "the credential value should be redacted: $out") + assertTrue(!out.contains("SECRET"), "raw authorization code leaked: $out") + } + + @Test + fun `redactUrlValue redacts an implicit-flow access token in the fragment`() { + // Implicit-flow tokens ride in the URL fragment as key=value tokens; they must be redacted + // under the same allow-list as query parameters. + val out = UrlRedactor.redactUrlValue("https://cb/#access_token=SECRET") + assertTrue(out.contains("#"), "fragment marker should be present: $out") + assertTrue(out.contains("access_token="), "fragment key should be kept: $out") + assertTrue(out.contains("***"), "fragment token value should be redacted: $out") + assertTrue(!out.contains("SECRET"), "raw fragment token leaked: $out") + } + + @Test + fun `redactUrlValue keeps the path but drops the query for a relative value`() { + // A relative Location value is not a parseable absolute URL, so the whole query (which may + // carry an OAuth code / pre-signed signature) is dropped while the path stays visible. + val out = UrlRedactor.redactUrlValue("/cb?code=SECRET") + assertEquals("/cb?***", out) + assertTrue(out.startsWith("/cb"), "the path must be preserved: $out") + assertTrue(!out.contains("SECRET"), "raw query credential leaked from a relative value: $out") + } } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/io/IoTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/io/IoTest.kt index bfa80a1c..604aec3e 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/io/IoTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/io/IoTest.kt @@ -7,12 +7,22 @@ package org.dexpace.sdk.core.io +import org.dexpace.sdk.core.instrumentation.ClientLogger +import org.dexpace.sdk.core.instrumentation.FakeSlf4jLogger import org.dexpace.sdk.core.testing.withProvider import org.dexpace.sdk.io.OkioIoProvider +import org.slf4j.event.Level +import java.io.File +import java.net.URL +import java.util.Collections +import java.util.Enumeration +import java.util.ServiceLoader import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -124,4 +134,259 @@ class IoTest { // Outer exit restores OkioIoProvider. assertSame(OkioIoProvider, Io.provider) } + + // ---- selectProvider unit tests (internal function, visible to tests) ---------- + + @Test + fun `selectProvider with empty list throws actionable message`() { + val thrown = + assertFailsWith { + Io.selectProvider(emptyList()) + } + assertTrue(thrown.message!!.contains("No IoProvider installed")) + assertTrue(thrown.message!!.contains("Io.installProvider")) + } + + @Test + fun `selectProvider with single candidate returns it`() { + val fake = object : IoProvider by OkioIoProvider {} + val result = Io.selectProvider(listOf(fake)) + assertSame(fake, result) + } + + @Test + fun `selectProvider with multiple candidates throws listing all class names`() { + val fakeA = object : IoProvider by OkioIoProvider {} + val fakeB = object : IoProvider by OkioIoProvider {} + val thrown = + assertFailsWith { + Io.selectProvider(listOf(fakeA, fakeB)) + } + assertTrue(thrown.message!!.contains("Multiple")) + assertTrue(thrown.message!!.contains("Io.installProvider")) + } + + // ---- ServiceLoader integration tests ---------------------------------------- + + @Test + fun `ServiceLoader discovers exactly one IoProvider on the classpath`() { + // sdk-io-okio3 registers OkioIoProviderLoader; with no other provider on the + // test classpath there should be exactly one candidate. + val providers = + ServiceLoader.load(IoProvider::class.java, IoProvider::class.java.classLoader).toList() + assertTrue(providers.isNotEmpty(), "Expected ServiceLoader to find at least one IoProvider") + assertTrue(providers.size == 1, "Expected exactly one IoProvider, found: ${providers.size}") + } + + @Test + fun `provider resolves via ServiceLoader when no explicit install`() { + // Clear the explicitly installed provider and the resolved cache, then confirm that + // Io.provider still returns a functional provider auto-discovered via ServiceLoader. + val saved = Io.swapProvider(null) + try { + val p = Io.provider + assertNotNull(p) + // Confirm it is actually functional (not a no-op stub). + assertNotNull(p.buffer()) + } finally { + // Restore previous explicit install so @AfterTest sees a consistent state. + Io.swapProvider(saved) + } + } + + // ---- classloader search order (TCCL fallback) ------------------------------- + + @Test + fun `loaderSearchOrder puts the thread-context loader first`() { + val tccl = object : ClassLoader() {} + val defining = object : ClassLoader() {} + assertEquals(listOf(tccl, defining), Io.loaderSearchOrder(tccl, defining)) + } + + @Test + fun `loaderSearchOrder falls back to the defining loader when no thread-context loader`() { + val defining = object : ClassLoader() {} + assertEquals(listOf(defining), Io.loaderSearchOrder(null, defining)) + } + + @Test + fun `loaderSearchOrder does not scan twice when both loaders are the same`() { + val loader = object : ClassLoader() {} + assertEquals(listOf(loader), Io.loaderSearchOrder(loader, loader)) + } + + @Test + fun `loadCandidates discovers a provider visible only via the thread-context loader`() { + // Simulate a hierarchical-classloader deployment: the okio provider is visible via the + // defining loader, while a second provider is registered only in a child loader that the + // thread-context classloader exposes. The hardened lookup must find BOTH, and must count + // the okio provider (visible through both loaders) exactly once. + val servicesFile = File.createTempFile("io-provider-service", ".txt") + servicesFile.writeText(TccOnlyProvider::class.java.name) + servicesFile.deleteOnExit() + + val parent = Thread.currentThread().contextClassLoader ?: ClassLoader.getSystemClassLoader() + val childLoader = ServiceInjectingClassLoader(parent, servicesFile.toURI().toURL()) + val savedTccl = Thread.currentThread().contextClassLoader + Thread.currentThread().contextClassLoader = childLoader + try { + val candidates = Io.loadCandidates() + val classNames = candidates.map { it::class.java.name } + assertTrue( + candidates.any { it is TccOnlyProvider }, + "expected the TCCL-only provider to be discovered; got $classNames", + ) + assertEquals( + 1, + candidates.count { it::class.java.name == "org.dexpace.sdk.io.OkioIoProviderLoader" }, + "the okio provider is visible via both loaders and must be de-duplicated; got $classNames", + ) + } finally { + Thread.currentThread().contextClassLoader = savedTccl + servicesFile.delete() + } + } + + // ---- install-after-resolve WARN --------------------------------------------- + + @Test + fun `installProvider warns when replacing a resolved-and-handed-out provider`() { + val fake = FakeSlf4jLogger("io.test") + val savedLogger = Io.logger + val savedInstalled = Io.swapProvider(null) + Io.logger = ClientLogger.forTesting(fake) + try { + // Force ServiceLoader resolution AND hand-out (library code touching Io.provider early). + val handedOut = Io.provider + assertNotNull(handedOut) + // A late explicit install of a genuinely DIFFERENT provider still wins, but warns. + // `underlying` is overridden to itself so it is not treated as the same implementation + // as the auto-resolved provider (whose `underlying` is the Okio singleton). + val explicit = + object : IoProvider by OkioIoProvider { + override val underlying: IoProvider get() = this + } + Io.installProvider(explicit) + assertSame(explicit, Io.provider, "explicit install must win over the auto-resolved provider") + + val warn = + fake.records.singleOrNull { rec -> + rec.level == Level.WARN && + rec.keyValues.any { it.key == "event" && it.value == "io.provider.install_after_resolve" } + } + assertNotNull(warn, "expected exactly one install-after-resolve WARN") + assertTrue( + warn.keyValues.any { it.key == "installed" }, + "warning should name the newly-installed provider", + ) + } finally { + Io.logger = savedLogger + Io.swapProvider(savedInstalled) + } + } + + @Test + fun `installProvider does not warn when installing the same auto-resolved provider`() { + val fake = FakeSlf4jLogger("io.test") + val savedLogger = Io.logger + val savedInstalled = Io.swapProvider(null) + Io.logger = ClientLogger.forTesting(fake) + try { + val handedOut = Io.provider + Io.installProvider(handedOut) // same instance -> explicit install, no warning + assertSame(handedOut, Io.provider) + assertTrue( + fake.records.none { it.level == Level.WARN }, + "installing the same resolved provider must not warn", + ) + } finally { + Io.logger = savedLogger + Io.swapProvider(savedInstalled) + } + } + + @Test + fun `installing OkioIoProvider after its ServiceLoader shim was resolved does not warn`() { + // The auto-resolved provider is the OkioIoProviderLoader shim (registered in + // META-INF/services), which delegates to OkioIoProvider. A later explicit + // installProvider(OkioIoProvider) is a DIFFERENT instance than the shim, but they share the + // same `underlying` implementation — so no mixed-provider state can arise and no WARN must + // fire (contrast the "replacing a resolved-and-handed-out provider" test, whose provider + // overrides `underlying` to itself precisely to force the warning). + val fake = FakeSlf4jLogger("io.test") + val savedLogger = Io.logger + val savedInstalled = Io.swapProvider(null) + Io.logger = ClientLogger.forTesting(fake) + try { + // Force ServiceLoader resolution AND hand-out. + val handedOut = Io.provider + assertTrue( + handedOut !== OkioIoProvider, + "the resolved provider must be the ServiceLoader shim, not the OkioIoProvider singleton", + ) + assertSame( + OkioIoProvider, + handedOut.underlying, + "the shim delegates to OkioIoProvider — they share the same underlying implementation", + ) + Io.installProvider(OkioIoProvider) + assertSame(OkioIoProvider, Io.provider, "explicit install must take effect") + assertTrue( + fake.records.none { + it.level == Level.WARN && + it.keyValues.any { kv -> kv.key == "event" && kv.value == "io.provider.install_after_resolve" } + }, + "installing OkioIoProvider after resolving its own ServiceLoader shim must not warn", + ) + } finally { + Io.logger = savedLogger + Io.swapProvider(savedInstalled) + } + } + + @Test + fun `installProvider does not warn on a first install with no prior resolution`() { + val fake = FakeSlf4jLogger("io.test") + val savedLogger = Io.logger + // swapProvider(null) clears installed + resolved + the handed-out flag; no getter is called, + // so nothing was ever resolved. + val savedInstalled = Io.swapProvider(null) + Io.logger = ClientLogger.forTesting(fake) + try { + val explicit = object : IoProvider by OkioIoProvider {} + Io.installProvider(explicit) + assertSame(explicit, Io.provider) + assertTrue( + fake.records.none { it.level == Level.WARN }, + "a first install with nothing previously resolved must not warn", + ) + } finally { + Io.logger = savedLogger + Io.swapProvider(savedInstalled) + } + } +} + +/** + * Test provider registered only through [ServiceInjectingClassLoader]. Public with a no-arg + * constructor so [ServiceLoader] can instantiate it; delegates all behaviour to [OkioIoProvider]. + */ +class TccOnlyProvider : IoProvider by OkioIoProvider + +/** + * Classloader that injects one extra `META-INF/services/` entry on top of whatever the + * parent already exposes, letting a test simulate a provider registered only in a child loader. + * Class loading itself delegates to the parent so the injected provider class remains resolvable. + */ +private class ServiceInjectingClassLoader( + parent: ClassLoader, + private val extraServiceUrl: URL, +) : ClassLoader(parent) { + override fun getResources(name: String): Enumeration { + val resources = Collections.list(super.getResources(name)) + if (name == "META-INF/services/${IoProvider::class.java.name}") { + resources.add(extraServiceUrl) + } + return Collections.enumeration(resources) + } } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginatorTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginatorTest.kt index 4fffa22f..54317e67 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginatorTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginatorTest.kt @@ -35,10 +35,11 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class AsyncPaginatorTest { - private val itemsExtractor: (Response) -> List = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - if (body.isEmpty()) emptyList() else body.split(",") - } + private val itemsExtractor = + ItemsExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + if (body.isEmpty()) emptyList() else body.split(",") + } @BeforeTest fun setup() { diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationTest.kt index ed70a9bf..77378d95 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorPaginationTest.kt @@ -32,14 +32,41 @@ class CursorPaginationTest { * the body exactly once and returns both the items and the next cursor as a * [CursorResult], so there is no double-drain of the single-use response body. */ - private val extractor: (Response) -> CursorResult = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - val itemsLine = body.lineSequence().firstOrNull { it.startsWith("items=") } ?: "items=" - val cursorLine = body.lineSequence().firstOrNull { it.startsWith("cursor=") } ?: "cursor=" - val itemsRaw = itemsLine.removePrefix("items=") - val cursorRaw = cursorLine.removePrefix("cursor=") - val items = if (itemsRaw.isEmpty()) emptyList() else itemsRaw.split(",") - CursorResult(items, cursorRaw.ifEmpty { null }) + private val extractor = + CursorExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + val itemsLine = body.lineSequence().firstOrNull { it.startsWith("items=") } ?: "items=" + val cursorLine = body.lineSequence().firstOrNull { it.startsWith("cursor=") } ?: "cursor=" + val itemsRaw = itemsLine.removePrefix("items=") + val cursorRaw = cursorLine.removePrefix("cursor=") + val items = if (itemsRaw.isEmpty()) emptyList() else itemsRaw.split(",") + CursorResult(items, cursorRaw.ifEmpty { null }) + } + + @Test + fun `cursor strategy accepts a Java-style SAM extractor and walks two pages`() { + val client = StubHttpClient() + client.on("https://api.example.com/items") { req -> + textResponse(req, "items=a,b\ncursor=n1") + } + client.on("https://api.example.com/items?cursor=n1") { req -> + textResponse(req, "items=c,d\ncursor=") + } + // Java caller path: an explicit CursorExtractor implementation (what an anonymous class + // compiles to), not a Kotlin function type — proves the constructor takes the fun interface, + // not kotlin.jvm.functions.Function1. + val javaStyle = + object : CursorExtractor { + override fun extract(response: Response): CursorResult { + val body = response.body!!.source().use { it.readUtf8() } + val items = body.substringAfter("items=").substringBefore("\n").split(",") + val cursor = body.substringAfter("cursor=").ifEmpty { null } + return CursorResult(items, cursor) + } + } + val paginator = Paginator(client, initialRequest(), CursorPaginationStrategy(javaStyle)) + assertEquals(listOf("a", "b", "c", "d"), paginator.iterateAll().toList()) + assertEquals(2, client.callCount) } @Test diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorSingleReadTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorSingleReadTest.kt index 20201ef1..277a5765 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorSingleReadTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/CursorSingleReadTest.kt @@ -9,7 +9,6 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request -import org.dexpace.sdk.core.http.response.Response import java.util.concurrent.atomic.AtomicInteger import kotlin.test.BeforeTest import kotlin.test.Test @@ -37,8 +36,8 @@ class CursorSingleReadTest { * Parses the body format `items=\ncursor=` into a [CursorResult]. * Reads the body exactly once and reports each read through [reads]. */ - private fun singleReadExtractor(reads: AtomicInteger): (Response) -> CursorResult = - { resp -> + private fun singleReadExtractor(reads: AtomicInteger): CursorExtractor = + CursorExtractor { resp -> reads.incrementAndGet() val body = resp.body!!.source().use { it.readUtf8() } val itemsLine = body.lineSequence().firstOrNull { it.startsWith("items=") } ?: "items=" @@ -81,12 +80,13 @@ class CursorSingleReadTest { singleUseResponse(req, "items=x,y\ncursor=") } - val parsing: (Response) -> CursorResult = { resp -> - parses.incrementAndGet() - val body = resp.body!!.source().use { it.readUtf8() } - val itemsRaw = body.substringAfter("items=").substringBefore('\n') - CursorResult(itemsRaw.split(","), null) - } + val parsing = + CursorExtractor { resp -> + parses.incrementAndGet() + val body = resp.body!!.source().use { it.readUtf8() } + val itemsRaw = body.substringAfter("items=").substringBefore('\n') + CursorResult(itemsRaw.split(","), null) + } val strategy = CursorPaginationStrategy(parsing) val paginator = Paginator(client, initialRequest(), strategy) diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LazinessTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LazinessTest.kt index daf5ebab..9866ecd3 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LazinessTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LazinessTest.kt @@ -9,7 +9,6 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request -import org.dexpace.sdk.core.http.response.Response import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -27,10 +26,11 @@ class LazinessTest { .method(Method.GET) .build() - private val itemsExtractor: (Response) -> List = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - if (body.isEmpty()) emptyList() else body.split(",") - } + private val itemsExtractor = + ItemsExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + if (body.isEmpty()) emptyList() else body.split(",") + } @Test fun `constructing a paginator triggers zero HTTP calls`() { diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationTest.kt index 6898cf12..1324db31 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationTest.kt @@ -26,9 +26,36 @@ class LinkHeaderPaginationTest { .method(Method.GET) .build() - private val itemsExtractor: (Response) -> List = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - if (body.isEmpty()) emptyList() else body.split(",") + private val itemsExtractor = + ItemsExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + if (body.isEmpty()) emptyList() else body.split(",") + } + + @Test + fun `link strategy accepts a Java-style SAM extractor and walks two pages`() { + val client = StubHttpClient() + client.on("https://api.example.com/repo/issues") { req -> + textResponse( + req, + "i1,i2", + extraHeaders = mapOf("Link" to "; rel=\"next\""), + ) + } + client.on("https://api.example.com/repo/issues?page=2") { req -> + textResponse(req, "i3,i4") + } + // Java caller path: an explicit ItemsExtractor implementation, not a Kotlin function type. + val javaStyle = + object : ItemsExtractor { + override fun extract(response: Response): List { + val body = response.body!!.source().use { it.readUtf8() } + return if (body.isEmpty()) emptyList() else body.split(",") + } + } + val paginator = Paginator(client, initialRequest(), LinkHeaderPaginationStrategy(javaStyle)) + assertEquals(listOf("i1", "i2", "i3", "i4"), paginator.iterateAll().toList()) + assertEquals(2, client.callCount) } @Test diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationTest.kt index ceb43bd3..be649937 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PageNumberPaginationTest.kt @@ -26,13 +26,39 @@ class PageNumberPaginationTest { .method(Method.GET) .build() - private val itemsExtractor: (Response) -> List = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - if (body.isEmpty()) { - emptyList() - } else { - body.split(",") + private val itemsExtractor = + ItemsExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + if (body.isEmpty()) { + emptyList() + } else { + body.split(",") + } } + + @Test + fun `page-number strategy accepts a Java-style SAM extractor and walks two pages`() { + val client = StubHttpClient() + client.on("https://api.example.com/things") { req -> + textResponse(req, "a,b") + } + client.on("https://api.example.com/things?page=2") { req -> + textResponse(req, "c,d") + } + client.on("https://api.example.com/things?page=3") { req -> + textResponse(req, "") + } + // Java caller path: an explicit ItemsExtractor implementation, not a Kotlin function type. + val javaStyle = + object : ItemsExtractor { + override fun extract(response: Response): List { + val body = response.body!!.source().use { it.readUtf8() } + return if (body.isEmpty()) emptyList() else body.split(",") + } + } + val paginator = Paginator(client, initialRequest(), PageNumberPaginationStrategy(javaStyle)) + assertEquals(listOf("a", "b", "c", "d"), paginator.iterateAll().toList()) + assertEquals(3, client.callCount) } @Test diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorByPageTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorByPageTest.kt index f6f6a69e..8dc47e63 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorByPageTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorByPageTest.kt @@ -9,7 +9,6 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request -import org.dexpace.sdk.core.http.response.Response import java.util.concurrent.atomic.AtomicInteger import kotlin.test.BeforeTest import kotlin.test.Test @@ -24,15 +23,16 @@ class PaginatorByPageTest { .method(Method.GET) .build() - private val extractor: (Response) -> CursorResult = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - val itemsLine = body.lineSequence().firstOrNull { it.startsWith("items=") } ?: "items=" - val cursorLine = body.lineSequence().firstOrNull { it.startsWith("cursor=") } ?: "cursor=" - val itemsRaw = itemsLine.removePrefix("items=") - val cursorRaw = cursorLine.removePrefix("cursor=") - val items = if (itemsRaw.isEmpty()) emptyList() else itemsRaw.split(",") - CursorResult(items, cursorRaw.ifEmpty { null }) - } + private val extractor = + CursorExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + val itemsLine = body.lineSequence().firstOrNull { it.startsWith("items=") } ?: "items=" + val cursorLine = body.lineSequence().firstOrNull { it.startsWith("cursor=") } ?: "cursor=" + val itemsRaw = itemsLine.removePrefix("items=") + val cursorRaw = cursorLine.removePrefix("cursor=") + val items = if (itemsRaw.isEmpty()) emptyList() else itemsRaw.split(",") + CursorResult(items, cursorRaw.ifEmpty { null }) + } @Test fun `byPage exposes one rich page per HTTP exchange with live response, then closes each`() { diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCapTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCapTest.kt index 0db00c64..722f8b32 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCapTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCapTest.kt @@ -9,7 +9,6 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request -import org.dexpace.sdk.core.http.response.Response import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -27,10 +26,11 @@ class PaginatorCapTest { .method(Method.GET) .build() - private val itemsExtractor: (Response) -> List = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - if (body.isEmpty()) emptyList() else body.split(",") - } + private val itemsExtractor = + ItemsExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + if (body.isEmpty()) emptyList() else body.split(",") + } /** * A server that always points `rel="next"` back at the same URL never advances its diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCloseTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCloseTest.kt index e45f1320..4687ab1d 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCloseTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorCloseTest.kt @@ -9,7 +9,6 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request -import org.dexpace.sdk.core.http.response.Response import java.io.IOException import java.io.UncheckedIOException import java.util.concurrent.atomic.AtomicInteger @@ -17,6 +16,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue /** * Response-close coverage for the sync [Paginator] that the async mirror already has: the @@ -32,15 +32,16 @@ class PaginatorCloseTest { .method(Method.GET) .build() - private val extractor: (Response) -> CursorResult = { resp -> - val body = resp.body!!.source().use { it.readUtf8() } - val itemsLine = body.lineSequence().firstOrNull { it.startsWith("items=") } ?: "items=" - val cursorLine = body.lineSequence().firstOrNull { it.startsWith("cursor=") } ?: "cursor=" - val itemsRaw = itemsLine.removePrefix("items=") - val cursorRaw = cursorLine.removePrefix("cursor=") - val items = if (itemsRaw.isEmpty()) emptyList() else itemsRaw.split(",") - CursorResult(items, cursorRaw.ifEmpty { null }) - } + private val extractor = + CursorExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + val itemsLine = body.lineSequence().firstOrNull { it.startsWith("items=") } ?: "items=" + val cursorLine = body.lineSequence().firstOrNull { it.startsWith("cursor=") } ?: "cursor=" + val itemsRaw = itemsLine.removePrefix("items=") + val cursorRaw = cursorLine.removePrefix("cursor=") + val items = if (itemsRaw.isEmpty()) emptyList() else itemsRaw.split(",") + CursorResult(items, cursorRaw.ifEmpty { null }) + } /** Two-page cursor stub whose page responses record their close count into [closes]. */ private fun twoPageClient(closes: AtomicInteger): StubHttpClient = @@ -131,6 +132,32 @@ class PaginatorCloseTest { assertEquals(2, closes.get(), "close() is idempotent — no double-close") } + @Test + fun `byPage hasNext probe without next still releases the prefetched page on close`() { + // A hasNext() that is never followed by next() (an emptiness probe, a peeking-iterator + // wrapper) makes the walker fetch and buffer the next page's live Response. That page must + // remain reachable so close() can release it — otherwise its connection leaks. + val closes = AtomicInteger(0) + val paginator = + Paginator(twoPageClient(closes), initialRequest(), CursorPaginationStrategy(extractor, "cursor")) + + val view = paginator.byPage() + val pages = view.iterator() + + // Probe once: page 1 is fetched and buffered, but next() is never called. + assertTrue(pages.hasNext()) + assertEquals(0, closes.get(), "the probed page is still open — not yet closed") + // A repeated probe must not fetch again (idempotent) and must not open a second page. + assertTrue(pages.hasNext()) + assertEquals(0, closes.get()) + + view.close() + assertEquals(1, closes.get(), "close() must release the page fetched by the abandoned hasNext() probe") + + view.close() + assertEquals(1, closes.get(), "close() is idempotent — no double-close of the prefetched page") + } + @Test fun `byPage stream short-circuit releases the held page when the stream is closed`() { // Regression: a short-circuiting terminal (findFirst) pulls one page and abandons the stream diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorOptionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorOptionsTest.kt new file mode 100644 index 00000000..00fbfeba --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorOptionsTest.kt @@ -0,0 +1,96 @@ +/* + * 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.pagination + +import org.dexpace.sdk.core.client.AsyncHttpClient +import org.dexpace.sdk.core.client.HttpClient +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 java.util.concurrent.CompletableFuture +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Proves the paginators thread their per-call [RequestOptions] into each page fetch. Both clients + * override only the options-aware overload and make the bare `execute`/`executeAsync` throw, so a + * paginator that dropped the options (calling the 1-arg overload) fails loudly rather than passing. + */ +class PaginatorOptionsTest { + @BeforeTest + fun setup() { + installIoProvider() + } + + private fun initialRequest(): Request = + Request.builder() + .url("https://api.example.com/items") + .method(Method.GET) + .build() + + // An empty page terminates pagination after exactly one fetch. + private val itemsExtractor = + ItemsExtractor { resp -> + val body = resp.body!!.source().use { it.readUtf8() } + if (body.isEmpty()) emptyList() else body.split(",") + } + + private fun options(): RequestOptions = RequestOptions.builder().maxRetries(0).tag("origin", "test").build() + + @Test + fun `sync paginator threads its RequestOptions into every page fetch`() { + val opts = options() + val seen = mutableListOf() + val client = + object : HttpClient { + override fun execute(request: Request): Response = + error("Paginator must call the options-aware execute(request, options) overload") + + override fun execute( + request: Request, + options: RequestOptions, + ): Response { + seen.add(options) + return textResponse(request, "") // empty page → terminate + } + } + val paginator = + Paginator(client, initialRequest(), PageNumberPaginationStrategy(itemsExtractor), options = opts) + + paginator.iterateAll().toList() + + assertEquals(listOf(opts), seen, "the paginator's options must reach the transport on each fetch") + } + + @Test + fun `async paginator threads its RequestOptions into every page fetch`() { + val opts = options() + val seen = mutableListOf() + val client = + object : AsyncHttpClient { + override fun executeAsync(request: Request): CompletableFuture = + error("AsyncPaginator must call the options-aware executeAsync(request, options) overload") + + override fun executeAsync( + request: Request, + options: RequestOptions, + ): CompletableFuture { + seen.add(options) + return CompletableFuture.completedFuture(textResponse(request, "")) + } + } + val paginator = + AsyncPaginator(client, initialRequest(), PageNumberPaginationStrategy(itemsExtractor), options = opts) + + paginator.collectAllAsync().get() + + assertEquals(listOf(opts), seen, "the async paginator's options must reach the transport on each fetch") + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponsePipelineTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChainTest.kt similarity index 90% rename from sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponsePipelineTest.kt rename to sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChainTest.kt index 933751af..9d0b323a 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponsePipelineTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChainTest.kt @@ -34,7 +34,7 @@ import java.util.concurrent.atomic.AtomicInteger * testFixtures `FakeHttpClient` so this test file stays the canonical reference for the * pipeline contract. */ -class ResponsePipelineTest { +class ResponseRecoveryChainTest { // region -- test fakes -- /** @@ -126,13 +126,13 @@ class ResponsePipelineTest { val transport = FakeHttpClient(listOf(TransportEntry.Ok(transportResponse))) val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - requestPipeline = RequestPipeline(), - responsePipeline = ResponsePipeline(), + requestChain = RequestRecoveryChain(), + responseChain = ResponseRecoveryChain(), ) - val out = pipeline.execute(req, ctx()) + val out = pipeline.recover(req, ctx()) assertSame(transportResponse, out, "Response should be the exact transport response") assertEquals(1, transport.received.size) @@ -154,12 +154,12 @@ class ResponsePipelineTest { } val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - responsePipeline = ResponsePipeline(recoverySteps = listOf(rescue)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(rescue)), ) - val out = pipeline.execute(req, ctx()) + val out = pipeline.recover(req, ctx()) assertSame(rescued, out, "Recovery step should rescue the transport failure") } @@ -173,12 +173,12 @@ class ResponsePipelineTest { val passthrough = ResponseRecoveryStep { outcome -> outcome } val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - responsePipeline = ResponsePipeline(recoverySteps = listOf(passthrough)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(passthrough)), ) - val thrown = assertThrows(IOException::class.java) { pipeline.execute(req, ctx()) } + val thrown = assertThrows(IOException::class.java) { pipeline.recover(req, ctx()) } assertSame(transportError, thrown, "Failure must be propagated unchanged") } @@ -198,12 +198,12 @@ class ResponsePipelineTest { } val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - responsePipeline = ResponsePipeline(recoverySteps = listOf(replace)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(replace)), ) - val thrown = assertThrows(IllegalStateException::class.java) { pipeline.execute(req, ctx()) } + val thrown = assertThrows(IllegalStateException::class.java) { pipeline.recover(req, ctx()) } assertSame(replacementError, thrown, "Replacement throwable should be surfaced") } @@ -232,16 +232,16 @@ class ResponsePipelineTest { } val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - responsePipeline = - ResponsePipeline( + responseChain = + ResponseRecoveryChain( responseSteps = listOf(failingResponseStep), recoverySteps = listOf(recoverFromResponseStep), ), ) - val out = pipeline.execute(req, ctx()) + val out = pipeline.recover(req, ctx()) assertSame(transportResponse, out) assertNotNull(recoverySawError, "Recovery step should have observed the response-step exception") @@ -260,7 +260,7 @@ class ResponsePipelineTest { throw responseStepError } - val pipeline = ResponsePipeline(responseSteps = listOf(failingResponseStep)) + val pipeline = ResponseRecoveryChain(responseSteps = listOf(failingResponseStep)) val out = pipeline.apply(ResponseOutcome.Success(inHand), ctx()) @@ -282,7 +282,7 @@ class ResponsePipelineTest { throw recoveryError } - val pipeline = ResponsePipeline(recoverySteps = listOf(failingRecoveryStep)) + val pipeline = ResponseRecoveryChain(recoverySteps = listOf(failingRecoveryStep)) val out = pipeline.apply(ResponseOutcome.Success(inHand), ctx()) @@ -319,13 +319,13 @@ class ResponsePipelineTest { } val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - requestPipeline = RequestPipeline(listOf(failingRequestStep)), - responsePipeline = ResponsePipeline(recoverySteps = listOf(recoverFromRequestStep)), + requestChain = RequestRecoveryChain(listOf(failingRequestStep)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(recoverFromRequestStep)), ) - val thrown = assertThrows(IllegalArgumentException::class.java) { pipeline.execute(req, ctx()) } + val thrown = assertThrows(IllegalArgumentException::class.java) { pipeline.recover(req, ctx()) } assertSame(requestStepError, thrown, "The original request-step error must propagate") assertSame( requestStepError, @@ -354,12 +354,12 @@ class ResponsePipelineTest { } val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - responsePipeline = ResponsePipeline(recoverySteps = listOf(observeTransportFailure)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(observeTransportFailure)), ) - val thrown = assertThrows(IOException::class.java) { pipeline.execute(req, ctx()) } + val thrown = assertThrows(IOException::class.java) { pipeline.recover(req, ctx()) } assertSame(transportError, thrown) assertSame(transportError, recoverySawError, "Recovery must observe transport failures") } @@ -419,12 +419,12 @@ class ResponsePipelineTest { } val pipeline = - ExecutionPipeline( + RecoveryChain( httpClient = transport, - responsePipeline = ResponsePipeline(recoverySteps = listOf(tag1, rescue, tag3)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(tag1, rescue, tag3)), ) - val out = pipeline.execute(req, ctx()) + val out = pipeline.recover(req, ctx()) assertSame(rescued, out) assertEquals(listOf("step1:Failure", "step2:Failure", "step3:Success"), observed) diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStepTest.kt index f04d8a50..38f124b7 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStepTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStepTest.kt @@ -24,7 +24,7 @@ class ClientIdentityStepTest { val step = ClientIdentityStep() val request = baseRequest() - val result = step.apply(request) + val result = step.execute(request, DispatchContext.default()) val ua = result.headers.get("User-Agent") assertNotNull(ua, "Expected User-Agent to be set") @@ -42,7 +42,7 @@ class ClientIdentityStepTest { .setHeader("User-Agent", "MyApp/1.0") .build() - val result = step.apply(request) + val result = step.execute(request, DispatchContext.default()) assertEquals( "MyApp/1.0 dexpace-sdk/${SdkInfo.sdkVersion} jvm/${SdkInfo.javaVersion}", @@ -62,7 +62,7 @@ class ClientIdentityStepTest { .setHeader("User-Agent", "MyApp/1.0") .build() - val result = step.apply(request) + val result = step.execute(request, DispatchContext.default()) assertEquals( "dexpace-sdk/${SdkInfo.sdkVersion} jvm/${SdkInfo.javaVersion}", @@ -77,7 +77,7 @@ class ClientIdentityStepTest { .tokens(listOf("foo/1", "bar/2")) .build() - val result = step.apply(baseRequest()) + val result = step.execute(baseRequest(), DispatchContext.default()) assertEquals("foo/1 bar/2", result.headers.get("User-Agent")) } @@ -89,7 +89,7 @@ class ClientIdentityStepTest { .headerName("X-Dexpace-Client") .build() - val result = step.apply(baseRequest()) + val result = step.execute(baseRequest(), DispatchContext.default()) assertEquals( "dexpace-sdk/${SdkInfo.sdkVersion} jvm/${SdkInfo.javaVersion}", @@ -107,13 +107,47 @@ class ClientIdentityStepTest { .setHeader("User-Agent", "MyApp/1.0") .build() - val result = step.apply(request) + val result = step.execute(request, DispatchContext.default()) // Same instance (request returned unchanged) and the existing User-Agent untouched. assertSame(request, result) assertEquals("MyApp/1.0", result.headers.get("User-Agent")) } + @Test + fun `append mode preserves every pre-existing value of a multi-valued header`() { + // A header can carry more than one value. Append mode must keep them all — reading only the + // first value (get()) and then replacing the whole header would silently drop the rest. + val step = + ClientIdentityStep.builder() + .headerName("X-Client") + .tokens(listOf("foo/1")) + .build() + val request = + baseRequest() + .newBuilder() + .addHeader("X-Client", "A/1") + .addHeader("X-Client", "B/2") + .build() + + val result = step.execute(request, DispatchContext.default()) + + // Token line appended to the first value; the second value survives untouched. + assertEquals(listOf("A/1 foo/1", "B/2"), result.headers.values("X-Client")) + } + + @Test + fun `an all-blank token list is a no-op and emits no header`() { + // Tokens that join to a blank line must not produce a blank/whitespace-only header. + val step = ClientIdentityStep(tokens = listOf(" ", "\t")) + val request = baseRequest() + + val result = step.execute(request, DispatchContext.default()) + + assertSame(request, result, "an all-blank token line must leave the request unchanged") + assertNull(result.headers.get("User-Agent"), "no blank User-Agent header may be emitted") + } + @Test fun `builder addToken with name and version composes the slash-separated form`() { val step = @@ -121,7 +155,7 @@ class ClientIdentityStepTest { .addToken("okhttp", "5.0.0") .build() - val result = step.apply(baseRequest()) + val result = step.execute(baseRequest(), DispatchContext.default()) assertEquals("okhttp/5.0.0", result.headers.get("User-Agent")) } @@ -134,7 +168,7 @@ class ClientIdentityStepTest { .addToken("okhttp", "5.0.0") .build() - val result = step.apply(baseRequest()) + val result = step.execute(baseRequest(), DispatchContext.default()) // The default `dexpace-sdk/...` and `jvm/...` tokens must NOT appear once the // caller has supplied any explicit token via `addToken`. @@ -167,8 +201,8 @@ class ClientIdentityStepTest { .newBuilder() .setHeader("X-Dexpace-Client", "Existing/9") .build() - val originalValue = original.apply(seeded).headers.get("X-Dexpace-Client") - val copyValue = copy.apply(seeded).headers.get("X-Dexpace-Client") + val originalValue = original.execute(seeded, DispatchContext.default()).headers.get("X-Dexpace-Client") + val copyValue = copy.execute(seeded, DispatchContext.default()).headers.get("X-Dexpace-Client") assertEquals(originalValue, copyValue) // Replace mode preserved: existing value overwritten with the copied token line. assertEquals("foo/1 bar/2", copyValue) @@ -191,8 +225,16 @@ class ClientIdentityStepTest { .setHeader("X-Client", "Caller/2") .build() - assertEquals("Caller/2 foo/1", original.apply(request).headers.get("X-Client"), "Original keeps Append mode") - assertEquals("foo/1", derived.apply(request).headers.get("X-Client"), "Derived uses Replace mode") + assertEquals( + "Caller/2 foo/1", + original.execute(request, DispatchContext.default()).headers.get("X-Client"), + "Original keeps Append mode", + ) + assertEquals( + "foo/1", + derived.execute(request, DispatchContext.default()).headers.get("X-Client"), + "Derived uses Replace mode", + ) } @Test @@ -202,7 +244,7 @@ class ClientIdentityStepTest { // addToken on a newBuilder() copy must append (no default seed to discard). val derived = original.newBuilder().addToken("bar", "2").build() - assertEquals("foo/1 bar/2", derived.apply(baseRequest()).headers.get("User-Agent")) + assertEquals("foo/1 bar/2", derived.execute(baseRequest(), DispatchContext.default()).headers.get("User-Agent")) } @Test @@ -212,12 +254,12 @@ class ClientIdentityStepTest { val copy = original.newBuilder().build() assertEquals( - original.apply(baseRequest()).headers.get("User-Agent"), - copy.apply(baseRequest()).headers.get("User-Agent"), + original.execute(baseRequest(), DispatchContext.default()).headers.get("User-Agent"), + copy.execute(baseRequest(), DispatchContext.default()).headers.get("User-Agent"), ) assertEquals( "dexpace-sdk/${SdkInfo.sdkVersion} jvm/${SdkInfo.javaVersion}", - copy.apply(baseRequest()).headers.get("User-Agent"), + copy.execute(baseRequest(), DispatchContext.default()).headers.get("User-Agent"), ) } @@ -232,7 +274,7 @@ class ClientIdentityStepTest { .setHeader("user-agent", "MyApp/1.0") .build() - val result = step.apply(request) + val result = step.execute(request, DispatchContext.default()) assertEquals("MyApp/1.0 foo/1", result.headers.get("User-Agent")) } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecoveryTest.kt similarity index 86% rename from sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStepTest.kt rename to sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecoveryTest.kt index 5938923f..097dbfc3 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorStepTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecoveryTest.kt @@ -23,7 +23,7 @@ import org.dexpace.sdk.core.http.response.exception.TooManyRequestsException import org.dexpace.sdk.core.io.BufferedSource import org.dexpace.sdk.core.io.Io import org.dexpace.sdk.core.pipeline.ResponseOutcome -import org.dexpace.sdk.core.pipeline.ResponsePipeline +import org.dexpace.sdk.core.pipeline.ResponseRecoveryChain import org.dexpace.sdk.io.OkioIoProvider import kotlin.test.BeforeTest import kotlin.test.Test @@ -35,11 +35,11 @@ import kotlin.test.assertSame import kotlin.test.assertTrue /** - * Verifies that [ThrowOnHttpErrorStep] is the live error path: an error response is routed + * Verifies that [ThrowOnHttpErrorRecovery] is the live error path: an error response is routed * through [org.dexpace.sdk.core.http.response.exception.HttpExceptionFactory.fromResponse] and * surfaces as the matching typed [HttpException], while a success response passes through. */ -class ThrowOnHttpErrorStepTest { +class ThrowOnHttpErrorRecoveryTest { @BeforeTest fun installProvider() { Io.installProvider(OkioIoProvider) @@ -50,7 +50,7 @@ class ThrowOnHttpErrorStepTest { @Test fun `2xx response passes through unchanged`() { val ok = response(Status.OK) - val out = ThrowOnHttpErrorStep.execute(ok, ctx()) + val out = ThrowOnHttpErrorRecovery.execute(ok, ctx()) assertSame(ok, out, "a success response must pass through untouched") } @@ -58,14 +58,14 @@ class ThrowOnHttpErrorStepTest { fun `3xx response passes through unchanged`() { // The factory rejects non-4xx/5xx; the step must not even call it for a 3xx. val redirect = response(Status.MOVED_PERMANENTLY) - assertSame(redirect, ThrowOnHttpErrorStep.execute(redirect, ctx())) + assertSame(redirect, ThrowOnHttpErrorRecovery.execute(redirect, ctx())) } @Test fun `404 maps to NotFoundException via the factory`() { val ex = assertFailsWith { - ThrowOnHttpErrorStep.execute(response(Status.NOT_FOUND), ctx()) + ThrowOnHttpErrorRecovery.execute(response(Status.NOT_FOUND), ctx()) } assertIs(ex, "404 must produce the factory's NotFoundException") assertEquals(Status.NOT_FOUND, ex.status) @@ -75,7 +75,7 @@ class ThrowOnHttpErrorStepTest { fun `503 maps to a retryable ServiceUnavailableException`() { val ex = assertFailsWith { - ThrowOnHttpErrorStep.execute(response(Status.SERVICE_UNAVAILABLE), ctx()) + ThrowOnHttpErrorRecovery.execute(response(Status.SERVICE_UNAVAILABLE), ctx()) } assertIs(ex) assertTrue(ex.isRetryable, "503 must surface as retryable") @@ -86,18 +86,18 @@ class ThrowOnHttpErrorStepTest { val headers = Headers.Builder().add("Retry-After", "7").build() val ex = assertFailsWith { - ThrowOnHttpErrorStep.execute(response(Status.TOO_MANY_REQUESTS, headers), ctx()) + ThrowOnHttpErrorRecovery.execute(response(Status.TOO_MANY_REQUESTS, headers), ctx()) } assertIs(ex) assertEquals("7", ex.headers.get("Retry-After")) assertEquals(Status.TOO_MANY_REQUESTS, ex.status) } - // ---- wired through the recovery-aware ResponsePipeline ------------------------------ + // ---- wired through the recovery-aware ResponseRecoveryChain ------------------------------ @Test fun `pipeline turns a 500 success-outcome into a Failure carrying the factory exception`() { - val pipeline = ResponsePipeline(responseSteps = listOf(ThrowOnHttpErrorStep)) + val pipeline = ResponseRecoveryChain(responseSteps = listOf(ThrowOnHttpErrorRecovery)) val outcome = ResponseOutcome.Success(response(Status.INTERNAL_SERVER_ERROR)) val result = pipeline.apply(outcome, ctx()) @@ -109,7 +109,7 @@ class ThrowOnHttpErrorStepTest { @Test fun `pipeline leaves a 2xx success-outcome as a Success`() { - val pipeline = ResponsePipeline(responseSteps = listOf(ThrowOnHttpErrorStep)) + val pipeline = ResponseRecoveryChain(responseSteps = listOf(ThrowOnHttpErrorRecovery)) val ok = response(Status.OK) val result = pipeline.apply(ResponseOutcome.Success(ok), ctx()) @@ -121,8 +121,8 @@ class ThrowOnHttpErrorStepTest { @Test fun `the mapped failure is Retryable so the retry classifier can key off it`() { // The error path emits an HttpException that is Retryable, which is exactly what - // RetryStep.isClassifiedRetryable matches on. - val pipeline = ResponsePipeline(responseSteps = listOf(ThrowOnHttpErrorStep)) + // RetryRecovery.isClassifiedRetryable matches on. + val pipeline = ResponseRecoveryChain(responseSteps = listOf(ThrowOnHttpErrorRecovery)) val result = pipeline.apply(ResponseOutcome.Success(response(Status.BAD_GATEWAY)), ctx()) val failure = assertIs(result) @@ -137,7 +137,7 @@ class ThrowOnHttpErrorStepTest { // even though the original transport body is closed by close-before-propagate. val payload = """{"error":"boom"}""" val original = closeTrackingBody(payload) - val pipeline = ResponsePipeline(responseSteps = listOf(ThrowOnHttpErrorStep)) + val pipeline = ResponseRecoveryChain(responseSteps = listOf(ThrowOnHttpErrorRecovery)) val result = pipeline.apply( diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryDefaultsReconciliationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryDefaultsReconciliationTest.kt index 8cc532b1..dae1dcb7 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryDefaultsReconciliationTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryDefaultsReconciliationTest.kt @@ -33,7 +33,7 @@ import java.time.Instant /** * Guards that the two retry stacks — the stage-based [DefaultRetryStep]/[HttpRetryOptions] and - * the recovery-aware [RetryStep]/[RetrySettings] — share ONE set of documented defaults and ONE + * the recovery-aware [RetryRecovery]/[RetrySettings] — share ONE set of documented defaults and ONE * backoff computation ([BackoffCalculator]). If a future change re-introduces divergent defaults * (max attempts, base/initial delay, max delay, multiplier, jitter) or a second backoff formula, * these assertions fail. @@ -199,8 +199,8 @@ class RetryDefaultsReconciliationTest { } } // No explicit scheduler: with zero delays the step never schedules a deferred wait, and - // leaving it null routes through RetryStep's process-wide lazy daemon scheduler rather - // than leaking a caller-owned executor (which RetryStep never shuts down) per run. + // leaving it null routes through RetryRecovery's process-wide lazy daemon scheduler rather + // than leaking a caller-owned executor (which RetryRecovery never shuts down) per run. val settings = RetrySettings.builder() .initialDelay(Duration.ZERO) @@ -209,7 +209,7 @@ class RetryDefaultsReconciliationTest { .jitter(0.0) .totalTimeout(Duration.ZERO) .build() - val step = RetryStep(client, settings, getRequest()) + val step = RetryRecovery(client, settings, getRequest()) val response = step.attempt() assertEquals(200, response.status.code, "recovery-aware step must retry a 408") } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecoveryTest.kt similarity index 84% rename from sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStepTest.kt rename to sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecoveryTest.kt index 99355da1..594e7b27 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStepTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecoveryTest.kt @@ -41,7 +41,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference /** - * Behavioural tests for [RetryStep]. Uses a hand-rolled fake [HttpClient] that records each + * Behavioural tests for [RetryRecovery]. Uses a hand-rolled fake [HttpClient] that records each * call and yields a queue of canned outcomes — letting us assert call counts, retry-or-not * semantics, and propagated exceptions. * @@ -49,7 +49,7 @@ import java.util.concurrent.atomic.AtomicReference * scheduled delay precisely; otherwise they use a zero-delay scheduler so retries fire * promptly without blocking the test thread on real time. */ -class RetryStepTest { +class RetryRecoveryTest { // region -- test fakes -- private sealed class Canned { @@ -93,7 +93,7 @@ class RetryStepTest { unit: TimeUnit, ): ScheduledFuture<*> { scheduledDelaysNanos.add(unit.toNanos(delay)) - // Run the runnable inline — this completes the CompletableFuture inside RetryStep + // Run the runnable inline — this completes the CompletableFuture inside RetryRecovery // immediately, so the retry fires without real wall-clock waiting. command.run() // Return an already-completed future stub. @@ -224,10 +224,10 @@ class RetryStepTest { ), ) val request = requestGet() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), request) val initialOutcome = ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE)) // Caller (the pipeline) passed the very first failure to invoke(). - // RetryStep then retries (maxAttempts-1) additional times. + // RetryRecovery then retries (maxAttempts-1) additional times. val out = step.invoke(initialOutcome) assertTrue(out is ResponseOutcome.Failure, "Retries should propagate the last failure") // maxAttempts = 3 → 2 retries (calls.size == 2 because the initial call was made by @@ -239,7 +239,7 @@ class RetryStepTest { fun `503 with non-replayable POST body is not retried`() { val client = FakeClient() val request = requestPostNonReplayable() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), request) val outcome = ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE)) val out = step.invoke(outcome) // Non-replayable POST: no retry attempted; transport never invoked. @@ -255,7 +255,7 @@ class RetryStepTest { // would otherwise trip the body's consume-once guard). val client = FakeClient() val request = requestPutNonReplayable() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), request) val outcome = ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE)) val out = step.invoke(outcome) assertSame(outcome, out, "Outcome must be unchanged when an idempotent method carries a non-replayable body") @@ -271,7 +271,7 @@ class RetryStepTest { // `body-less POST is NOT retried` case in the http.pipeline DefaultRetryStep suite. val client = FakeClient() val request = requestPostBodyless() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), request) val outcome = ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE)) val out = step.invoke(outcome) assertSame(outcome, out, "Outcome must be unchanged — a body-less POST is non-idempotent") @@ -287,7 +287,7 @@ class RetryStepTest { val ok = response(SC_OK) val client = FakeClient(listOf(Canned.Ok(ok))) val request = requestPutBodyless() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), request) val outcome = ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE)) val out = step.invoke(outcome) assertTrue(out is ResponseOutcome.Success, "body-less PUT must retry — PUT is idempotent") @@ -299,7 +299,7 @@ class RetryStepTest { fun `404 is not retried`() { val client = FakeClient() val request = requestGet() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), request) val outcome = ResponseOutcome.Failure(httpException(SC_NOT_FOUND)) val out = step.invoke(outcome) assertSame(outcome, out, "404 must pass through unchanged") @@ -317,7 +317,7 @@ class RetryStepTest { ), ) val request = requestGet() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), request) val outcome = ResponseOutcome.Failure(NetworkException("connection refused")) val out = step.invoke(outcome) assertTrue(out is ResponseOutcome.Success) @@ -329,7 +329,7 @@ class RetryStepTest { fun `success outcome is pass-through`() { val client = FakeClient() val ok = response(SC_OK) - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val outcome = ResponseOutcome.Success(ok) val out = step.invoke(outcome) assertSame(outcome, out) @@ -337,7 +337,69 @@ class RetryStepTest { } @Test - fun `reused RetryStep retries correctly on a second top-level call`() { + fun `transport returning 503 503 200 keeps retrying and returns the 200`() { + // The transport returns raw error-status RESPONSES (not thrown exceptions). classify() + // re-maps a 503 whose status is in retryableStatuses into a Failure so the retry loop keeps + // re-evaluating the budget — the sequence must reach the final 200 rather than stopping + // after a single re-send. + val ok = response(SC_OK) + val client = + FakeClient( + listOf( + Canned.Ok(response(SC_SERVICE_UNAVAILABLE)), + Canned.Ok(response(SC_SERVICE_UNAVAILABLE)), + Canned.Ok(ok), + ), + ) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val result = step.attempt() + assertSame(ok, result, "the loop must keep retrying through both 503s and return the final 200") + assertEquals(3, client.calls.size, "initial send + two retries") + } + + @Test + fun `exhausted 503 responses surface the mapped HttpException not a raw response`() { + // Every attempt returns a raw 503 response. With the budget exhausted the terminal outcome + // must be the typed HttpException classify() mapped the 503 into — never a raw 503 Response. + val client = + FakeClient( + listOf( + Canned.Ok(response(SC_SERVICE_UNAVAILABLE)), + Canned.Ok(response(SC_SERVICE_UNAVAILABLE)), + Canned.Ok(response(SC_SERVICE_UNAVAILABLE)), + ), + ) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val ex = assertThrows(HttpException::class.java) { step.attempt() } + assertEquals(SC_SERVICE_UNAVAILABLE, ex.status.code) + assertEquals(DEFAULT_MAX_ATTEMPTS, client.calls.size) + } + + @Test + fun `status configured in retryableStatuses but not classifier-retryable is retried`() { + // 425 Too Early is NOT in the built-in RetryUtils retryable set, but it IS configured here. + // For an HttpException the configured retryableStatuses is authoritative, so 425 must retry. + val ok = response(SC_OK) + val client = + FakeClient( + listOf( + Canned.Ok(response(SC_TOO_EARLY)), + Canned.Ok(ok), + ), + ) + val settings = + zeroDelaySettings(InstantScheduler()) + .newBuilder() + .retryableStatuses(setOf(SC_TOO_EARLY)) + .build() + val step = RetryRecovery(client, settings, requestGet()) + val result = step.attempt() + assertSame(ok, result, "a configured-but-not-classifier-retryable status must be retried") + assertEquals(2, client.calls.size, "initial 425 + one retry to the 200") + } + + @Test + fun `reused RetryRecovery retries correctly on a second top-level call`() { // C-3: the step holds no per-call state on the instance, so invoking it a second time // after the first call exhausted its attempt budget must start from a fresh budget and // retry again — not silently abort because a stale attempt count was carried over. @@ -351,7 +413,7 @@ class RetryStepTest { Canned.Ok(response(SC_OK)), ), ) - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val first = step.invoke(ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE))) assertTrue(first is ResponseOutcome.Success, "First call should recover via retry") @@ -363,7 +425,7 @@ class RetryStepTest { } @Test - fun `reused RetryStep exhausts the full attempt budget on each call`() { + fun `reused RetryRecovery exhausts the full attempt budget on each call`() { // Each top-level invoke() gets maxAttempts-1 retries; reuse must not shrink the budget. val client = FakeClient( @@ -376,7 +438,7 @@ class RetryStepTest { Canned.Err(httpException(SC_SERVICE_UNAVAILABLE)), ), ) - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val first = step.invoke(ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE))) assertTrue(first is ResponseOutcome.Failure) @@ -399,7 +461,7 @@ class RetryStepTest { val headers = Headers.builder().set("Retry-After", "2").build() val scheduler = InstantScheduler() val step = - RetryStep( + RetryRecovery( client, zeroDelaySettings(scheduler), requestGet(), @@ -420,7 +482,7 @@ class RetryStepTest { val ok = response(SC_OK) val client = FakeClient(listOf(Canned.Ok(ok))) val headers = Headers.builder().set("X-RateLimit-Reset", Long.MAX_VALUE.toString()).build() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val out = step.invoke(ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE, headers))) @@ -462,7 +524,7 @@ class RetryStepTest { .maxAttempts(DEFAULT_MAX_ATTEMPTS_TIMEOUT) .scheduler(InstantScheduler()) .build() - val step = RetryStep(client, settings, requestGet(), clock = testClock) + val step = RetryRecovery(client, settings, requestGet(), clock = testClock) val failure = httpException(SC_SERVICE_UNAVAILABLE) val outcome = ResponseOutcome.Failure(failure) val out = step.invoke(outcome) @@ -479,7 +541,7 @@ class RetryStepTest { fun `interrupt during wait yields InterruptedIOException and restores flag`() { val client = FakeClient() - // Custom scheduler that blocks forever — the RetryStep's get() call will be the one + // Custom scheduler that blocks forever — the RetryRecovery's get() call will be the one // the test interrupts. val blockingScheduler = object : ScheduledExecutorService by Executors.newSingleThreadScheduledExecutor() { @@ -488,7 +550,7 @@ class RetryStepTest { delay: Long, unit: TimeUnit, ): ScheduledFuture<*> { - // Never run the runnable — the future inside RetryStep stays pending. + // Never run the runnable — the future inside RetryRecovery stays pending. return BlockingScheduledFuture } } @@ -502,7 +564,7 @@ class RetryStepTest { .totalTimeout(Duration.ZERO) .scheduler(blockingScheduler) .build() - val step = RetryStep(client, settings, requestGet()) + val step = RetryRecovery(client, settings, requestGet()) // Run the retry on a separate thread so we can interrupt it. val started = CountDownLatch(1) @@ -531,7 +593,7 @@ class RetryStepTest { val result = resultRef.get() assertTrue( result is ResponseOutcome.Failure, - "RetryStep must surface a Failure when the wait is interrupted", + "RetryRecovery must surface a Failure when the wait is interrupted", ) val error = (result as ResponseOutcome.Failure).error assertTrue( @@ -568,7 +630,7 @@ class RetryStepTest { fun `attempt success returns the response`() { val ok = response(SC_OK) val client = FakeClient(listOf(Canned.Ok(ok))) - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val out = step.attempt() assertSame(ok, out) assertEquals(1, client.calls.size) @@ -584,7 +646,7 @@ class RetryStepTest { Canned.Ok(ok), ), ) - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val out = step.attempt() assertSame(ok, out) assertEquals(2, client.calls.size) @@ -600,7 +662,7 @@ class RetryStepTest { Canned.Err(httpException(SC_SERVICE_UNAVAILABLE)), ), ) - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val ex = assertThrows(HttpException::class.java) { step.attempt() } assertEquals(SC_SERVICE_UNAVAILABLE, ex.status.code) assertEquals(DEFAULT_MAX_ATTEMPTS, client.calls.size) @@ -613,11 +675,11 @@ class RetryStepTest { @Test fun `non-HttpException non-NetworkException error is pass-through`() { val client = FakeClient() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val unknown = IOException("unknown") val outcome = ResponseOutcome.Failure(unknown) val out = step.invoke(outcome) - // Plain IOException is not classified retryable by RetryStep (only NetworkException + // Plain IOException is not classified retryable by RetryRecovery (only NetworkException // and HttpException with retryable=true). Should pass through. assertSame(outcome, out) assertFalse(out is ResponseOutcome.Success) @@ -635,7 +697,7 @@ class RetryStepTest { body = null, ) {} val client = FakeClient() - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val outcome = ResponseOutcome.Failure(nonRetryable) val out = step.invoke(outcome) assertSame(outcome, out) @@ -656,7 +718,7 @@ class RetryStepTest { Canned.Ok(response(SC_OK)), ), ) - val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), requestGet()) + val step = RetryRecovery(client, zeroDelaySettings(InstantScheduler()), requestGet()) val out = step.invoke(ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE))) assertTrue(out is ResponseOutcome.Success) // Two retried sends were made; neither carries the attempt header. @@ -683,7 +745,7 @@ class RetryStepTest { .newBuilder() .attemptHeaderName(DEFAULT_ATTEMPT_HEADER) .build() - val step = RetryStep(client, settings, requestGet()) + val step = RetryRecovery(client, settings, requestGet()) val response = step.attempt() assertEquals(SC_OK, response.status.code) @@ -708,7 +770,7 @@ class RetryStepTest { .newBuilder() .attemptHeaderName(custom) .build() - val step = RetryStep(client, settings, requestGet()) + val step = RetryRecovery(client, settings, requestGet()) val out = step.invoke(ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE))) assertTrue(out is ResponseOutcome.Success) @@ -734,7 +796,7 @@ class RetryStepTest { .newBuilder() .attemptHeaderName(DEFAULT_ATTEMPT_HEADER) .build() - val step = RetryStep(client, settings, request) + val step = RetryRecovery(client, settings, request) step.invoke(ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE))) // The retried request copy carries the header; the immutable template never gains it. @@ -747,6 +809,7 @@ class RetryStepTest { private companion object { // HTTP status code constants for tests. private const val SC_OK = 200 + private const val SC_TOO_EARLY = 425 private const val SC_NOT_FOUND = 404 private const val SC_SERVICE_UNAVAILABLE = 503 diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/DeserializerTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/DeserializerTest.kt index 2f712119..69e0cca0 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/DeserializerTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/DeserializerTest.kt @@ -11,6 +11,7 @@ import java.io.ByteArrayInputStream import java.io.InputStream import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertSame class DeserializerTest { @@ -80,4 +81,34 @@ class DeserializerTest { val out = d.deserialize("dave", Dto::class.java) assertEquals(Dto("dave"), out) } + + @Test + fun `default TypeRef string overload delegates to the Class overload for a non-generic ref`() { + val d = RecordingDeserializer() + // A TypeRef whose captured type is a plain Class (no type args): the interface default + // delegates to the existing Class overload, so a non-Jackson deserializer still works. + val out: Dto = d.deserialize("erin", object : TypeRef() {}) + assertEquals(Dto("erin"), out) + assertSame(Dto::class.java, d.lastType) + } + + @Test + fun `default TypeRef stream overload delegates to the Class overload for a non-generic ref`() { + val d = RecordingDeserializer() + val out: Dto = d.deserialize(ByteArrayInputStream("frank".toByteArray()), object : TypeRef() {}) + assertEquals(Dto("frank"), out) + assertSame(Dto::class.java, d.lastType) + } + + @Test + fun `default TypeRef overload throws SerdeException for a generic ref a plain Deserializer cannot resolve`() { + val d = RecordingDeserializer() + // A parametric ref (List) has no raw-Class fast path, and a format-agnostic Deserializer + // cannot resolve the element type — the default must fail loudly, not silently return a map. + val ex = + assertFailsWith { + d.deserialize("[]", object : TypeRef>() {}) + } + assertEquals(true, ex.message?.contains("does not resolve generic types") == true) + } } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/TypeRefTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/TypeRefTest.kt new file mode 100644 index 00000000..27d8b801 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/TypeRefTest.kt @@ -0,0 +1,100 @@ +/* + * 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.serde + +import java.lang.reflect.ParameterizedType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TypeRefTest { + data class User(val id: Int) + + // Invariant container: its reflected type argument is the element type itself, unlike the + // read-only List, which Kotlin reflects as java.util.List (a wildcard). + class Box( + @Suppress("unused") val value: T, + ) + + // A generic subclass of TypeRef. Its genericSuperclass is `TypeRef` whose actual type + // argument is the *unresolved* type variable T — erased to Any at construction. Used to prove + // the constructor rejects a type it cannot faithfully capture. + private class Foo : TypeRef() + + // Anonymous TypeRef built inside a generic function: T is the function's type variable, which + // is likewise erased, so construction must fail. + private fun makeErasedRef(): TypeRef = object : TypeRef() {} + + @Test + fun `non-generic ref captures the plain Class as both type and rawClass`() { + val ref = object : TypeRef() {} + assertSame(User::class.java, ref.type) + assertSame(User::class.java, ref.rawClass) + } + + @Test + fun `parametric ref captures a ParameterizedType with the erased raw class`() { + val ref = object : TypeRef>() {} + val type = ref.type + assertTrue(type is ParameterizedType, "List must be captured as a ParameterizedType") + assertSame(List::class.java, type.rawType) + // rawClass erases the type arguments down to the container class. + assertSame(List::class.java, ref.rawClass) + } + + @Test + fun `invariant parametric ref captures the exact element type`() { + val ref = object : TypeRef>() {} + val type = ref.type + assertTrue(type is ParameterizedType, "Box must be captured as a ParameterizedType") + assertSame(Box::class.java, type.rawType) + assertSame(User::class.java, type.actualTypeArguments.first()) + assertSame(Box::class.java, ref.rawClass) + } + + @Test + fun `nested parametric ref preserves the full type while erasing rawClass to the container`() { + val ref = object : TypeRef>>() {} + val type = ref.type + assertTrue(type is ParameterizedType, "Map> must be a ParameterizedType") + assertSame(Map::class.java, type.rawType) + assertSame(Map::class.java, ref.rawClass) + assertEquals(2, type.actualTypeArguments.size) + } + + @Test + fun `anonymous TypeRef built in a generic function rejects the unresolved type variable`() { + // T is the function's type variable — erased to its bound at construction, so capturing it + // would silently decode into the wrong type. The constructor must reject it up front. + assertFailsWith { makeErasedRef() } + } + + @Test + fun `generic subclass of TypeRef rejects its unresolved type variable`() { + // `class Foo : TypeRef()` used as Foo() records `TypeRef` (a type variable), + // not `TypeRef`, as its genericSuperclass — the User argument is erased. Rejected. + assertFailsWith { Foo() } + } + + @Test + fun `concrete parametric ref built at the call site still succeeds`() { + // Control for the two rejection cases above: a concrete argument supplied at the call site + // is faithfully captured, so construction succeeds and the full generic type survives. + val ref = object : TypeRef>() {} + val type = ref.type + assertTrue(type is ParameterizedType) + assertSame(List::class.java, type.rawType) + assertSame(List::class.java, ref.rawClass) + // Kotlin's `List` variance means the captured argument is `? extends User`, not the + // bare `User` — the point here is only that a concrete parametric type is faithfully + // captured (contrast the rejected type-variable cases above). + assertEquals(1, type.actualTypeArguments.size) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/FuturesTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/FuturesTest.kt index 406fc8dc..40caec93 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/FuturesTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/FuturesTest.kt @@ -11,13 +11,16 @@ import java.io.IOException import java.time.Duration import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException +import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails +import kotlin.test.assertFalse import kotlin.test.assertSame import kotlin.test.assertTrue @@ -104,6 +107,54 @@ class FuturesTest { assertFails { Futures.delay(scheduler, Duration.ofMillis(-1)) } } + @Test + fun `interruptibleFuture closes a Closeable value that loses the completion race`() { + // Deterministic lost-completion path: cancel(false) marks the future cancelled without + // interrupting the worker, so the task runs to completion and `result.complete(value)` + // returns false. The computed Closeable (a stand-in for a Response holding an open body / + // pooled connection) would leak unless the discard path closes it. + val started = CountDownLatch(1) + val proceed = CountDownLatch(1) + val closed = CountDownLatch(1) + val closeable = AutoCloseable { closed.countDown() } + + val worker = Executors.newSingleThreadExecutor() + try { + val future = + interruptibleFuture(worker) { + started.countDown() + // Park until the test has cancelled the future, so completion loses the race. + proceed.await() + closeable + } + assertTrue(started.await(2, TimeUnit.SECONDS), "task should have started") + future.cancel(false) + proceed.countDown() + + assertTrue(closed.await(2, TimeUnit.SECONDS), "the discarded Closeable value must be closed") + assertTrue(future.isCancelled) + } finally { + worker.shutdownNow() + } + } + + @Test + fun `interruptibleFuture delivers a Closeable value without closing it on the normal path`() { + // The success path must NOT close the value — the caller owns it. Guards against an + // over-eager close that would break every non-cancelled call returning a Closeable. + val closed = AtomicBoolean(false) + val closeable = AutoCloseable { closed.set(true) } + + val worker = Executors.newSingleThreadExecutor() + try { + val delivered = interruptibleFuture(worker) { closeable }.get(2, TimeUnit.SECONDS) + assertSame(closeable, delivered, "the value must be delivered unchanged") + assertFalse(closed.get(), "a delivered value must not be closed") + } finally { + worker.shutdownNow() + } + } + @Test fun `cancelling the delay future cancels the scheduled task`() { val future = Futures.delay(scheduler, Duration.ofSeconds(5)) diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ProxyOptionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ProxyOptionsTest.kt index c4731a21..c149276f 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ProxyOptionsTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ProxyOptionsTest.kt @@ -10,6 +10,7 @@ package org.dexpace.sdk.core.util import org.dexpace.sdk.core.auth.AuthenticateChallenge import org.dexpace.sdk.core.auth.AuthorizationHeader import org.dexpace.sdk.core.auth.ChallengeHandler +import org.dexpace.sdk.core.config.Configuration import org.dexpace.sdk.core.config.ConfigurationBuilder import org.dexpace.sdk.core.http.request.Method import java.net.InetSocketAddress @@ -972,6 +973,71 @@ class ProxyOptionsTest { assertFalse(pattern.matcher("api.other.com").matches()) } + // ----- fromEnvironment: convenience over the global configuration ----- + + @Test + fun `fromEnvironment resolves the proxy from the global configuration`() { + val previous = Configuration.getGlobalConfiguration() + try { + Configuration.setGlobalConfiguration( + Configuration.builder() + .envSource { null } + .propsSource { name -> + when (name) { + "https.proxyHost" -> "global.proxy" + "https.proxyPort" -> "8443" + else -> null + } + } + .build(), + ) + val po = ProxyOptions.fromEnvironment() + assertNotNull(po) + assertEquals("global.proxy", po.address.hostString) + assertEquals(8443, po.address.port) + } finally { + Configuration.setGlobalConfiguration(previous) + } + } + + @Test + fun `fromEnvironment returns null when the global configuration has no proxy`() { + val previous = Configuration.getGlobalConfiguration() + try { + Configuration.setGlobalConfiguration( + Configuration.builder() + .envSource { null } + .propsSource { null } + .build(), + ) + assertNull(ProxyOptions.fromEnvironment()) + } finally { + Configuration.setGlobalConfiguration(previous) + } + } + + @Test + fun `fromEnvironment NO_PROXY=star bypasses everything and returns null`() { + val previous = Configuration.getGlobalConfiguration() + try { + Configuration.setGlobalConfiguration( + Configuration.builder() + .envSource { name -> + when (name) { + "HTTPS_PROXY" -> "http://proxy:8080" + "NO_PROXY" -> "*" + else -> null + } + } + .propsSource { null } + .build(), + ) + assertNull(ProxyOptions.fromEnvironment()) + } finally { + Configuration.setGlobalConfiguration(previous) + } + } + // ----- Stub ChallengeHandler for tests ----- private class StubChallengeHandler : ChallengeHandler { diff --git a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt index d45907c3..2655f63d 100644 --- a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt +++ b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt @@ -24,13 +24,14 @@ import org.dexpace.sdk.core.http.pipeline.steps.DefaultRetryStep import org.dexpace.sdk.core.http.pipeline.steps.HttpInstrumentationOptions import org.dexpace.sdk.core.http.pipeline.steps.HttpLogLevel import org.dexpace.sdk.core.http.pipeline.steps.KeyCredentialAuthStep -import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.request.RequestBody +import org.dexpace.sdk.core.http.response.parsedWith +import org.dexpace.sdk.core.http.response.throwOnError import org.dexpace.sdk.core.io.Io -import org.dexpace.sdk.core.serde.deserialize import org.dexpace.sdk.io.OkioIoProvider import org.dexpace.sdk.serde.jackson.JacksonSerde +import org.dexpace.sdk.serde.jackson.jsonHandler import org.dexpace.sdk.transport.okhttp.OkHttpTransport import java.net.URL @@ -175,23 +176,19 @@ public fun createUser( serde: JacksonSerde, endpoint: URL, request: CreateUserRequest, -): User { - val json = serde.serializer.serialize(request) - val httpRequest = - Request.builder() - .method(Method.POST) - .url(endpoint) - .addHeader(HttpHeaderName.ACCEPT.toString(), CommonMediaTypes.APPLICATION_JSON.toString()) - .body(RequestBody.create(json, CommonMediaTypes.APPLICATION_JSON)) - .build() - - pipeline.send(httpRequest).use { response -> - val status = response.status - val payload = response.body?.source()?.readUtf8().orEmpty() - check(status.isSuccess) { "Unexpected status $status — body: $payload" } - return serde.deserializer.deserialize(payload) - } -} +): User = + pipeline + .send( + Request.builder() + .url(endpoint) + .post(RequestBody.create(request, serde)) + .addHeader(HttpHeaderName.ACCEPT, CommonMediaTypes.APPLICATION_JSON) + .build(), + ) + .use { response -> + response.throwOnError() + response.parsedWith(jsonHandler(serde, User::class.java)).value() + } /** * Runs the full sample against an embedded HTTPS [MockWebServer] and prints the typed round-trip. diff --git a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/SimpleGetApp.kt b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/SimpleGetApp.kt new file mode 100644 index 00000000..b32962e4 --- /dev/null +++ b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/SimpleGetApp.kt @@ -0,0 +1,49 @@ +/* + * 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.example + +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.dexpace.sdk.core.http.pipeline.HttpPipeline +import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.transport.okhttp.OkHttpTransport + +/* + * Minimal end-to-end sample: a plain-HTTP GET through the simplest possible pipeline. + * + * No TLS, no auth, no serde — just HttpPipeline.of(transport) and Request.get(url). + * A MockWebServer provides a deterministic local endpoint so the sample runs with no network. + * sdk-io-okio3 on the classpath registers itself automatically; no explicit installProvider() call + * is needed. + * + * Run via `./gradlew :sdk-example:run` (will run ExampleApp.main instead; call runSimpleGet() + * directly to exercise this path) or via the smoke test SimpleGetAppTest. + */ + +/** + * Fires a single GET request against an embedded [MockWebServer] and prints the response body. + * Extracted from [main] so the smoke test can call it directly. + */ +public fun runSimpleGet() { + MockWebServer().use { server -> + server.enqueue(MockResponse.Builder().body("Hello, dexpace!").build()) + server.start() + + OkHttpTransport.builder().build().use { transport -> + val pipeline = HttpPipeline.of(transport) + pipeline.send(Request.get(server.url("/hello").toString())).use { response -> + println(response.body?.string()) + } + } + } +} + +/** Entry point for running this minimal sample standalone. */ +public fun main() { + runSimpleGet() +} diff --git a/sdk-example/src/test/kotlin/org/dexpace/sdk/example/SimpleGetAppTest.kt b/sdk-example/src/test/kotlin/org/dexpace/sdk/example/SimpleGetAppTest.kt new file mode 100644 index 00000000..29b3b324 --- /dev/null +++ b/sdk-example/src/test/kotlin/org/dexpace/sdk/example/SimpleGetAppTest.kt @@ -0,0 +1,22 @@ +/* + * 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.example + +import kotlin.test.Test + +/** + * Smoke test for [runSimpleGet]: asserts the minimal GET sample runs deterministically against an + * embedded server without throwing. + */ +class SimpleGetAppTest { + @Test + fun minimalGetRunsWithoutThrowing() { + installIoProvider() + runSimpleGet() + } +} diff --git a/sdk-io-okio3/api/sdk-io-okio3.api b/sdk-io-okio3/api/sdk-io-okio3.api index 11a5f0b8..673df5e7 100644 --- a/sdk-io-okio3/api/sdk-io-okio3.api +++ b/sdk-io-okio3/api/sdk-io-okio3.api @@ -3,6 +3,7 @@ public final class org/dexpace/sdk/io/OkioIoProvider : org/dexpace/sdk/core/io/I public fun buffer ()Lorg/dexpace/sdk/core/io/Buffer; public fun bufferedSink (Lorg/dexpace/sdk/core/io/Sink;)Lorg/dexpace/sdk/core/io/BufferedSink; public fun bufferedSource (Lorg/dexpace/sdk/core/io/Source;)Lorg/dexpace/sdk/core/io/BufferedSource; + public fun getUnderlying ()Lorg/dexpace/sdk/core/io/IoProvider; public fun sink (Ljava/io/OutputStream;)Lorg/dexpace/sdk/core/io/BufferedSink; public fun source (Ljava/io/InputStream;)Lorg/dexpace/sdk/core/io/BufferedSource; public fun source ([B)Lorg/dexpace/sdk/core/io/BufferedSource; diff --git a/sdk-io-okio3/src/main/kotlin/org/dexpace/sdk/io/OkioIoProviderLoader.kt b/sdk-io-okio3/src/main/kotlin/org/dexpace/sdk/io/OkioIoProviderLoader.kt new file mode 100644 index 00000000..7907bd08 --- /dev/null +++ b/sdk-io-okio3/src/main/kotlin/org/dexpace/sdk/io/OkioIoProviderLoader.kt @@ -0,0 +1,25 @@ +/* + * 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.io + +import org.dexpace.sdk.core.io.IoProvider + +/** + * ServiceLoader-instantiable shim that delegates every [IoProvider] call to [OkioIoProvider]. + * + * [OkioIoProvider] is a Kotlin `object` with a private constructor, so [java.util.ServiceLoader] + * cannot instantiate it directly (ServiceLoader requires a public no-arg constructor). This class + * provides that constructor while keeping [OkioIoProvider] as the real stateless implementation. + * + * Registered in `META-INF/services/org.dexpace.sdk.core.io.IoProvider`. + * + * Application code should not use this class directly; either call + * `Io.installProvider(OkioIoProvider)` explicitly at startup, or rely on the automatic + * ServiceLoader resolution built into [org.dexpace.sdk.core.io.Io]. + */ +internal class OkioIoProviderLoader : IoProvider by OkioIoProvider diff --git a/sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro b/sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro index ce06b60a..8a79812e 100644 --- a/sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro +++ b/sdk-io-okio3/src/main/resources/META-INF/proguard/sdk-io-okio3.pro @@ -9,3 +9,9 @@ # via Io.installProvider(OkioIoProvider). A shrinker following the application from its own # entry points cannot always see that wiring, so keep the provider and its INSTANCE field. -keep class org.dexpace.sdk.io.OkioIoProvider { *; } + +# The zero-config path resolves the provider via ServiceLoader, which reflectively instantiates +# OkioIoProviderLoader (registered in META-INF/services). R8 cannot see that reflective +# construction and would tree-shake the shim, leaving an auto-discovering consumer with no +# provider at runtime. Keep the shim and its no-arg constructor. +-keep class org.dexpace.sdk.io.OkioIoProviderLoader { (); } diff --git a/sdk-io-okio3/src/main/resources/META-INF/services/org.dexpace.sdk.core.io.IoProvider b/sdk-io-okio3/src/main/resources/META-INF/services/org.dexpace.sdk.core.io.IoProvider new file mode 100644 index 00000000..87000e43 --- /dev/null +++ b/sdk-io-okio3/src/main/resources/META-INF/services/org.dexpace.sdk.core.io.IoProvider @@ -0,0 +1 @@ +org.dexpace.sdk.io.OkioIoProviderLoader diff --git a/sdk-serde-jackson/api/sdk-serde-jackson.api b/sdk-serde-jackson/api/sdk-serde-jackson.api index f14795d4..4180831a 100644 --- a/sdk-serde-jackson/api/sdk-serde-jackson.api +++ b/sdk-serde-jackson/api/sdk-serde-jackson.api @@ -6,10 +6,12 @@ public final class org/dexpace/sdk/serde/jackson/JacksonObjectMappers { public final class org/dexpace/sdk/serde/jackson/JacksonSerde : org/dexpace/sdk/core/serde/Serde { public static final field Companion Lorg/dexpace/sdk/serde/jackson/JacksonSerde$Companion; public synthetic fun (Lcom/fasterxml/jackson/databind/ObjectMapper;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun contentType ()Lorg/dexpace/sdk/core/http/common/MediaType; public final fun deserializeAs (Ljava/io/InputStream;Lcom/fasterxml/jackson/core/type/TypeReference;)Ljava/lang/Object; public final fun deserializeAs (Ljava/lang/String;Lcom/fasterxml/jackson/core/type/TypeReference;)Ljava/lang/Object; public final fun deserializeAs ([BLcom/fasterxml/jackson/core/type/TypeReference;)Ljava/lang/Object; public static final fun from (Lcom/fasterxml/jackson/databind/ObjectMapper;)Lorg/dexpace/sdk/serde/jackson/JacksonSerde; + public static final fun from (Lcom/fasterxml/jackson/databind/ObjectMapper;Z)Lorg/dexpace/sdk/serde/jackson/JacksonSerde; public fun getDeserializer ()Lorg/dexpace/sdk/core/serde/Deserializer; public final fun getMapper ()Lcom/fasterxml/jackson/databind/ObjectMapper; public fun getSerializer ()Lorg/dexpace/sdk/core/serde/Serializer; @@ -18,12 +20,16 @@ public final class org/dexpace/sdk/serde/jackson/JacksonSerde : org/dexpace/sdk/ public final class org/dexpace/sdk/serde/jackson/JacksonSerde$Companion { public final fun from (Lcom/fasterxml/jackson/databind/ObjectMapper;)Lorg/dexpace/sdk/serde/jackson/JacksonSerde; + public final fun from (Lcom/fasterxml/jackson/databind/ObjectMapper;Z)Lorg/dexpace/sdk/serde/jackson/JacksonSerde; + public static synthetic fun from$default (Lorg/dexpace/sdk/serde/jackson/JacksonSerde$Companion;Lcom/fasterxml/jackson/databind/ObjectMapper;ZILjava/lang/Object;)Lorg/dexpace/sdk/serde/jackson/JacksonSerde; public final fun withDefaults ()Lorg/dexpace/sdk/serde/jackson/JacksonSerde; } -public final class org/dexpace/sdk/serde/jackson/JsonResponseHandlerKt { +public final class org/dexpace/sdk/serde/jackson/JsonResponseHandlers { public static final fun jsonHandler (Lorg/dexpace/sdk/core/serde/Serde;Ljava/lang/Class;)Lorg/dexpace/sdk/core/http/response/ResponseHandler; public static final fun jsonHandler (Lorg/dexpace/sdk/serde/jackson/JacksonSerde;Lcom/fasterxml/jackson/core/type/TypeReference;)Lorg/dexpace/sdk/core/http/response/ResponseHandler; + public static final fun jsonHandlerOrThrow (Lorg/dexpace/sdk/core/serde/Serde;Ljava/lang/Class;)Lorg/dexpace/sdk/core/http/response/ResponseHandler; + public static final fun jsonHandlerOrThrow (Lorg/dexpace/sdk/serde/jackson/JacksonSerde;Lcom/fasterxml/jackson/core/type/TypeReference;)Lorg/dexpace/sdk/core/http/response/ResponseHandler; } public final class org/dexpace/sdk/serde/jackson/TristateModule : com/fasterxml/jackson/databind/module/SimpleModule { diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt index e19aa117..1a4a1062 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerde.kt @@ -12,11 +12,13 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper +import org.dexpace.sdk.core.http.common.MediaType import org.dexpace.sdk.core.serde.DeserializationException import org.dexpace.sdk.core.serde.Deserializer import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.SerializationException import org.dexpace.sdk.core.serde.Serializer +import org.dexpace.sdk.core.serde.TypeRef import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -52,6 +54,9 @@ public class JacksonSerde private constructor( override val serializer: Serializer = JacksonSerializer(mapper) override val deserializer: Deserializer = JacksonDeserializer(mapper) + /** JSON is this serde's wire format, so bodies built from it default to `application/json`. */ + override fun contentType(): MediaType = MediaType.APPLICATION_JSON + /** * Deserialize [input] into a value of the type captured by [type]. This overload is the * "correct" entry point for parametric or otherwise erased generic targets. @@ -61,7 +66,7 @@ public class JacksonSerde private constructor( public fun deserializeAs( input: String, type: TypeReference, - ): T = deserializing { mapper.readValue(input, type) } + ): T = nonNull(deserializing { mapper.readValue(input, type) }, type.type.typeName) /** * Deserialize from a [ByteArray]. See [deserializeAs] for type-reference rationale. @@ -71,7 +76,7 @@ public class JacksonSerde private constructor( public fun deserializeAs( input: ByteArray, type: TypeReference, - ): T = deserializing { mapper.readValue(input, type) } + ): T = nonNull(deserializing { mapper.readValue(input, type) }, type.type.typeName) /** * Deserialize by streaming from [inputStream]. The implementation reads to EOF but does @@ -82,7 +87,15 @@ public class JacksonSerde private constructor( public fun deserializeAs( inputStream: InputStream, type: TypeReference, - ): T = deserializing { mapper.readValue(inputStream, type) } + ): T { + // The caller owns closing the stream. Drive a per-call parser with AUTO_CLOSE_SOURCE off so + // a caller-supplied mapper (see JacksonSerde.from, whose copy inherits AUTO_CLOSE_SOURCE=true) + // is not mutated and the stream is read to EOF but left open — matching this method's KDoc. + val parser = mapper.factory.createParser(inputStream).disable(JsonParser.Feature.AUTO_CLOSE_SOURCE) + return parser.use { p -> + nonNull(deserializing { mapper.readValue(p, type) }, type.type.typeName) + } + } public companion object { /** Build a [JacksonSerde] backed by an [ObjectMapper] with the SDK-correct defaults. */ @@ -92,14 +105,45 @@ public class JacksonSerde private constructor( /** * Build a [JacksonSerde] backed by a caller-supplied [mapper]. * + * This method operates on a **defensive copy** of [mapper] — the caller's original instance + * is never mutated. Because the copy is taken via [ObjectMapper.copy], the supplied mapper + * must be a standard `ObjectMapper` / `JsonMapper` (or a subclass that overrides `copy()`): + * Jackson's own `copy()` throws `IllegalStateException` for a subclass that does not override + * it. Modules registered on [mapper] *after* this call are not reflected in the copy — register + * them before calling [from], or register them on the returned serde's [mapper]. + * + * ## Tristate PATCH semantics + * + * [org.dexpace.sdk.core.serde.Tristate] PATCH fields **require** [TristateModule] to be + * registered on the underlying mapper. Without it, [org.dexpace.sdk.core.serde.Tristate.Absent] + * and [org.dexpace.sdk.core.serde.Tristate.Null] are indistinguishable on the wire — both + * serialize to a non-absent representation, silently corrupting `PATCH` payloads. By default, + * [from] registers [TristateModule] on the copy automatically. + * + * Pass `registerTristate = false` only when the caller's mapper already carries a + * manually-registered [TristateModule] (or a compatible substitute) and a second + * registration is undesirable. + * * The caller-owned-stream contract on [Serializer.serialize] / [Deserializer.deserialize] * holds regardless of the mapper's `AUTO_CLOSE_TARGET`/`AUTO_CLOSE_SOURCE` settings: the * stream overloads drive a per-call generator/parser with auto-close disabled, so the - * caller's [OutputStream]/[InputStream] is left open and the supplied [mapper] is never - * mutated. + * caller's [OutputStream]/[InputStream] is left open. + * + * @param mapper The mapper to copy and use as the serialization engine. + * @param registerTristate When `true` (the default), [TristateModule] is registered on + * the copy. Pass `false` when the caller has already registered the module or a + * compatible substitute. */ @JvmStatic - public fun from(mapper: ObjectMapper): JacksonSerde = JacksonSerde(mapper) + @JvmOverloads + public fun from( + mapper: ObjectMapper, + registerTristate: Boolean = true, + ): JacksonSerde { + val copy = mapper.copy() + if (registerTristate) copy.registerModule(TristateModule()) + return JacksonSerde(copy) + } } } @@ -180,13 +224,13 @@ internal class JacksonDeserializer internal constructor( override fun deserialize( input: String, type: Class, - ): T = deserializing { mapper.readValue(input, type) } + ): T = nonNull(deserializing { mapper.readValue(input, type) }, type.typeName) /** @throws DeserializationException if [input] is malformed or does not match [type]. */ override fun deserialize( input: ByteArray, type: Class, - ): T = deserializing { mapper.readValue(input, type) } + ): T = nonNull(deserializing { mapper.readValue(input, type) }, type.typeName) /** @throws DeserializationException if the payload is malformed or does not match [type]. */ override fun deserialize( @@ -197,7 +241,45 @@ internal class JacksonDeserializer internal constructor( // a caller-supplied mapper (see JacksonSerde.from) is not mutated and the stream stays open. val parser = mapper.factory.createParser(inputStream).disable(JsonParser.Feature.AUTO_CLOSE_SOURCE) return parser.use { p -> - deserializing { mapper.readValue(p, type) } + nonNull(deserializing { mapper.readValue(p, type) }, type.typeName) + } + } + + /** + * Resolves the parametric target via `mapper.constructType(ref.type)`, so a `List` decodes + * with its element type intact — no Jackson type needed at the call site. + * + * @throws DeserializationException if [input] is malformed or does not match [ref]. + */ + override fun deserialize( + input: String, + ref: TypeRef, + ): T = nonNull(deserializing { mapper.readValue(input, mapper.constructType(ref.type)) }, ref.type.typeName) + + /** + * Byte-array counterpart to the string [TypeRef] overload; resolves the parametric target via + * `mapper.constructType(ref.type)` so a `List` decodes with its element type intact. + * + * @throws DeserializationException if [input] is malformed or does not match [ref]. + */ + override fun deserialize( + input: ByteArray, + ref: TypeRef, + ): T = nonNull(deserializing { mapper.readValue(input, mapper.constructType(ref.type)) }, ref.type.typeName) + + /** + * Streaming counterpart to the string [TypeRef] overload. Reads to EOF but does **not** close the + * stream — the caller retains ownership. + * + * @throws DeserializationException if the payload is malformed or does not match [ref]. + */ + override fun deserialize( + inputStream: InputStream, + ref: TypeRef, + ): T { + val parser = mapper.factory.createParser(inputStream).disable(JsonParser.Feature.AUTO_CLOSE_SOURCE) + return parser.use { p -> + nonNull(deserializing { mapper.readValue(p, mapper.constructType(ref.type)) }, ref.type.typeName) } } } @@ -234,3 +316,19 @@ private inline fun deserializing(block: () -> T): T = } catch (e: JsonProcessingException) { throw DeserializationException(e.originalMessage ?: e.message, e) } + +/** + * Guards the non-null return contract of the `deserialize`/`deserializeAs` overloads. Jackson's + * `readValue` yields a Java `null` when the document is the JSON literal `null`; left unchecked that + * `null` flows through the non-null generic `T` return and detonates as a `NullPointerException` far + * from the decode site. Convert it here into a [DeserializationException] naming [typeName], so the + * failure is reported where it happens and callers catch the SDK's stable serde type. + */ +private fun nonNull( + value: T?, + typeName: String, +): T = + value + ?: throw DeserializationException( + "Decoded a null value for $typeName; the input was the JSON literal null.", + ) diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandler.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandler.kt index 4cf922a8..0850b95c 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandler.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandler.kt @@ -5,13 +5,19 @@ * SPDX-License-Identifier: MIT */ +@file:JvmName("JsonResponseHandlers") + package org.dexpace.sdk.serde.jackson import com.fasterxml.jackson.core.type.TypeReference +import org.dexpace.sdk.core.http.common.HttpHeaderName import org.dexpace.sdk.core.http.response.Response import org.dexpace.sdk.core.http.response.ResponseHandler +import org.dexpace.sdk.core.http.response.exception.HttpException +import org.dexpace.sdk.core.http.response.throwOnError import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.SerdeException +import java.io.IOException /** * A [ResponseHandler] that streams the response body through [serde]'s deserializer into a value @@ -22,9 +28,10 @@ import org.dexpace.sdk.core.serde.SerdeException * raw header / status inspection must happen before the handler runs — pair it with a * `ParsedResponse` to keep raw access available and to parse lazily exactly once. * - * A missing body (e.g. a `204 No Content`) or any failure from the deserializer is surfaced as a - * [SerdeException], with the original codec exception preserved as the cause; the response is - * still closed on the failure path. + * A missing body (e.g. a `204 No Content`) or a codec/parse failure is surfaced as a + * [SerdeException], with the original codec exception preserved as the cause; a genuine mid-stream + * [IOException] (e.g. a connection reset) propagates unwrapped so a caller's `catch (IOException)` + * still fires. The response is closed on the failure path either way. * * This overload takes a raw [Class] token, which is sufficient for non-parametric targets. For * parametric targets (`List`, `Map`) the raw class erases the element type — use @@ -48,7 +55,8 @@ public fun jsonHandler( * * Use this overload for generic targets (`List`, `Map`) where a raw [Class] * token would lose the element type. Behaves like the [Class] overload otherwise: it consumes and - * closes the body and surfaces deserialization failures (and a missing body) as a [SerdeException]. + * closes the body, surfaces codec failures (and a missing body) as a [SerdeException], and lets a + * genuine mid-stream [IOException] propagate unwrapped. * * @param serde The Jackson serde whose mapper decodes the body. * @param type The parametric target type. @@ -62,10 +70,89 @@ public fun jsonHandler( decode(response, type.type.typeName) { stream -> serde.deserializeAs(stream, type) } } +/** + * Status-aware variant of [jsonHandler]: on a 2xx response it decodes the body into [type]; on any + * non-2xx response it throws instead of attempting to deserialize an error payload as [type]. + * + * A 4xx / 5xx response is mapped through [Response.throwOnError] to the matching + * [org.dexpace.sdk.core.http.response.exception.HttpException] subclass, whose error + * [org.dexpace.sdk.core.http.response.ResponseBody] is a **buffered** in-memory copy (bounded to + * 1 MiB) — so it stays readable from the caught exception (e.g. via `bodySnapshot()` or typed + * error-body deserialization) even after this handler runs inside a `response.use { … }` block that + * closes the live response. Any other non-success status (1xx / 3xx reaching a terminal handler) + * closes the response and fails with a [SerdeException]. On the success path the body is consumed + * and the response closed exactly as the plain [jsonHandler]. + * + * @param serde The serde whose deserializer decodes the body. + * @param type The non-parametric success target type. + * @return A [ResponseHandler] that yields a value of [type] on 2xx, and throws on non-2xx. + */ +public fun jsonHandlerOrThrow( + serde: Serde, + type: Class, +): ResponseHandler = + ResponseHandler { response -> + throwIfNotSuccess(response) + decode(response, type.typeName) { stream -> serde.deserializer.deserialize(stream, type) } + } + +/** + * Status-aware variant of the parametric [jsonHandler]: decodes the body into the type captured by + * [type] on 2xx, and throws on any non-2xx response. See the [Class] overload of [jsonHandlerOrThrow] + * for the non-success mapping contract. + * + * @param serde The Jackson serde whose mapper decodes the body. + * @param type The parametric success target type. + * @return A [ResponseHandler] that yields a value of the captured type on 2xx, and throws on non-2xx. + */ +public fun jsonHandlerOrThrow( + serde: JacksonSerde, + type: TypeReference, +): ResponseHandler = + ResponseHandler { response -> + throwIfNotSuccess(response) + decode(response, type.type.typeName) { stream -> serde.deserializeAs(stream, type) } + } + +/** + * Throws when [response] is not a 2xx: a 4xx / 5xx is mapped to its [HttpException] via + * [Response.throwOnError], which buffers a bounded (≤ 1 MiB) copy of the error body into the thrown + * exception so it survives the live response being closed (e.g. by a surrounding `response.use { … }`). + * + * Any other non-success status (a 1xx, or an unfollowed 3xx such as a `304 Not Modified` from a + * conditional request) is a **protocol/status condition, not a decode failure**: it raises a + * [SerdeException] whose message leads with the status code and carries any `ETag` / `Location` + * header — captured before the response is closed — so the caller can tell it apart from a codec + * bug and still recover the conditional-request / redirect context. The type stays [SerdeException] + * so existing `catch` sites keep working. Returns normally on a 2xx so the caller proceeds to decode. + */ +private fun throwIfNotSuccess(response: Response) { + val status = response.status + if (status.isSuccess) return + if (status.isError) { + // Buffers the error body (≤ 1 MiB) into the mapped HttpException and throws, so the caught + // exception's body is readable even after the live response is closed. Always throws here. + response.throwOnError() + } + // A 1xx or an unfollowed 3xx (e.g. 304 Not Modified). Capture the status code and the ETag / + // Location context BEFORE close() discards the response, and word the message as a status + // condition — not a deserialization failure — so it is not mistaken for a codec bug. + val message = + buildString { + append("HTTP ").append(status.code) + append(" is not a 2xx success status; refusing to decode a body from a non-success response.") + response.headers.get(HttpHeaderName.ETAG)?.let { append(" ETag=").append(it) } + response.headers.get(HttpHeaderName.LOCATION)?.let { append(" Location=").append(it) } + } + response.close() + throw SerdeException(message) +} + /** * Shared body-streaming + error-translation core for the [jsonHandler] overloads. Reads the * response body as an `InputStream`, hands it to [decoder], and closes the response in all cases. - * A missing body or any decoder failure is translated into a [SerdeException]. + * A missing body or a codec/parse failure is translated into a [SerdeException]; a genuine + * mid-stream [IOException] propagates unwrapped. */ private inline fun decode( response: Response, @@ -82,6 +169,13 @@ private inline fun decode( decoder(body.source().inputStream()) } catch (e: SerdeException) { throw e + } catch (e: IOException) { + // A genuine mid-stream read failure (e.g. a connection reset) is an I/O condition, not a + // codec fault, so propagate it unwrapped — a caller's catch (IOException) must still fire. + // The deserializer already normalizes parse/shape failures to SerdeException (caught + // above), matching the "a genuine stream-read IOException propagates unwrapped" contract + // JacksonDeserializer documents. + throw e } catch (e: Exception) { throw SerdeException("Failed to deserialize response body: ${e.message}", e) } diff --git a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerdeTest.kt b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerdeTest.kt index cdc6dd33..41e3e02b 100644 --- a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerdeTest.kt +++ b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JacksonSerdeTest.kt @@ -18,6 +18,7 @@ import org.dexpace.sdk.core.serde.DeserializationException import org.dexpace.sdk.core.serde.SerdeException import org.dexpace.sdk.core.serde.SerializationException import org.dexpace.sdk.core.serde.Tristate +import org.dexpace.sdk.core.serde.TypeRef import org.dexpace.sdk.core.serde.deserialize import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -34,6 +35,12 @@ class JacksonSerdeTest { // Kotlin data class with a nested object, list, java.time.Instant, Optional, Tristate. data class Inner(val tag: String, val score: Int) + // Minimal DTO with a Tristate field; used to verify PATCH-field serialization correctness. + data class PatchDto( + val name: String = "", + val description: Tristate = Tristate.Absent, + ) + data class FullModel( val name: String, val children: List, @@ -141,6 +148,43 @@ class JacksonSerdeTest { assertEquals(99, dto.score) } + @Test + fun `deserializer decodes a parametric TypeRef from a JSON array without a Jackson type at the call site`() { + val serde = JacksonSerde.withDefaults() + val json = """[{"tag":"a","score":1},{"tag":"b","score":2}]""" + // The decode call names only org.dexpace.sdk.core.serde.TypeRef — no com.fasterxml.jackson + // type is required to resolve List. This is the dep-free parametric-decode contract. + val list: List = serde.deserializer.deserialize(json, object : TypeRef>() {}) + assertEquals(listOf(Inner("a", 1), Inner("b", 2)), list) + } + + @Test + fun `deserializer decodes a parametric TypeRef from an InputStream`() { + val serde = JacksonSerde.withDefaults() + val json = """[{"tag":"x","score":9}]""" + val list: List = + serde.deserializer.deserialize(ByteArrayInputStream(json.toByteArray()), object : TypeRef>() {}) + assertEquals(listOf(Inner("x", 9)), list) + } + + @Test + fun `deserializer decodes a parametric TypeRef from a byte array`() { + // Byte-array counterpart to the string/InputStream parametric-TypeRef paths: the element + // type of List must survive erasure via mapper.constructType(ref.type). + val serde = JacksonSerde.withDefaults() + val json = """[{"tag":"a","score":1},{"tag":"b","score":2}]""" + val list: List = + serde.deserializer.deserialize(json.toByteArray(), object : TypeRef>() {}) + assertEquals(listOf(Inner("a", 1), Inner("b", 2)), list) + } + + @Test + fun `deserializer decodes a non-generic TypeRef via the raw-Class path`() { + val serde = JacksonSerde.withDefaults() + val dto: Inner = serde.deserializer.deserialize("""{"tag":"solo","score":3}""", object : TypeRef() {}) + assertEquals(Inner("solo", 3), dto) + } + @Test fun `reified deserialize extension forwards the type token`() { val serde = JacksonSerde.withDefaults() @@ -182,6 +226,72 @@ class JacksonSerdeTest { assertEquals(0, tracker.closeCount, "from(plain mapper) must not close the caller InputStream") } + @Test + fun `deserializeAs InputStream TypeReference leaves the caller stream open on a from-mapper serde`() { + // The deserializeAs(InputStream, TypeReference) overload drives a per-call parser with + // AUTO_CLOSE_SOURCE disabled, so even a from(plain mapper) serde (whose copy inherits + // AUTO_CLOSE_SOURCE=true) must read to EOF and leave the caller's stream open. + val serde = JacksonSerde.from(ObjectMapper().registerKotlinModule()) + val json = """{"tag":"s","score":5}""" + val tracker = TrackingInputStream(ByteArrayInputStream(json.toByteArray())) + val dto: Inner = serde.deserializeAs(tracker, object : TypeReference() {}) + assertEquals(Inner("s", 5), dto) + assertEquals(0, tracker.closeCount, "deserializeAs(InputStream, TypeReference) must not close the stream") + } + + // ----- T10: from(mapper) must register TristateModule (silent-corruption fix) ----- + + @Test + fun `from bare ObjectMapper round-trips Tristate present`() { + val serde = JacksonSerde.from(ObjectMapper().registerKotlinModule()) + val dto = PatchDto(name = "x", description = Tristate.Present("patch-value")) + val json = serde.serializer.serialize(dto) + assertTrue(json.contains("\"description\""), "Present field must appear in JSON: $json") + assertTrue(json.contains("\"patch-value\""), "Present value must appear in JSON: $json") + } + + @Test + fun `from bare ObjectMapper serializes Tristate absent by omitting the field`() { + val serde = JacksonSerde.from(ObjectMapper().registerKotlinModule()) + val dto = PatchDto(name = "y", description = Tristate.Absent) + val json = serde.serializer.serialize(dto) + assertFalse(json.contains("\"description\""), "Absent field must be omitted from JSON: $json") + } + + @Test + fun `from bare ObjectMapper serializes Tristate null as JSON null`() { + val serde = JacksonSerde.from(ObjectMapper().registerKotlinModule()) + val dto = PatchDto(name = "z", description = Tristate.Null) + val json = serde.serializer.serialize(dto) + assertTrue(json.contains("\"description\""), "Null field key must appear in JSON: $json") + assertTrue(json.contains(":null"), "Null field value must be JSON null: $json") + } + + @Test + fun `from does not mutate the caller's original mapper`() { + val original = ObjectMapper().registerKotlinModule() + JacksonSerde.from(original) + // If from() mutated original (added TristateModule), Tristate.Absent would be omitted. + // Without TristateModule, ObjectMapper treats Tristate.Absent as a regular object and + // includes the field in the output. + val dto = PatchDto(name = "check", description = Tristate.Absent) + val json = original.writeValueAsString(dto) + assertTrue(json.contains("\"description\""), "Original mapper must not be mutated by from(): $json") + } + + @Test + fun `from with registerTristate false skips auto-registration`() { + // Callers who already registered TristateModule manually should opt out to avoid + // a double registration. The copy of their mapper already has the module. + val pre = ObjectMapper().registerKotlinModule().also { it.registerModule(TristateModule()) } + val serde = JacksonSerde.from(pre, registerTristate = false) + // Serde is functional because TristateModule was pre-registered by the caller. + val dto = PatchDto(name = "a", description = Tristate.Present("v")) + val json = serde.serializer.serialize(dto) + assertTrue(json.contains("\"description\"")) + assertTrue(json.contains("\"v\"")) + } + // Java-style builder-pattern class. Jackson's @JsonDeserialize(builder = ...) + @JsonPOJOBuilder // is the canonical builder-friendly immutability pattern called out in the spec. @JsonDeserialize(builder = BuilderModel.Builder::class) @@ -228,11 +338,16 @@ class JacksonSerdeTest { } @Test - fun `JacksonSerde from caller-supplied mapper uses that mapper`() { + fun `JacksonSerde from caller-supplied mapper produces a working serde backed by a copy`() { val mapper = JacksonObjectMappers.defaultObjectMapper() val serde = JacksonSerde.from(mapper) - // The same mapper instance is exposed via the `mapper` property. - assertTrue(serde.mapper === mapper) + // from() works on a defensive copy — never the same reference as the caller's mapper. + assertFalse(serde.mapper === mapper, "from() must back the serde with a copy, not the original mapper") + // The serde is fully functional via the copy. + val dto = Inner("check", 1) + val json = serde.serializer.serialize(dto) + val back: Inner = serde.deserializer.deserialize(json, Inner::class.java) + assertEquals(dto, back) } // ----- SPI error wrapping (#22): Jackson exception types must not leak across the Serde SPI ----- @@ -297,6 +412,40 @@ class JacksonSerdeTest { } } + @Test + fun `decoding the JSON literal null through a non-null overload throws DeserializationException`() { + // Jackson's readValue yields a Java null for the literal `null`. The nonNull guard must + // convert that into a DeserializationException across every non-null overload rather than + // letting the null flow through the non-null generic T and detonate later as an NPE. + val serde = JacksonSerde.withDefaults() + val nullJson = "null" + val ref = object : TypeRef() {} + val typeRef = object : TypeReference() {} + + // String / ByteArray / InputStream × Class + assertFailsWith { serde.deserializer.deserialize(nullJson, Inner::class.java) } + assertFailsWith { + serde.deserializer.deserialize(nullJson.toByteArray(), Inner::class.java) + } + assertFailsWith { + serde.deserializer.deserialize(ByteArrayInputStream(nullJson.toByteArray()), Inner::class.java) + } + + // String / ByteArray / InputStream × TypeRef + assertFailsWith { serde.deserializer.deserialize(nullJson, ref) } + assertFailsWith { serde.deserializer.deserialize(nullJson.toByteArray(), ref) } + assertFailsWith { + serde.deserializer.deserialize(ByteArrayInputStream(nullJson.toByteArray()), ref) + } + + // deserializeAs (TypeReference) overloads + assertFailsWith { serde.deserializeAs(nullJson, typeRef) } + assertFailsWith { serde.deserializeAs(nullJson.toByteArray(), typeRef) } + assertFailsWith { + serde.deserializeAs(ByteArrayInputStream(nullJson.toByteArray()), typeRef) + } + } + @Test fun `type-mismatch payload surfaces SerdeException`() { val serde = JacksonSerde.withDefaults() diff --git a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandlerTest.kt b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandlerTest.kt index be9c404f..7770311e 100644 --- a/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandlerTest.kt +++ b/sdk-serde-jackson/src/test/kotlin/org/dexpace/sdk/serde/jackson/JsonResponseHandlerTest.kt @@ -16,9 +16,12 @@ import org.dexpace.sdk.core.http.response.ParsedResponse import org.dexpace.sdk.core.http.response.Response import org.dexpace.sdk.core.http.response.ResponseBody import org.dexpace.sdk.core.http.response.Status +import org.dexpace.sdk.core.http.response.deserialize +import org.dexpace.sdk.core.http.response.exception.HttpException import org.dexpace.sdk.core.io.BufferedSource import org.dexpace.sdk.core.io.Io import org.dexpace.sdk.core.serde.SerdeException +import org.dexpace.sdk.core.serde.TypeRef import org.dexpace.sdk.io.OkioIoProvider import java.util.concurrent.atomic.AtomicInteger import kotlin.test.BeforeTest @@ -34,13 +37,16 @@ class JsonResponseHandlerTest { Io.installProvider(OkioIoProvider) } - private fun jsonResponse(payload: String): Response { + private fun jsonResponse( + payload: String, + status: Status = Status.OK, + ): Response { val source = Io.provider.source(payload.toByteArray()) val body = ResponseBody.create(source, MediaType.parse("application/json"), payload.length.toLong()) return Response.builder() .request(Request.builder().url("https://api.example.test/p").method(Method.GET).build()) .protocol(Protocol.HTTP_1_1) - .status(Status.OK) + .status(status) .body(body) .build() } @@ -116,6 +122,96 @@ class JsonResponseHandlerTest { assertEquals(listOf(Dto("a", 1), Dto("b", 2)), list) } + @Test + fun `jsonHandlerOrThrow decodes the typed value on a 2xx response`() { + val serde = JacksonSerde.withDefaults() + val handler = jsonHandlerOrThrow(serde, Dto::class.java) + val dto = handler.handle(jsonResponse("""{"name":"ok","score":1}""")) + assertEquals(Dto("ok", 1), dto) + } + + @Test + fun `jsonHandlerOrThrow throws the mapped HttpException on a 500 instead of decoding`() { + val serde = JacksonSerde.withDefaults() + val handler = jsonHandlerOrThrow(serde, Dto::class.java) + val ex = + assertFailsWith { + handler.handle(jsonResponse("""{"error":"boom"}""", status = Status.INTERNAL_SERVER_ERROR)) + } + // The exception carries the originating status; it was NOT a decode attempt of the error body. + assertEquals(500, ex.status.code) + } + + @Test + fun `jsonHandlerOrThrow buffers the error body so it survives the response being closed`() { + val serde = JacksonSerde.withDefaults() + val handler = jsonHandlerOrThrow(serde, Dto::class.java) + val resp = jsonResponse("""{"error":"boom"}""", status = Status.INTERNAL_SERVER_ERROR) + // Mirrors handleWith: the handler runs inside response.use { }, so the live response (and its + // body) is closed as the block unwinds via the thrown exception. + val ex = + assertFailsWith { + resp.use { handler.handle(it) } + } + // The error body was buffered into the exception, so it is still readable after the outer + // use{} closed the live response — no use-after-close. + val body = ex.body ?: error("expected a buffered error body on the thrown HttpException") + assertEquals("""{"error":"boom"}""", body.source().readString(Charsets.UTF_8)) + } + + @Test + fun `jsonHandlerOrThrow with a TypeReference decodes parametric types on 2xx and throws on 5xx`() { + val serde = JacksonSerde.withDefaults() + val ok = + jsonHandlerOrThrow(serde, object : TypeReference>() {}) + .handle(jsonResponse("""[{"name":"a","score":1}]""")) + assertEquals(listOf(Dto("a", 1)), ok) + + assertFailsWith { + jsonHandlerOrThrow(serde, object : TypeReference>() {}) + .handle(jsonResponse("""{"error":"nope"}""", status = Status.SERVICE_UNAVAILABLE)) + } + } + + @Test + fun `jsonHandlerOrThrow throws the mapped HttpException on a non-canonical 5xx code`() { + val serde = JacksonSerde.withDefaults() + val handler = jsonHandlerOrThrow(serde, Dto::class.java) + // 599 has no canonical Status constant; status.isError (400..599) must still route it + // through throwOnError to a mapped HttpException rather than decoding the error body. + val ex = + assertFailsWith { + handler.handle(jsonResponse("""{"error":"boom"}""", status = Status.fromCode(599))) + } + assertEquals(599, ex.status.code) + } + + @Test + fun `jsonHandlerOrThrow closes the response and raises SerdeException on a non-error non-2xx status`() { + val serde = JacksonSerde.withDefaults() + val body = CountingBody("""{"whatever":true}""") + val resp = + Response.builder() + .request(Request.builder().url("https://api.example.test/p").method(Method.GET).build()) + .protocol(Protocol.HTTP_1_1) + // 304: neither success nor error, so it is neither decoded nor mapped to an + // HttpException — the handler closes the response and fails with a SerdeException. + .status(Status.NOT_MODIFIED) + .body(body) + .build() + val ex = assertFailsWith { jsonHandlerOrThrow(serde, Dto::class.java).handle(resp) } + assertEquals(true, ex.message?.contains("304") == true, "message should name the offending status") + assertEquals(1, body.closeCount.get(), "the non-success response must be closed") + } + + @Test + fun `Response deserialize with a TypeRef decodes a parametric List via a JacksonSerde`() { + val serde = JacksonSerde.withDefaults() + val resp = jsonResponse("""[{"name":"a","score":1},{"name":"b","score":2}]""") + val list: List = resp.deserialize(serde, object : TypeRef>() {}) + assertEquals(listOf(Dto("a", 1), Dto("b", 2)), list) + } + @Test fun `jsonHandler composes with ParsedResponse for lazy parse-once`() { val serde = JacksonSerde.withDefaults() diff --git a/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api b/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api index 661e8c57..e7608777 100644 --- a/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api +++ b/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api @@ -7,7 +7,9 @@ public final class org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport : org/dexp public static final fun create (Ljava/net/http/HttpClient;Ljava/time/Duration;)Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport; public static final fun create (Ljava/net/http/HttpClient;Ljava/time/Duration;Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;)Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport; 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 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/transport/jdkhttp/JdkHttpTransport$Builder : org/dexpace/sdk/core/generics/Builder { @@ -18,6 +20,7 @@ public final class org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport$Builder : public final fun followRedirects (Z)Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport$Builder; public final fun httpVersion (Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport$HttpVersion;)Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport$Builder; public final fun proxy (Lorg/dexpace/sdk/core/util/ProxyOptions;)Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport$Builder; + public final fun proxyFromEnvironment ()Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport$Builder; public final fun responseTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/transport/jdkhttp/JdkHttpTransport$Builder; } diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt index 6abaa7ad..860e4342 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt @@ -10,13 +10,16 @@ package org.dexpace.sdk.transport.jdkhttp import org.dexpace.sdk.core.client.AsyncHttpClient 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 org.dexpace.sdk.core.http.response.exception.NetworkException import org.dexpace.sdk.core.instrumentation.ClientLogger import org.dexpace.sdk.core.instrumentation.DroppedHeaderLogging import org.dexpace.sdk.core.util.ProxyOptions import org.dexpace.sdk.transport.jdkhttp.internal.RequestAdapter import org.dexpace.sdk.transport.jdkhttp.internal.ResponseAdapter import org.dexpace.sdk.transport.jdkhttp.internal.bridgeAsyncResponse +import org.dexpace.sdk.transport.jdkhttp.internal.mapTransportFailure import java.io.IOException import java.io.InputStream import java.io.InterruptedIOException @@ -98,32 +101,54 @@ public class JdkHttpTransport private constructor( private val closed: AtomicBoolean = AtomicBoolean(false) /** - * Synchronously executes [request] on the caller's thread. Honours `Thread.interrupt` - * via [InterruptedIOException]: the JDK client surfaces a thread-interrupt mid-call - * as a wrapped [InterruptedException]; the adapter unwraps that, re-asserts the - * thread interrupt status, and rethrows as [InterruptedIOException] so callers see a - * consistent interrupt surface. + * Synchronously executes [request] on the caller's thread. Delegates to the per-call overload + * with [RequestOptions.EMPTY], so the configured [responseTimeout] applies unchanged. */ @Throws(IOException::class) - override fun execute(request: Request): Response { - val jdkRequest = requestAdapter.adapt(request, responseTimeout) - return try { - val jdkResponse: HttpResponse = + override fun execute(request: Request): Response = execute(request, RequestOptions.EMPTY) + + /** + * Synchronously executes [request] with per-call [options] on the caller's thread. When + * [RequestOptions.timeout] is non-null it replaces the configured [responseTimeout] on this + * request's [java.net.http.HttpRequest.Builder.timeout] for this call only; a null timeout + * keeps the configured default. Honours `Thread.interrupt` via [InterruptedIOException]: the + * JDK client surfaces a thread-interrupt mid-call as a wrapped [InterruptedException]; the + * adapter unwraps that, re-asserts the thread interrupt status, and rethrows as + * [InterruptedIOException] so callers see a consistent interrupt surface. + */ + @Throws(IOException::class) + override fun execute( + request: Request, + options: RequestOptions, + ): Response { + val jdkRequest = requestAdapter.adapt(request, effectiveTimeout(options)) + // The dispatch is guarded on its own so only a `client.send` failure maps onto the + // transport-error contract — a later `responseAdapter.adapt` failure keeps its own type. + val jdkResponse: HttpResponse = + try { client.send(jdkRequest, HttpResponse.BodyHandlers.ofInputStream()) - responseAdapter.adapt(request, jdkResponse) - } catch (e: InterruptedException) { - // `HttpClient.send` declares `InterruptedException` — the JDK throws it when the - // calling thread is interrupted mid-call. Re-assert the interrupt flag so the - // caller's surrounding code can observe interrupt status, then surface as the - // SDK's documented `InterruptedIOException`. - Thread.currentThread().interrupt() - val wrapped = InterruptedIOException("interrupted while waiting for response") - wrapped.initCause(e) - throw wrapped - } catch (e: InterruptedIOException) { - Thread.currentThread().interrupt() - throw e - } + } catch (e: InterruptedException) { + // `HttpClient.send` declares `InterruptedException` — the JDK throws it when the + // calling thread is interrupted mid-call. Re-assert the interrupt flag so the + // caller's surrounding code can observe interrupt status, then surface as the + // SDK's documented `InterruptedIOException`. + Thread.currentThread().interrupt() + val wrapped = InterruptedIOException("interrupted while waiting for response") + wrapped.initCause(e) + throw wrapped + } catch (e: InterruptedIOException) { + Thread.currentThread().interrupt() + throw e + } catch (e: IOException) { + // A non-interrupt transport failure — connect refused, DNS lookup failure, peer + // reset, TLS handshake failure, a non-interrupt (Http)timeout — produced no + // response. Surface it as the SDK's NetworkException; because that is itself an + // IOException, existing `catch (IOException)` sites keep matching while the retry + // classifier now sees the retryable transport-failure contract. The two interrupt + // branches above run first, so an interrupt-derived failure is never wrapped here. + throw NetworkException(e.message, e) + } + return responseAdapter.adapt(request, jdkResponse) } /** @@ -137,15 +162,29 @@ public class JdkHttpTransport private constructor( * [bridgeAsyncResponse]). The intermediate `thenApply`-style future the JDK hands back does * not propagate cancellation to the `sendAsync` exchange on its own, so the bridge wires that * through explicitly. Consumers should still call `Response.close()` on success-path - * completions to release the body's connection back to the pool. + * completions to release the body's connection back to the pool. Delegates to the per-call + * overload with [RequestOptions.EMPTY], so the configured [responseTimeout] applies unchanged. */ - override fun executeAsync(request: Request): CompletableFuture { + override fun executeAsync(request: Request): CompletableFuture = + executeAsync(request, RequestOptions.EMPTY) + + /** + * Asynchronously executes [request] with per-call [options]. When [RequestOptions.timeout] is + * non-null it replaces the configured [responseTimeout] on this request's per-request timeout + * for this call only; a null timeout keeps the configured default. Otherwise identical to + * [executeAsync]: the returned future completes with the [Response] or the transport failure, + * and cancelling it cancels the underlying JDK exchange. + */ + override fun executeAsync( + request: Request, + options: RequestOptions, + ): CompletableFuture { // Held outside the try so the catch can release the JDK exchange if dispatch succeeded but a // later step threw (see the catch). Null until `sendAsync` returns: a throw at or before // dispatch leaves nothing to clean up. var inFlight: CompletableFuture>? = null return try { - val jdkRequest = requestAdapter.adapt(request, responseTimeout) + val jdkRequest = requestAdapter.adapt(request, effectiveTimeout(options)) // `sendAsync` is inside the guard too. Its contract does not promise that every failure // is delivered through the returned future: the JDK's own Javadoc permits a synchronous // `IllegalArgumentException` for a request it rejects, and a custom or future @@ -155,7 +194,12 @@ public class JdkHttpTransport private constructor( // failures into an already-failed future — e.g. on a closed client — which the bridge // propagates and so never reaches this catch; the guard is for the throwing case.) inFlight = client.sendAsync(jdkRequest, HttpResponse.BodyHandlers.ofInputStream()) - bridgeAsyncResponse(inFlight) { jdkResponse -> responseAdapter.adapt(request, jdkResponse) } + // `mapTransportFailure` maps a non-interrupt transport IOException delivered through the + // exchange future onto the SDK's NetworkException contract (interrupt-derived failures + // pass through untouched), mirroring the sync `execute` path. + bridgeAsyncResponse(inFlight, ::mapTransportFailure) { jdkResponse -> + responseAdapter.adapt(request, jdkResponse) + } } catch (e: Exception) { // The async contract is that errors arrive through the returned future. The dispatch // path above runs on the caller's thread and can throw — request adaptation rejecting a @@ -177,6 +221,15 @@ public class JdkHttpTransport private constructor( } } + /** + * The per-request timeout to apply for this call: [RequestOptions.timeout] when the caller + * supplied one, otherwise the transport's configured [responseTimeout] (which may itself be + * null, i.e. no per-request timeout). The JDK client has no global response-timeout knob, so + * the resolved value is handed to [RequestAdapter.adapt] and set via + * [java.net.http.HttpRequest.Builder.timeout]. + */ + private fun effectiveTimeout(options: RequestOptions): Duration? = options.timeout ?: responseTimeout + /** * Releases SDK-owned JDK HTTP resources. When this transport was built via [builder]: * @@ -277,6 +330,16 @@ public class JdkHttpTransport private constructor( * assignment. */ internal const val DEFAULT_RESPONSE_TIMEOUT_SECONDS: Long = 30L + + /** + * Default connect timeout in seconds applied when the [Builder] is used without + * an explicit [Builder.connectTimeout] call. 10 s covers most real-world TCP + TLS + * handshake latencies while keeping test feedback tight on dead ports. + * + * Internal for the same reason as [DEFAULT_RESPONSE_TIMEOUT_SECONDS] — callers rely + * on the [Builder]'s default rather than reading this constant. + */ + internal const val DEFAULT_CONNECT_TIMEOUT_SECONDS: Long = 10L } /** @@ -298,7 +361,8 @@ public class JdkHttpTransport private constructor( * [java.net.http.HttpClient] directly and pass it to [JdkHttpTransport.create]. * * Defaults: - * - `connectTimeout`: not set (the JDK applies its platform default). + * - `connectTimeout`: 10 seconds (TCP + TLS handshake budget). Pass `null` to + * `connectTimeout(null)` to disable and fall back to the JDK platform default. * - `responseTimeout`: 30 seconds (applied per-request). * - `proxy`: none. * - `followRedirects`: `false` (SDK has `DefaultRedirectStep`). @@ -306,15 +370,18 @@ public class JdkHttpTransport private constructor( */ public class Builder internal constructor() : SdkBuilder { private val log: ClientLogger = ClientLogger("org.dexpace.sdk.transport.jdkhttp.JdkHttpTransport.Builder") - private var connectTimeout: Duration? = null + private var connectTimeout: Duration? = Duration.ofSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS) private var responseTimeout: Duration? = Duration.ofSeconds(DEFAULT_RESPONSE_TIMEOUT_SECONDS) private var proxy: ProxyOptions? = null private var followRedirects: Boolean = false private var httpVersion: HttpVersion = HttpVersion.HTTP_2 private var droppedHeaderLogging: DroppedHeaderLogging = DroppedHeaderLogging.ONCE_PER_HEADER - /** Sets the connect timeout (TCP handshake + TLS handshake). */ - public fun connectTimeout(d: Duration): Builder = + /** + * Sets the connect timeout (TCP + TLS handshake). Pass `null` to disable the + * timeout and fall back to the JDK's platform default (effectively infinite). + */ + public fun connectTimeout(d: Duration?): Builder = apply { this.connectTimeout = d } @@ -335,6 +402,24 @@ public class JdkHttpTransport private constructor( this.proxy = p } + /** + * Reads proxy settings from the process-wide environment via [ProxyOptions.fromEnvironment] + * and applies the resolved proxy to the client being built. See that method for the exact + * environment-variable / system-property resolution. + * + * Strictly opt-in: the environment is never consulted unless this is called, and the call + * is a no-op when nothing there configures a proxy (or when `NO_PROXY=*` bypasses it). This + * writes the same slot as [proxy], so whichever of the two runs last wins. + * + * If the resolved proxy carries Basic credentials, note the HTTPS CONNECT-tunnel caveat + * documented on [applyProxy]: the JDK client does not send Basic proxy credentials on an + * HTTPS tunnel unless `jdk.http.auth.tunneling.disabledSchemes` is cleared. + */ + public fun proxyFromEnvironment(): Builder = + apply { + ProxyOptions.fromEnvironment()?.let { this.proxy = it } + } + /** * Whether the JDK should follow 3xx redirects automatically. `true` → * [java.net.http.HttpClient.Redirect.NORMAL]; `false` (default) → @@ -424,6 +509,18 @@ public class JdkHttpTransport private constructor( * built-in handling of a registered `Authenticator` covers the **Basic** scheme * only — this transport does **not** perform Digest proxy authentication. * + * **Basic proxy auth over an HTTPS CONNECT tunnel is disabled by default.** For an HTTPS + * origin the JDK client establishes the proxy via a `CONNECT` tunnel, and since the fix for + * JDK-8168839 the `java.net.http` client refuses to send Basic proxy credentials on that + * tunnel — the `jdk.http.auth.tunneling.disabledSchemes` system property defaults to + * `Basic`. So a [ProxyOptions.username] / [ProxyOptions.password] Basic credential that works + * for a plaintext-HTTP origin (where the proxy is used as a forward proxy, not a tunnel) + * is silently NOT sent for an `https://` origin, and the proxy answers `407`. This diverges + * from the OkHttp transport, which sends Basic proxy credentials on HTTPS tunnels. To opt + * back in, launch the JVM with `-Djdk.http.auth.tunneling.disabledSchemes=` (empty value) + * — set it as an early startup property, since the JDK reads it once. Credentials are + * deliberately never logged. + * * A configured [ProxyOptions.challengeHandler] is **not** honoured by this transport: * `java.net.http.HttpClient` exposes no per-407 hook through which a custom * `ChallengeHandler` (e.g. Digest) could be invoked, so the handler is dropped with a diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt index 4b7e40c2..2fe18161 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt @@ -25,17 +25,21 @@ import org.dexpace.sdk.core.http.response.Response as SdkResponse * `InputStream`), the adapted response is closed so its connection returns to the pool. * * @param inFlight the future from `HttpClient.sendAsync(..., BodyHandlers.ofInputStream())`. + * @param mapError maps a failure delivered through [inFlight] onto the exception the returned + * future should complete with — e.g. wrapping a transport [java.io.IOException] as the SDK's + * `NetworkException`. Defaults to identity (surface the failure verbatim). * @param adapt converts the JDK response into an SDK [SdkResponse]; it must close the response * body itself if it throws (the SDK [ResponseAdapter] does). */ internal fun bridgeAsyncResponse( inFlight: CompletableFuture>, + mapError: (Throwable) -> Throwable = { it }, adapt: (HttpResponse) -> SdkResponse, ): CompletableFuture { val result = CompletableFuture() inFlight.whenComplete { jdkResponse, error -> if (error != null) { - result.completeExceptionally(error) + result.completeExceptionally(mapError(error)) } else { val adapted = try { diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt index 97fef2ed..af2c0799 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt @@ -84,10 +84,48 @@ internal class RequestAdapter( val builder = HttpRequest.newBuilder().uri(request.url.toURI()) attachMethod(builder, request) attachHeaders(builder, request) + attachDerivedContentType(builder, request) responseTimeout?.let { builder.timeout(it) } return builder.build() } + /** + * Emits a `Content-Type` derived from the request body's media type when — and only when — the + * caller set no explicit `Content-Type` header. + * + * `java.net.http.HttpClient` does not derive a `Content-Type` from the body publisher, unlike + * OkHttp's `BridgeInterceptor`. So the documented `RequestBody.create(value, serde)` pattern — + * which auto-stamps the body with `application/json` — would put a `Content-Type` on the wire + * through the OkHttp transport but none through this one. Deriving it here from + * [org.dexpace.sdk.core.http.request.RequestBody.mediaType] closes that gap so both transports + * agree on the wire form. + * + * An explicit caller `Content-Type` (already copied by [attachHeaders]; `Content-Type` is not a + * [RestrictedHeaders] entry) is authoritative and never overridden — the `headers.contains` + * check is case-insensitive, so any casing of the caller's header suppresses the derived one. + */ + private fun attachDerivedContentType( + builder: HttpRequest.Builder, + request: SdkRequest, + ) { + if (request.headers.contains("Content-Type")) { + return + } + val mediaType = request.body?.mediaType() ?: return + try { + builder.header("Content-Type", mediaType.toString()) + } catch (e: IllegalArgumentException) { + // Defensive: a body media type the JDK refuses as a header value. Drop it rather than + // let the IllegalArgumentException escape adapt (reachable from execute, declared + // @Throws(IOException)); the request proceeds without a derived Content-Type. + logger.atVerbose() + .event("transport.jdkhttp.header.rejected") + .field("name", "Content-Type") + .cause(e) + .log("JDK rejected body-derived Content-Type; sending without it") + } + } + /** * Maps the SDK [Method] onto the JDK builder's method API. The split between methods * that take a [HttpRequest.BodyPublisher] and those that force [HttpRequest.BodyPublishers.noBody] @@ -103,6 +141,18 @@ internal class RequestAdapter( * - `POST` / `PUT` / `PATCH` / `DELETE` / `OPTIONS` — body publisher passed through. `DELETE` * and `OPTIONS` with a body are unusual but permitted by HTTP and the JDK builder. * - `CONNECT` — rejected; see [adapt]'s KDoc. + * + * **Bodyless-DELETE wire divergence (residual, documented).** For a bodyless DELETE the SDK body + * is `null`, so [BodyPublishers.adaptBody] returns `HttpRequest.BodyPublishers.noBody()`, whose + * `contentLength()` is `0`. On JDK builds that serialize a fixed zero length, this puts + * `Content-Length: 0` on the wire, whereas the OkHttp transport sends a bodyless DELETE with no + * `Content-Length` at all (its `null`-body path writes no framing header). The `java.net.http` + * builder API cannot express a non-GET method with a genuinely absent body — `method(String, + * BodyPublisher)` requires a non-null publisher and the `DELETE()` convenience itself uses + * `noBody()` — so `noBody()` is the only representable "empty" body and this residual difference + * cannot be eliminated at the adapter layer. It is a header-only divergence with no effect on the + * request semantics. (Bodyless POST/PUT/PATCH already emit `Content-Length: 0` on both transports + * — OkHttp substitutes a zero-length body there — so only DELETE diverges.) */ private fun attachMethod( builder: HttpRequest.Builder, diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailures.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailures.kt new file mode 100644 index 00000000..1c407827 --- /dev/null +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailures.kt @@ -0,0 +1,38 @@ +/* + * 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.transport.jdkhttp.internal + +import org.dexpace.sdk.core.http.response.exception.NetworkException +import org.dexpace.sdk.core.util.Futures +import java.io.IOException +import java.io.InterruptedIOException + +/** + * Maps a JDK transport-level failure onto the SDK's [NetworkException] contract. + * + * `HttpClient.sendAsync` reports a transport failure by completing its exchange future + * exceptionally; the failure may arrive bare or wrapped in a + * [java.util.concurrent.CompletionException] / [java.util.concurrent.ExecutionException]. This + * strips those envelopes via [Futures.unwrap] (which is cycle-safe) and then classifies the root + * cause: + * + * - An interrupt-derived failure ([InterruptedIOException]) is returned untouched — as the + * ORIGINAL [error] with any envelopes intact — so the caller keeps observing it as an + * interrupt. + * - Any other [IOException] — connect refused, DNS lookup failure, peer reset, TLS handshake + * failure, a non-interrupt timeout — is wrapped in a [NetworkException]. Because that is itself + * an [IOException], existing `catch (IOException)` sites keep matching and the retry classifier + * sees the retryable transport-failure contract. + * - Anything else is returned untouched. + */ +internal fun mapTransportFailure(error: Throwable): Throwable = + when (val cause = Futures.unwrap(error)) { + is InterruptedIOException -> error + is IOException -> NetworkException(cause.message, cause) + else -> error + } diff --git a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportDefaultConnectTimeoutTest.kt b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportDefaultConnectTimeoutTest.kt new file mode 100644 index 00000000..71aae3d0 --- /dev/null +++ b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportDefaultConnectTimeoutTest.kt @@ -0,0 +1,65 @@ +/* + * 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.transport.jdkhttp + +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.io.OkioIoProvider +import java.net.http.HttpClient +import java.time.Duration +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Verifies that [JdkHttpTransport.Builder] applies a sensible connect-timeout default + * and allows callers to override or clear it. + * + * The underlying [java.net.http.HttpClient] is extracted via reflection (the same technique + * used in [JdkHttpTransportCloseTest]) because the transport intentionally does not expose + * the internal JDK client. + */ +class JdkHttpTransportDefaultConnectTimeoutTest { + @BeforeTest + fun setUp() { + Io.installProvider(OkioIoProvider) + } + + @Test + fun `builder applies 10s connect timeout by default`() { + val transport = JdkHttpTransport.builder().build() + val timeout = extractClient(transport).connectTimeout() + + assertTrue(timeout.isPresent, "connect timeout should be set by default") + assertEquals(Duration.ofSeconds(10), timeout.get()) + } + + @Test + fun `builder honours explicit connect timeout override`() { + val transport = JdkHttpTransport.builder().connectTimeout(Duration.ofSeconds(5)).build() + val timeout = extractClient(transport).connectTimeout() + + assertTrue(timeout.isPresent) + assertEquals(Duration.ofSeconds(5), timeout.get()) + } + + @Test + fun `builder accepts null connect timeout to disable it`() { + val transport = JdkHttpTransport.builder().connectTimeout(null).build() + val timeout = extractClient(transport).connectTimeout() + + assertFalse(timeout.isPresent, "null connect timeout should result in no JDK connect timeout") + } + + private fun extractClient(transport: JdkHttpTransport): HttpClient { + val field = JdkHttpTransport::class.java.getDeclaredField("client") + field.isAccessible = true + return field.get(transport) as HttpClient + } +} diff --git a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt index 15bfa6fe..2c63daa7 100644 --- a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt +++ b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt @@ -11,12 +11,15 @@ import mockwebserver3.MockResponse import mockwebserver3.MockWebServer import mockwebserver3.junit5.StartStop import okio.Buffer +import org.dexpace.sdk.core.config.Configuration import org.dexpace.sdk.core.http.common.CommonMediaTypes import org.dexpace.sdk.core.http.common.MediaType 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.RequestBody +import org.dexpace.sdk.core.http.request.RequestOptions +import org.dexpace.sdk.core.http.response.exception.NetworkException import org.dexpace.sdk.core.instrumentation.DroppedHeaderLogging import org.dexpace.sdk.core.io.BufferedSink import org.dexpace.sdk.core.io.Io @@ -194,6 +197,72 @@ class JdkHttpTransportTest { assertEquals(payload, recorded.body?.utf8()) } + // -------- per-call RequestOptions.timeout -------- + + @Test + fun `perCallTimeoutFiresOnSlowResponse`() { + // A short per-call timeout replaces the transport's 30s default response timeout for this + // request only. The headers delay comfortably exceeds the 200ms budget, so the JDK's + // per-request timeout fires and surfaces as an IOException (HttpTimeoutException). + server.enqueue( + MockResponse.Builder() + .code(200) + .body("late") + .headersDelay(800, TimeUnit.MILLISECONDS) + .build(), + ) + val options = RequestOptions.builder().timeout(Duration.ofMillis(200)).build() + val ex = + assertFails { + transport.execute(simpleGet("/slow-percall"), options).close() + } + assertTrue(ex is IOException, "expected IOException, got ${ex::class}") + } + + @Test + fun `perCallTimeoutHonouredWhenGenerousAndAbsent`() { + // Contrast: the SAME per-call mechanism with a generous budget does NOT prematurely fail a + // moderately slow response, proving the configured duration is honoured. A second request + // with EMPTY options (no per-call override, so the 30s default applies) also succeeds — + // the no-override path is unchanged. + server.enqueue( + MockResponse.Builder() + .code(200) + .body("on-time") + .headersDelay(150, TimeUnit.MILLISECONDS) + .build(), + ) + val generous = RequestOptions.builder().timeout(Duration.ofSeconds(10)).build() + transport.execute(simpleGet("/generous-percall"), generous).use { response -> + assertEquals(200, response.status.code) + assertEquals("on-time", response.body?.source()?.readUtf8()) + } + + server.enqueue(MockResponse.Builder().code(200).body("empty-opts").build()) + transport.execute(simpleGet("/empty-opts"), RequestOptions.EMPTY).use { response -> + assertEquals(200, response.status.code) + assertEquals("empty-opts", response.body?.source()?.readUtf8()) + } + } + + @Test + fun `perCallTimeoutFiresOnSlowResponseAsync`() { + // Async mirror: the per-call timeout is set on the per-request builder, so the returned + // future completes exceptionally. + server.enqueue( + MockResponse.Builder() + .code(200) + .body("late") + .headersDelay(800, TimeUnit.MILLISECONDS) + .build(), + ) + val options = RequestOptions.builder().timeout(Duration.ofMillis(200)).build() + val future = transport.executeAsync(simpleGet("/slow-percall-async"), options) + val ex = assertFails { future.get(5, TimeUnit.SECONDS) } + assertTrue(ex is ExecutionException, "expected ExecutionException, got ${ex::class}") + assertTrue(ex.cause is IOException, "expected IOException cause, got ${ex.cause?.let { it::class }}") + } + // -------- async adaptation failures -------- @Test @@ -658,6 +727,86 @@ class JdkHttpTransportTest { } } + // -------- proxy: opt-in proxyFromEnvironment() -------- + + @Test + fun `proxyFromEnvironmentAppliesConfiguredProxy`() { + // With HTTPS_PROXY pointed at MockWebServer, proxyFromEnvironment() must apply it: a + // request to an unresolvable `.example` host still reaches MockWebServer, which is only + // possible via the forward proxy (a direct connect would fail DNS). + val previous = Configuration.getGlobalConfiguration() + try { + Configuration.setGlobalConfiguration( + Configuration.builder() + .envSource { null } + .propsSource { null } + .put(Configuration.HTTPS_PROXY, "http://${server.hostName}:${server.port}") + .build(), + ) + server.enqueue(MockResponse.Builder().code(200).body("via-proxy").build()) + JdkHttpTransport.builder() + .httpVersion(JdkHttpTransport.HttpVersion.HTTP_1_1) + .proxyFromEnvironment() + .build() + .use { proxied -> + val request = + Request.builder() + .method(Method.GET) + .url(URL("http://downstream.example/resource")) + .build() + proxied.execute(request).use { response -> + assertEquals( + 200, + response.status.code, + "request routed through the env-configured proxy must succeed", + ) + assertEquals("via-proxy", response.body?.source()?.readUtf8()) + } + } + val recorded = server.takeRequest() + assertTrue( + recorded.target.contains("downstream.example"), + "request must have been forwarded via the env-configured proxy, target was: ${recorded.target}", + ) + } finally { + Configuration.setGlobalConfiguration(previous) + } + } + + @Test + fun `proxyFromEnvironmentIsOptIn`() { + // The very same global config carries a proxy, but a builder that does NOT call + // proxyFromEnvironment() must ignore it: a direct request lands on MockWebServer with an + // origin-form target, proving no forward proxy was inserted. + val previous = Configuration.getGlobalConfiguration() + try { + Configuration.setGlobalConfiguration( + Configuration.builder() + .envSource { null } + .propsSource { null } + .put(Configuration.HTTPS_PROXY, "http://${server.hostName}:${server.port}") + .build(), + ) + server.enqueue(MockResponse.Builder().code(200).body("direct").build()) + JdkHttpTransport.builder() + .httpVersion(JdkHttpTransport.HttpVersion.HTTP_1_1) + .build() + .use { plain -> + plain.execute(simpleGet("/direct")).use { response -> + assertEquals(200, response.status.code) + } + } + val recorded = server.takeRequest() + assertEquals( + "/direct", + recorded.target, + "an opt-out transport must issue an origin-form request (no proxy applied)", + ) + } finally { + Configuration.setGlobalConfiguration(previous) + } + } + // -------- response body streaming -------- @Test @@ -733,6 +882,70 @@ class JdkHttpTransportTest { Thread.sleep(900) } + // -------- transport error contract: NetworkException -------- + + @Test + fun `connectionFailureSurfacesAsNetworkExceptionSync`() { + // A refused TCP connect (a freed-then-closed local port) is a transport-level IOException + // with no response on the wire. The transport must surface it as the SDK's + // NetworkException — an IOException subtype, so existing `catch (IOException)` sites keep + // matching — and it must be retryable. + val deadPort = freePort() + val request = + Request.builder() + .method(Method.GET) + .url(URL("http://127.0.0.1:$deadPort/")) + .build() + val ex = + assertFailsWith { + transport.execute(request).close() + } + assertTrue(ex.isRetryable, "a transport-level failure must be retryable") + } + + @Test + fun `connectionFailureSurfacesAsNetworkExceptionAsync`() { + val deadPort = freePort() + val request = + Request.builder() + .method(Method.GET) + .url(URL("http://127.0.0.1:$deadPort/")) + .build() + val future = transport.executeAsync(request) + val ex = assertFailsWith { future.get(5, TimeUnit.SECONDS) } + val cause = ex.cause + assertTrue(cause is NetworkException, "expected NetworkException, got ${cause?.let { it::class }}") + assertTrue(cause.isRetryable, "a transport-level failure must be retryable") + } + + @Test + fun `interruptIsNotWrappedAsNetworkException`() { + // The interrupt path must remain unchanged: a thread interrupted mid-call surfaces an + // InterruptedIOException, never a NetworkException. Pre-interrupt the worker so the JDK's + // send side observes the interrupt before any socket I/O. + val errorRef = AtomicReference() + val worker = + Thread { + Thread.currentThread().interrupt() + try { + transport.execute(simpleGet("/never-served")) + } catch (t: Throwable) { + errorRef.set(t) + } + } + worker.start() + worker.join(3000) + assertTrue(!worker.isAlive, "worker should exit on interrupt") + val captured = errorRef.get() + assertNotNull(captured, "expected InterruptedIOException to surface") + assertTrue( + captured is java.io.InterruptedIOException, + "interrupt must surface as InterruptedIOException, got ${captured::class}", + ) + // An InterruptedIOException is disjoint from NetworkException (sibling IOException + // subtypes), so surfacing the former proves the interrupt was NOT wrapped as the latter. + } + // -------- cancellation -------- @Test diff --git a/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailuresTest.kt b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailuresTest.kt new file mode 100644 index 00000000..dced0596 --- /dev/null +++ b/sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailuresTest.kt @@ -0,0 +1,70 @@ +/* + * 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.transport.jdkhttp.internal + +import org.dexpace.sdk.core.http.response.exception.NetworkException +import java.io.IOException +import java.io.InterruptedIOException +import java.net.ConnectException +import java.util.concurrent.CompletionException +import java.util.concurrent.ExecutionException +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Unit coverage for [mapTransportFailure]'s classification of async-delivered exchange failures. */ +class TransportFailuresTest { + @Test + fun `a bare transport IOException is wrapped as a retryable NetworkException`() { + val cause = ConnectException("connection refused") + val mapped = mapTransportFailure(cause) + assertTrue(mapped is NetworkException, "expected NetworkException, got ${mapped::class}") + assertSame(cause, mapped.cause, "the original IOException must be preserved as the cause") + assertTrue(mapped.isRetryable, "a transport failure must be retryable") + } + + @Test + fun `a CompletionException-wrapped IOException is unwrapped then mapped`() { + val cause = IOException("reset by peer") + val mapped = mapTransportFailure(CompletionException(cause)) + assertTrue(mapped is NetworkException, "expected NetworkException, got ${mapped::class}") + assertSame(cause, mapped.cause, "the inner IOException must become the NetworkException cause") + } + + @Test + fun `an interrupt-derived failure is returned untouched`() { + val interrupt = InterruptedIOException("interrupted") + assertSame(interrupt, mapTransportFailure(interrupt), "an interrupt must not be wrapped") + + val wrapped = CompletionException(interrupt) + assertSame(wrapped, mapTransportFailure(wrapped), "an enveloped interrupt must pass through verbatim") + } + + @Test + fun `a non-IO failure is returned untouched`() { + val boom = IllegalStateException("not a transport failure") + assertSame(boom, mapTransportFailure(boom), "a non-IO failure must not be repackaged") + } + + @Test + fun `nested envelopes are stripped down to the root IOException`() { + val cause = IOException("tls handshake failure") + val nested = CompletionException(ExecutionException("execution", cause)) + val mapped = mapTransportFailure(nested) + assertTrue(mapped is NetworkException, "expected NetworkException, got ${mapped::class}") + assertSame(cause, mapped.cause, "the root IOException must be reached through both envelopes") + } + + @Test + fun `an envelope with no cause is returned untouched`() { + // A CompletionException carrying no cause has no transport IOException to surface, so the + // envelope itself is returned rather than being mapped or looping in the unwrap. + val empty = CompletionException("no cause", null) + assertSame(empty, mapTransportFailure(empty)) + } +} diff --git a/sdk-transport-okhttp/api/sdk-transport-okhttp.api b/sdk-transport-okhttp/api/sdk-transport-okhttp.api index 529de89c..dd93c51a 100644 --- a/sdk-transport-okhttp/api/sdk-transport-okhttp.api +++ b/sdk-transport-okhttp/api/sdk-transport-okhttp.api @@ -6,7 +6,9 @@ public final class org/dexpace/sdk/transport/okhttp/OkHttpTransport : org/dexpac public static final fun create (Lokhttp3/OkHttpClient;)Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport; public static final fun create (Lokhttp3/OkHttpClient;Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;)Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport; 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 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/transport/okhttp/OkHttpTransport$Builder : org/dexpace/sdk/core/generics/Builder { @@ -17,6 +19,7 @@ public final class org/dexpace/sdk/transport/okhttp/OkHttpTransport$Builder : or public final fun droppedHeaderLogging (Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;)Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport$Builder; public final fun followRedirects (Z)Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport$Builder; public final fun proxy (Lorg/dexpace/sdk/core/util/ProxyOptions;)Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport$Builder; + public final fun proxyFromEnvironment ()Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport$Builder; public final fun readTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport$Builder; public final fun writeTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/transport/okhttp/OkHttpTransport$Builder; } diff --git a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt index 8e99ba25..0d5ddb90 100644 --- a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt +++ b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt @@ -15,7 +15,9 @@ import okhttp3.OkHttpClient import org.dexpace.sdk.core.client.AsyncHttpClient 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 org.dexpace.sdk.core.http.response.exception.NetworkException import org.dexpace.sdk.core.instrumentation.ClientLogger import org.dexpace.sdk.core.instrumentation.DroppedHeaderLogging import org.dexpace.sdk.core.util.ProxyOptions @@ -26,6 +28,7 @@ import java.io.InterruptedIOException import java.net.Proxy import java.net.ProxySelector import java.net.SocketAddress +import java.net.SocketTimeoutException import java.net.URI import java.time.Duration import java.util.concurrent.CompletableFuture @@ -80,20 +83,38 @@ public class OkHttpTransport private constructor( private val closed: AtomicBoolean = AtomicBoolean(false) /** - * Synchronously executes [request] on the caller's thread. Honours `Thread.interrupt` - * via `InterruptedIOException`: the interrupt flag is re-asserted before the - * exception is rethrown so callers see a consistent interrupt state. + * Synchronously executes [request] on the caller's thread. Delegates to the per-call overload + * with [RequestOptions.EMPTY], so the configured client timeouts apply unchanged. */ @Throws(IOException::class) - override fun execute(request: Request): Response { + override fun execute(request: Request): Response = execute(request, RequestOptions.EMPTY) + + /** + * Synchronously executes [request] with per-call [options] on the caller's thread. When + * [RequestOptions.timeout] is non-null it is applied as OkHttp's per-call timeout (connect + + * write + read + handshake) for this call only, overriding the client's configured + * `callTimeout`; a null timeout leaves the client default in force. + * + * OkHttp surfaces both a genuine thread interrupt and a plain timeout as an + * `InterruptedIOException` (a `SocketTimeoutException` is one; the call-timeout path throws + * `InterruptedIOException("timeout")`). The two are told apart by the caller thread's interrupt + * flag: a genuine interrupt (flag set) re-asserts the flag and rethrows the + * `InterruptedIOException` as a terminal cancellation, whereas a timeout (flag clear) surfaces + * as a retryable [NetworkException] and does NOT set the interrupt flag. + */ + @Throws(IOException::class) + override fun execute( + request: Request, + options: RequestOptions, + ): Response { val okRequest = requestAdapter.adapt(request) val call = client.newCall(okRequest) + applyCallTimeout(call, options) val okResponse = try { call.execute() - } catch (e: InterruptedIOException) { - Thread.currentThread().interrupt() - throw e + } catch (e: IOException) { + throw classifyExecuteFailure(e) } // ResponseAdapter.adapt acquires the body source and constructs the SDK Response; if any // step throws (e.g. `Io.provider` not installed, an unparseable Content-Type) it closes @@ -101,14 +122,54 @@ public class OkHttpTransport private constructor( return responseAdapter.adapt(request, okResponse) } + /** + * Maps a synchronous [execute] transport failure to the exception the caller should observe. + * + * OkHttp collapses a genuine thread interrupt and a plain timeout into the same exception type: + * [SocketTimeoutException] extends [InterruptedIOException], and the call-timeout path throws + * `InterruptedIOException("timeout")`. Message matching is fragile, so the two are told apart by + * the caller thread's interrupt flag — a real interrupt leaves it set, whereas a timeout (a + * per-call/read/connect deadline, or the call-timeout watchdog cancelling the call) does not, and + * a [SocketTimeoutException] is unambiguously a timeout. + * + * A genuine interrupt re-asserts the flag and is surfaced as the terminal [InterruptedIOException] + * (the retry classifier treats it as cancellation). Everything else — a timeout, or a non-interrupt + * transport failure (connection refused, DNS failure, peer reset, TLS handshake failure) — produced + * no response and is surfaced as the retryable [NetworkException] (itself an [IOException], so + * existing `catch (IOException)` sites keep matching) without leaving a spurious interrupt flag, + * matching how the JDK transport wraps its `HttpTimeoutException`. + */ + private fun classifyExecuteFailure(e: IOException): IOException { + if (e is InterruptedIOException && e !is SocketTimeoutException && Thread.currentThread().isInterrupted) { + Thread.currentThread().interrupt() + return e + } + return NetworkException(e.message, e) + } + /** * Asynchronously executes [request]. Returns a [CompletableFuture] that completes with * the [Response] on success or completes exceptionally with the transport failure on * error — including a request-adaptation failure, which runs on the calling thread but is * delivered through the future rather than thrown synchronously. Cancelling the future - * cancels the underlying OkHttp [Call]. + * cancels the underlying OkHttp [Call]. Delegates to the per-call overload with + * [RequestOptions.EMPTY], so the configured client timeouts apply unchanged. */ - override fun executeAsync(request: Request): CompletableFuture { + override fun executeAsync(request: Request): CompletableFuture = + executeAsync(request, RequestOptions.EMPTY) + + /** + * Asynchronously executes [request] with per-call [options]. When [RequestOptions.timeout] is + * non-null it is applied as OkHttp's per-call timeout for this call only, overriding the + * client's configured `callTimeout`; a null timeout leaves the client default in force. + * Otherwise identical to [executeAsync]: the returned [CompletableFuture] completes with the + * [Response] on success or exceptionally with the transport failure, and cancelling it cancels + * the underlying OkHttp [Call]. + */ + override fun executeAsync( + request: Request, + options: RequestOptions, + ): CompletableFuture { val okRequest = try { requestAdapter.adapt(request) @@ -128,6 +189,7 @@ public class OkHttpTransport private constructor( // dispatch failure (including a `RejectedExecutionException` from a shut-down dispatcher) // is delivered through `Callback.onFailure` below, so it already reaches the future. val call = client.newCall(okRequest) + applyCallTimeout(call, options) val future = CompletableFuture() call.enqueue( object : Callback { @@ -156,7 +218,33 @@ public class OkHttpTransport private constructor( call: Call, e: IOException, ) { - future.completeExceptionally(e) + // A cancellation that originates INSIDE OkHttp — dispatcher().cancelAll(), or an + // interceptor/EventListener calling call.cancel() — surfaces here as a bare + // IOException("Canceled") while the SDK future is still live. Wrapping that as a + // retryable NetworkException would have DefaultAsyncRetryStep re-send a + // deliberately cancelled request, so complete with a non-retryable cancellation + // instead: an InterruptedIOException (an IOException subtype, so catch(IOException) + // sites keep matching) that the retry step's interrupt carve-out treats as + // cancellation rather than a retryable failure. It is NOT wrapped as a + // NetworkException. + // + // The `!is InterruptedIOException` guard separates that genuine cancellation from a + // call-timeout: OkHttp's call-timeout watchdog ALSO cancels the call (so + // isCanceled() is true), but it wraps the failure via timeoutExit() into + // `InterruptedIOException("timeout")`, whereas an external cancel surfaces as a plain + // IOException. A timeout produced no response and must stay a retryable + // NetworkException — matching the sync path and the JDK transport — so it falls to + // the else branch along with every other no-response failure (connection/DNS/TLS + // error). Consumer-driven future cancellation is handled separately by call.cancel() + // below, after the future is already cancelled, so that exception is discarded in + // the race rather than observed. + if (call.isCanceled() && e !is InterruptedIOException) { + val cancelled = InterruptedIOException(e.message ?: "Canceled") + cancelled.initCause(e) + future.completeExceptionally(cancelled) + } else { + future.completeExceptionally(asNetworkException(e)) + } } }, ) @@ -176,6 +264,28 @@ public class OkHttpTransport private constructor( private fun failedFuture(t: Throwable): CompletableFuture = CompletableFuture().apply { completeExceptionally(t) } + /** + * Applies [RequestOptions.timeout], when present, as the per-[call] timeout for this exchange. + * `Call.timeout()` is OkHttp's per-call handle (an okio `Timeout`); setting it before dispatch + * overrides the client's configured `callTimeout` for this call only and leaves the client + * untouched, so a slow endpoint can widen (or a sensitive one tighten) its budget without a + * second client. A null timeout is a no-op, keeping the client default in force. + */ + private fun applyCallTimeout( + call: Call, + options: RequestOptions, + ) { + options.timeout?.let { + // okio's Timeout treats 0 as "no timeout", so a positive sub-millisecond Duration would + // truncate to 0ms via toMillis() and silently DISABLE the per-call timeout (also + // overriding any client-configured callTimeout with infinity). Clamp to at least 1ms so + // a sub-ms budget still yields a finite deadline. Zero/negative Durations are rejected + // upstream at RequestOptions construction, so only the sub-ms-positive case needs + // clamping here. + call.timeout().timeout(maxOf(1L, it.toMillis()), TimeUnit.MILLISECONDS) + } + } + /** * Best-effort close on a discard path. Used when an adapted [Response] loses the race to a * cancelled future: the response is already being discarded, so a failure to close it has @@ -286,6 +396,17 @@ public class OkHttpTransport private constructor( * * Note: `followRedirects` defaults to `false` because the SDK has `DefaultRedirectStep` * — letting OkHttp follow redirects underneath would double-handle them. + * + * ## Timeout defaults differ from the JDK transport + * + * When no timeout is configured here, the underlying [OkHttpClient] keeps OkHttp's own + * library defaults — notably a ~10 s socket read timeout. A default-built + * `JdkHttpTransport`, by contrast, applies a 30 s per-request response timeout. The same + * pipeline code therefore has a materially different response budget depending on which + * transport backs it. If you rely on default construction, set [readTimeout] (and, if you + * want a single overall budget, [callTimeout]) explicitly to get parity with the JDK + * transport. Runtime defaults are intentionally left as OkHttp's; only this documentation + * calls out the divergence. */ public class Builder internal constructor() : SdkBuilder { private var connectTimeout: Duration? = null @@ -302,7 +423,12 @@ public class OkHttpTransport private constructor( this.connectTimeout = d } - /** Sets the socket read timeout (longest gap between bytes arriving). */ + /** + * Sets the socket read timeout (longest gap between bytes arriving). Left unset, OkHttp's + * ~10 s library default applies — shorter than the JDK transport's 30 s default response + * timeout, so set this explicitly for a consistent response budget across transports (see + * the [Builder] class KDoc). + */ public fun readTimeout(d: Duration): Builder = apply { this.readTimeout = d @@ -328,6 +454,20 @@ public class OkHttpTransport private constructor( this.proxy = p } + /** + * Reads proxy settings from the process-wide environment via [ProxyOptions.fromEnvironment] + * and applies the resolved proxy to the client being built. See that method for the exact + * environment-variable / system-property resolution. + * + * Strictly opt-in: the environment is never consulted unless this is called, and the call + * is a no-op when nothing there configures a proxy (or when `NO_PROXY=*` bypasses it). This + * writes the same slot as [proxy], so whichever of the two runs last wins. + */ + public fun proxyFromEnvironment(): Builder = + apply { + ProxyOptions.fromEnvironment()?.let { this.proxy = it } + } + /** * Whether OkHttp should follow 3xx redirects automatically. Defaults to `false` * because the SDK pipeline owns redirect handling via `DefaultRedirectStep`. @@ -471,3 +611,23 @@ public class OkHttpTransport private constructor( } } } + +/** + * Maps an OkHttp transport-level [IOException] onto the SDK's [NetworkException] contract for the + * async path. The failure arrives on OkHttp's dispatcher thread rather than the caller's, and every + * failure OkHttp reports through `Callback.onFailure` — a connection/DNS/TLS error, a + * [SocketTimeoutException] read/connect deadline, or the call-timeout watchdog's bare + * `InterruptedIOException("timeout")` — is a transport failure that produced no response. All are + * wrapped as a retryable [NetworkException] (itself an [IOException], so `catch (IOException)` sites + * keep matching), so async timeouts retry exactly like the sync path and the JDK transport. + * + * There is no genuine-interrupt case to preserve here as there is on the sync path: the SDK never + * interrupts OkHttp's dispatcher threads (`close()` uses `shutdown`, not `shutdownNow`). Two + * cancellation shapes are handled by the `onFailure` caller before this function is reached: + * consumer-driven future cancellation is delivered via `call.cancel()` — surfacing as a plain + * `IOException("Canceled")` after the future is already cancelled, so the exception is discarded in + * that race rather than observed by a caller — and an OkHttp-internal cancellation (`cancelAll()` or + * an interceptor's `call.cancel()`, where the future is still live) is completed as a non-retryable + * `InterruptedIOException` in `onFailure`. Only a genuine no-response failure reaches this function. + */ +private fun asNetworkException(e: IOException): IOException = NetworkException(e.message, e) diff --git a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/FileRequestBodyAdapter.kt b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/FileRequestBodyAdapter.kt index b700c216..6ac6cc44 100644 --- a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/FileRequestBodyAdapter.kt +++ b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/FileRequestBodyAdapter.kt @@ -30,13 +30,22 @@ import org.dexpace.sdk.core.http.request.FileRequestBody * and each write opens a fresh handle positioned at [position]. * * [contentLength] is the body's [count] (the exact number of bytes that will be written). - * [contentType] is derived from the body's [org.dexpace.sdk.core.http.common.MediaType]; an - * unparseable value yields `null` and OkHttp emits no `Content-Type`. + * [contentType] is the caller's explicit request `Content-Type` header ([explicitContentType]) + * when one was set, otherwise the body's [org.dexpace.sdk.core.http.common.MediaType]; an + * unparseable value yields `null` and OkHttp emits no `Content-Type`. Reporting the explicit + * header here keeps it authoritative over OkHttp's `BridgeInterceptor`, which otherwise overwrites + * the request's `Content-Type` with `body.contentType()` — see [SdkRequestBodyAdapter]. */ internal class FileRequestBodyAdapter( private val sdkBody: FileRequestBody, + private val explicitContentType: String? = null, ) : RequestBody() { - override fun contentType(): okhttp3.MediaType? = sdkBody.mediaType()?.toString()?.toMediaTypeOrNull() + override fun contentType(): okhttp3.MediaType? = + if (explicitContentType != null) { + explicitContentType.toMediaTypeOrNull() + } else { + sdkBody.mediaType()?.toString()?.toMediaTypeOrNull() + } override fun contentLength(): Long = sdkBody.count diff --git a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt index ca96e771..39282e5f 100644 --- a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt +++ b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt @@ -118,9 +118,16 @@ internal class RequestAdapter( } val methodToken = request.method.method val body = request.body + // Read the caller's explicit Content-Type (case-insensitively) so it can be made + // authoritative over the body's own media type. OkHttp's BridgeInterceptor overwrites the + // request's Content-Type with body.contentType() when the latter is non-null, so an + // explicit vendor Content-Type would otherwise be silently replaced by the auto-stamped + // body media type; threading it into the body adapter's contentType() keeps the caller's + // value winning, matching the JDK transport. + val explicitContentType = request.headers.get("Content-Type") val okhttpBody = when { - body != null -> toOkHttpBody(body) + body != null -> toOkHttpBody(body, explicitContentType) // OkHttp rejects a null body for the methods it treats as requiring one. The // SDK allows a body-less request for any method, so substitute an empty body // here rather than letting Request.Builder.method throw. @@ -146,12 +153,16 @@ internal class RequestAdapter( * Selects the OkHttp [RequestBody] adapter for an SDK body. A [FileRequestBody] streams * zero-copy via [FileRequestBodyAdapter] (okio `FileHandle` → OkHttp's `BufferedSink`, * honouring `position`/`count`); every other body shape uses the generic - * [SdkRequestBodyAdapter]. + * [SdkRequestBodyAdapter]. [explicitContentType], when non-null, is the caller's explicit + * request `Content-Type` header and is made authoritative by the adapter's `contentType()`. */ - private fun toOkHttpBody(body: SdkRequestBody): RequestBody = + private fun toOkHttpBody( + body: SdkRequestBody, + explicitContentType: String?, + ): RequestBody = when (body) { - is FileRequestBody -> FileRequestBodyAdapter(body) - else -> SdkRequestBodyAdapter(body) + is FileRequestBody -> FileRequestBodyAdapter(body, explicitContentType) + else -> SdkRequestBodyAdapter(body, explicitContentType) } private companion object { diff --git a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/SdkRequestBodyAdapter.kt b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/SdkRequestBodyAdapter.kt index ea42fd37..e7d34fab 100644 --- a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/SdkRequestBodyAdapter.kt +++ b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/SdkRequestBodyAdapter.kt @@ -23,10 +23,15 @@ import org.dexpace.sdk.core.http.request.RequestBody as SdkRequestBody * provider's [org.dexpace.sdk.core.io.BufferedSink] surface. Bytes never leave the * streaming path — the OutputStream wrapper is a thin shim around the okio sink. * - * [contentLength] is taken straight from the SDK body (`-1` if unknown). [contentType] is - * derived from the SDK body's [org.dexpace.sdk.core.http.common.MediaType] via - * `toString()` → `okhttp3.MediaType` parse; an unparseable string returns `null` and OkHttp - * falls back to no `Content-Type`. + * [contentLength] is taken straight from the SDK body (`-1` if unknown). [contentType] is the + * caller's explicit request `Content-Type` header ([explicitContentType]) when one was set, + * otherwise the SDK body's [org.dexpace.sdk.core.http.common.MediaType]; either way it is parsed + * via `toString()` → `okhttp3.MediaType`, and an unparseable string returns `null`. Reporting the + * explicit header here is what keeps it authoritative: OkHttp's `BridgeInterceptor` overwrites the + * request's `Content-Type` with `body.contentType()` when the latter is non-null, so without this + * the auto-stamped body media type would silently replace a caller-set vendor `Content-Type`. A + * `null` return (no explicit header AND no body media type, or an unparseable explicit header) + * leaves the caller's own header untouched. * * [isOneShot] mirrors the SDK body's replayability: it returns `true` for a non-replayable * body so OkHttp will not re-write it on a connection-failure or auth (401/407) follow-up. @@ -37,8 +42,14 @@ import org.dexpace.sdk.core.http.request.RequestBody as SdkRequestBody */ internal class SdkRequestBodyAdapter( private val sdkBody: SdkRequestBody, + private val explicitContentType: String? = null, ) : RequestBody() { - override fun contentType(): okhttp3.MediaType? = sdkBody.mediaType()?.toString()?.toMediaTypeOrNull() + override fun contentType(): okhttp3.MediaType? = + if (explicitContentType != null) { + explicitContentType.toMediaTypeOrNull() + } else { + sdkBody.mediaType()?.toString()?.toMediaTypeOrNull() + } override fun contentLength(): Long = sdkBody.contentLength() diff --git a/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt b/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt index a3f41922..62f3ab4c 100644 --- a/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt +++ b/sdk-transport-okhttp/src/test/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransportTest.kt @@ -14,14 +14,17 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okio.Buffer import org.dexpace.sdk.core.auth.BasicChallengeHandler +import org.dexpace.sdk.core.config.Configuration import org.dexpace.sdk.core.http.common.CommonMediaTypes import org.dexpace.sdk.core.http.common.Protocol import org.dexpace.sdk.core.http.request.FileRequestBody import org.dexpace.sdk.core.http.request.Method import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.request.RequestBody +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.dexpace.sdk.core.http.response.exception.NetworkException import org.dexpace.sdk.core.instrumentation.DroppedHeaderLogging import org.dexpace.sdk.core.io.Io import org.dexpace.sdk.core.util.ProxyOptions @@ -508,17 +511,197 @@ class OkHttpTransportTest { OkHttpTransport.builder() .readTimeout(Duration.ofMillis(200)) .build() + // A read timeout arrives as a SocketTimeoutException (an InterruptedIOException subtype), + // but it is a timeout, not a cancellation: the transport surfaces it as a retryable + // NetworkException and does not set the caller thread's interrupt flag. + assertFailsWith { + shortReadTransport.execute(simpleGet("/slow")).close() + } + assertFalse( + Thread.currentThread().isInterrupted, + "a timeout must not leave the caller thread's interrupt flag set", + ) + // Let MockWebServer's pending dispatch drain before @StartStop shuts it down. + Thread.sleep(900) + } + + // -------- per-call RequestOptions.timeout -------- + + @Test + fun perCallTimeoutFiresOnSlowResponse() { + // A short per-call timeout must bound a slow exchange even though the default-built + // transport imposes no callTimeout of its own. The headers delay comfortably exceeds the + // 200ms per-call budget so the timeout fires. + server.enqueue( + MockResponse.Builder() + .code(200) + .body("late") + .headersDelay(800, TimeUnit.MILLISECONDS) + .build(), + ) + val options = RequestOptions.builder().timeout(Duration.ofMillis(200)).build() + // A timeout is a retryable transport failure, not a cancellation: it surfaces as a + // NetworkException (an IOException subtype) so DefaultRetryStep re-attempts it. val ex = + assertFailsWith { + transport.execute(simpleGet("/slow-percall"), options).close() + } + assertTrue(ex.isRetryable, "a timeout must be retryable") + // The timeout must NOT set the caller thread's interrupt flag; only a genuine interrupt + // does. (This is why the previous `Thread.interrupted()` workaround is no longer needed.) + assertFalse( + Thread.currentThread().isInterrupted, + "a timeout must not leave the caller thread's interrupt flag set", + ) + // Let MockWebServer's pending dispatch drain before @StartStop shuts it down. + Thread.sleep(900) + } + + @Test + fun subMillisPerCallTimeoutIsClampedNotDisabled() { + // A positive sub-millisecond per-call timeout truncates to 0ms via Duration.toMillis(), and + // okio's Timeout treats 0 as "no timeout". Without clamping this would silently DISABLE the + // per-call timeout and let the slow exchange run to completion. The transport clamps the + // applied deadline to at least 1ms, so the timeout stays finite and fires on the slow + // response rather than being disabled. + server.enqueue( + MockResponse.Builder() + .code(200) + .body("late") + .headersDelay(800, TimeUnit.MILLISECONDS) + .build(), + ) + // 500_000 ns = 0.5 ms → toMillis() == 0, the truncation the clamp exists to defend against. + val subMillis = RequestOptions.builder().timeout(Duration.ofNanos(500_000)).build() + // The clamped-but-finite timeout must still fire, surfacing as a retryable NetworkException. + val ex = + assertFailsWith { + transport.execute(simpleGet("/sub-ms-timeout"), subMillis).close() + } + assertTrue(ex.isRetryable, "a timeout must be retryable") + assertFalse( + Thread.currentThread().isInterrupted, + "a timeout must not leave the caller thread's interrupt flag set", + ) + // Let MockWebServer's pending dispatch drain before @StartStop shuts it down. + Thread.sleep(900) + } + + @Test + fun perCallTimeoutHonouredWhenGenerousAndAbsent() { + // Contrast for perCallTimeoutFiresOnSlowResponse: the SAME per-call mechanism with a + // generous budget does NOT prematurely fail a moderately slow response, proving the + // configured duration is honoured rather than a fixed tiny default. A second request with + // EMPTY options (no per-call override) also succeeds — the no-override path is unchanged. + server.enqueue( + MockResponse.Builder() + .code(200) + .body("on-time") + .headersDelay(150, TimeUnit.MILLISECONDS) + .build(), + ) + val generous = RequestOptions.builder().timeout(Duration.ofSeconds(10)).build() + transport.execute(simpleGet("/generous-percall"), generous).use { response -> + assertEquals(200, response.status.code) + assertEquals("on-time", response.body?.source()?.readUtf8()) + } + + server.enqueue(MockResponse.Builder().code(200).body("empty-opts").build()) + transport.execute(simpleGet("/empty-opts"), RequestOptions.EMPTY).use { response -> + assertEquals(200, response.status.code) + assertEquals("empty-opts", response.body?.source()?.readUtf8()) + } + } + + @Test + fun perCallTimeoutFiresOnSlowResponseAsync() { + // Async mirror of perCallTimeoutFiresOnSlowResponse: the per-call timeout is applied to the + // enqueued OkHttp call, so the returned future completes exceptionally. + server.enqueue( + MockResponse.Builder() + .code(200) + .body("late") + .headersDelay(800, TimeUnit.MILLISECONDS) + .build(), + ) + val options = RequestOptions.builder().timeout(Duration.ofMillis(200)).build() + val future = transport.executeAsync(simpleGet("/slow-percall-async"), options) + val ex = assertFails { future.join() } + // join() wraps the failure in CompletionException. The async path must surface an OkHttp + // call-timeout as a retryable NetworkException, mirroring the sync path and the JDK transport. + assertTrue(ex is CompletionException, "expected CompletionException, got ${ex::class}") + val cause = ex.cause + assertTrue(cause is NetworkException, "expected NetworkException cause, got ${cause?.let { it::class }}") + assertTrue(cause.isRetryable, "an async timeout must be retryable") + Thread.sleep(900) + } + + // -------- transport error contract: NetworkException -------- + + @Test + fun connectionFailureSurfacesAsNetworkExceptionSync() { + // A refused TCP connect (a freed-then-closed local port) is a transport-level IOException + // with no response on the wire. The transport must surface it as the SDK's + // NetworkException — an IOException subtype, so existing `catch (IOException)` sites keep + // matching — and it must be retryable. + val deadPort = freePort() + val request = + Request.builder() + .method(Method.GET) + .url(URL("http://127.0.0.1:$deadPort/")) + .build() + val ex = + assertFailsWith { + transport.execute(request).close() + } + assertTrue(ex.isRetryable, "a transport-level failure must be retryable") + } + + @Test + fun connectionFailureSurfacesAsNetworkExceptionAsync() { + val deadPort = freePort() + val request = + Request.builder() + .method(Method.GET) + .url(URL("http://127.0.0.1:$deadPort/")) + .build() + val future = transport.executeAsync(request) + val ex = assertFailsWith { future.get(5, TimeUnit.SECONDS) } + val cause = ex.cause + assertTrue(cause is NetworkException, "expected NetworkException, got ${cause?.let { it::class }}") + assertTrue(cause.isRetryable, "a transport-level failure must be retryable") + } + + @Test + fun interruptIsNotWrappedAsNetworkException() { + // A genuine thread interrupt must remain terminal: it surfaces as an InterruptedIOException + // (with the interrupt flag re-asserted), never repackaged as a retryable NetworkException. + // OkHttp collapses interrupts and timeouts into InterruptedIOException, so the transport + // discriminates on the caller thread's interrupt flag; the interceptor sets that flag to + // model a real interrupt (a timeout would leave it clear and be wrapped as a + // NetworkException instead). + val interceptedTransport = + OkHttpTransport.create( + OkHttpClient.Builder() + .addInterceptor { + Thread.currentThread().interrupt() + throw java.io.InterruptedIOException("simulated interrupt") + } + .build(), + ) + val captured = assertFails { - shortReadTransport.execute(simpleGet("/slow")).close() + interceptedTransport.execute(simpleGet("/never-served")) } - assertTrue(ex is IOException, "expected IOException, got ${ex::class}") - // The transport's `execute` preserves the interrupt status when surfacing an - // `InterruptedIOException`. OkHttp's SocketTimeoutException path on JDK 25 leaves - // the thread's interrupt flag set; clear it before sleeping so MockWebServer's - // pending dispatch has time to drain without an InterruptedException. + assertTrue( + captured is java.io.InterruptedIOException, + "interrupt must surface as InterruptedIOException, got ${captured::class}", + ) + // An InterruptedIOException is disjoint from NetworkException (sibling IOException + // subtypes), so surfacing the former proves the interrupt was NOT wrapped as the latter. + // The transport re-asserts the interrupt flag on this thread; clear it so it does not + // leak into later tests sharing the JUnit worker thread. Thread.interrupted() - Thread.sleep(900) } // -------- cancellation -------- @@ -529,15 +712,17 @@ class OkHttpTransportTest { // `InterruptedIOException`, the calling thread's interrupt status is preserved. // OkHttp 5.x's default transport uses plain `java.net.Socket` rather than // `SocketChannel`, so `Thread.interrupt()` does NOT in general abort a pending - // socket read — we therefore inject an `InterruptedIOException` via a custom - // OkHttp interceptor (the same hook that real callers would use to wire - // cancellation into their own stack) and verify that our wrapper re-asserts + // socket read — we therefore model a genuine interrupt via a custom OkHttp + // interceptor that sets the thread's interrupt flag before throwing an + // `InterruptedIOException` (the flag is how the transport tells a real interrupt + // apart from a timeout), and verify that our wrapper re-asserts // `Thread.currentThread().interrupt()` before rethrowing. val interceptedTransport = OkHttpTransport.create( OkHttpClient.Builder() .addInterceptor( Interceptor { + Thread.currentThread().interrupt() throw java.io.InterruptedIOException("simulated interrupt") }, ) @@ -719,6 +904,80 @@ class OkHttpTransportTest { ) } + // -------- proxy: opt-in proxyFromEnvironment() -------- + + @Test + fun proxyFromEnvironmentAppliesConfiguredProxy() { + // With HTTPS_PROXY pointed at MockWebServer, proxyFromEnvironment() must apply it: a + // request to an unresolvable `.example` host still reaches MockWebServer (proving the + // forward proxy is in play) and arrives in absolute-form request-target. + val previous = Configuration.getGlobalConfiguration() + try { + Configuration.setGlobalConfiguration( + Configuration.builder() + .envSource { null } + .propsSource { null } + .put(Configuration.HTTPS_PROXY, "http://${server.hostName}:${server.port}") + .build(), + ) + server.enqueue(MockResponse.Builder().code(200).body("via-proxy").build()) + OkHttpTransport.builder().proxyFromEnvironment().build().use { proxied -> + val request = + Request.builder() + .method(Method.GET) + .url(URL("http://downstream.example/resource")) + .build() + proxied.execute(request).use { response -> + assertEquals( + 200, + response.status.code, + "request routed through the env-configured proxy must succeed", + ) + assertEquals("via-proxy", response.body?.source()?.readUtf8()) + } + } + val recorded = server.takeRequest() + assertEquals( + "http://downstream.example/resource", + recorded.target, + "request must have been forwarded via the env-configured proxy in absolute form", + ) + } finally { + Configuration.setGlobalConfiguration(previous) + } + } + + @Test + fun proxyFromEnvironmentIsOptIn() { + // The very same global config carries a proxy, but a builder that does NOT call + // proxyFromEnvironment() must ignore it: a direct request lands on MockWebServer with an + // origin-form target, proving no forward proxy was inserted. + val previous = Configuration.getGlobalConfiguration() + try { + Configuration.setGlobalConfiguration( + Configuration.builder() + .envSource { null } + .propsSource { null } + .put(Configuration.HTTPS_PROXY, "http://${server.hostName}:${server.port}") + .build(), + ) + server.enqueue(MockResponse.Builder().code(200).body("direct").build()) + OkHttpTransport.builder().build().use { plain -> + plain.execute(simpleGet("/direct")).use { response -> + assertEquals(200, response.status.code) + } + } + val recorded = server.takeRequest() + assertEquals( + "/direct", + recorded.target, + "an opt-out transport must issue an origin-form request (no proxy applied)", + ) + } finally { + Configuration.setGlobalConfiguration(previous) + } + } + // -------- redirect behaviour -------- @Test