Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
|---|---|
| `client` | `HttpClient`, `AsyncHttpClient` — the two transport SPIs (sync and async). |
| `http.request` | `Request`, `RequestBody`, `FileRequestBody`, `LoggableRequestBody`, `Method`. |
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), `HttpResponseException`. |
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), `HttpResponseException`, plus the raw-vs-parsed seam: `ResponseHandler<T>` (with dep-free `string()`/`empty()` handlers) and a lazy, parse-once `ParsedResponse<T>`. |
| `http.response.exception` | Typed `HttpException` hierarchy (`BadRequestException`, `RequestTimeoutException`, `TooManyRequestsException`, `ServiceUnavailableException`, …) with `retryable` derived from `RetryUtils.isRetryable`, plus `NetworkException` and `HttpExceptionFactory`. |
| `http.common` | `Headers`, `HttpHeaderName` (interned), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
| `http.context` | `CallContext` → `DispatchContext` → `RequestContext` → `ExchangeContext` chain, `ContextStore`. |
Expand All @@ -258,7 +258,7 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
| `http.paging` | `PagedIterable<T>`, `PagedResponse<T>`, `PagingOptions` with `byPage()` and `stream()` accessors. |
| `pagination` | `Paginator<T>` (with a `maxPages` safety cap) over cursor / page-number / link-header `PaginationStrategy` implementations, plus `Page<T>` / `SimplePage<T>`. |
| `pipeline` | Recovery-aware primitives: `RequestPipeline`, `ResponsePipeline`, `ExecutionPipeline` over a sealed `ResponseOutcome`, with steps (`pipeline.step`, `pipeline.step.retry`) like `RetryStep`, `ResponseRecoveryStep`, `IdempotencyKeyStep`, `ClientIdentityStep`. |
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions and `Tristate<T>` (absent / null / present). |
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions, `Tristate<T>` (absent / null / present), and `SerdeException` (the unchecked failure adapters translate codec errors into). |
| `io` | `Source`, `Sink`, `Buffer`, `BufferedSource`, `BufferedSink`, `IoProvider`, `Io`, `TeeSink`. |
| `instrumentation` | `ClientLogger` (zero-alloc disabled path), `LoggingEvent`, `UrlRedactor`, `Tracer` / `NoopTracer`, `Span` / `NoopSpan`, `InstrumentationContext`. |
| `instrumentation.metrics` | `Meter`, `LongCounter`, `DoubleHistogram`, `NoopMeter`. |
Expand Down
33 changes: 33 additions & 0 deletions sdk-core/api/sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,27 @@ public final class org/dexpace/sdk/core/http/response/LoggableResponseBody : org
public fun source ()Lorg/dexpace/sdk/core/io/BufferedSource;
}

public final class org/dexpace/sdk/core/http/response/ParsedResponse : java/io/Closeable {
public static final field Companion Lorg/dexpace/sdk/core/http/response/ParsedResponse$Companion;
public fun close ()V
public final fun getHeaders ()Lorg/dexpace/sdk/core/http/common/Headers;
public final fun getMessage ()Ljava/lang/String;
public final fun getProtocol ()Lorg/dexpace/sdk/core/http/common/Protocol;
public final fun getRaw ()Lorg/dexpace/sdk/core/http/response/Response;
public final fun getRequest ()Lorg/dexpace/sdk/core/http/request/Request;
public final fun getStatus ()Lorg/dexpace/sdk/core/http/response/Status;
public static final fun of (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/http/response/ResponseHandler;)Lorg/dexpace/sdk/core/http/response/ParsedResponse;
public final fun value ()Ljava/lang/Object;
}

public final class org/dexpace/sdk/core/http/response/ParsedResponse$Companion {
public final fun of (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/http/response/ResponseHandler;)Lorg/dexpace/sdk/core/http/response/ParsedResponse;
}

public final class org/dexpace/sdk/core/http/response/ParsedResponseKt {
public static final fun parsedWith (Lorg/dexpace/sdk/core/http/response/Response;Lorg/dexpace/sdk/core/http/response/ResponseHandler;)Lorg/dexpace/sdk/core/http/response/ParsedResponse;
}

public final class org/dexpace/sdk/core/http/response/Response : java/io/Closeable {
public static final field Companion Lorg/dexpace/sdk/core/http/response/Response$Companion;
public synthetic fun <init> (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/common/Protocol;Lorg/dexpace/sdk/core/http/response/Status;Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/Headers;Lorg/dexpace/sdk/core/http/response/ResponseBody;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down Expand Up @@ -1218,6 +1239,18 @@ 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 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;
public abstract fun handle (Lorg/dexpace/sdk/core/http/response/Response;)Ljava/lang/Object;
public static fun string ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
}

public final class org/dexpace/sdk/core/http/response/ResponseHandler$Companion {
public final fun empty ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
public final fun string ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
}

public final class org/dexpace/sdk/core/http/response/Status {
public static final field ACCEPTED Lorg/dexpace/sdk/core/http/response/Status;
public static final field ALREADY_REPORTED Lorg/dexpace/sdk/core/http/response/Status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* 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.Protocol
import org.dexpace.sdk.core.http.request.Request
import java.io.Closeable
import java.io.IOException
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

/**
* Pairs a raw [Response] with a [ResponseHandler] so the typed value can be parsed **lazily and
* exactly once**, while the raw status / headers / metadata stay readable without forcing
* deserialization.
*
* This is the raw-vs-parsed seam: header and status access (e.g. reading `ETag`, branching on a
* `404`) goes straight to the underlying response and never touches the body, whereas the typed
* value is produced on demand by the handler. Because the body is single-use, [value] memoizes
* the handler's outcome — the first call runs the handler and every subsequent call returns the
* same result (or re-throws the same failure) without re-invoking the handler or re-reading the
* body.
*
* ## Body consumption
*
* The handler owns the body. Calling [value] runs the handler, which typically consumes and
* closes the body (the built-in [ResponseHandler.string] / [ResponseHandler.empty] and adapter
* JSON handlers do). **Read any raw headers / status before the first [value] call**, since the
* body is gone afterwards. [close] is available for the path where the typed value is never
* needed and the body must still be released.
*
* ## Thread-safety
*
* Raw accessors are immutable and safe to share. [value] is guarded by a [ReentrantLock]
* (`synchronized` would pin a carrier thread under virtual threads): concurrent first calls block
* until the single parse completes and then all observe the same memoized result. A `null` result
* and a thrown failure are both memoized, so neither triggers a re-parse.
*
* @param T The typed value the handler produces.
* @param raw The underlying raw response. Header / status / metadata access reads from here.
* @param handler Strategy that maps [raw] to the typed value on first [value] access.
*/
public class ParsedResponse<out T> internal constructor(
public val raw: Response,
private val handler: ResponseHandler<T>,
) : Closeable {
private val lock = ReentrantLock()

// Holds the memoized outcome once the handler has run. A non-null holder means "parsed"
// (success or failure); the wrapped value distinguishes the two. A holder (rather than a
// bare value) lets a legitimately-null success memoize without being mistaken for "unparsed".
@Volatile
private var outcome: Outcome<T>? = null

/** The request that produced [raw]. Does not parse. */
public val request: Request get() = raw.request

/** The negotiated wire protocol. Does not parse. */
public val protocol: Protocol get() = raw.protocol

/** The HTTP status. Does not parse. */
public val status: Status get() = raw.status

/** The status-line reason phrase, or `null` if absent. Does not parse. */
public val message: String? get() = raw.message

/** The response headers. Does not parse. */
public val headers: Headers get() = raw.headers

/**
* Returns the typed value, parsing it on the first call and memoizing the outcome.
*
* The handler runs at most once: the first call invokes [ResponseHandler.handle] (which
* typically consumes and closes the body); subsequent calls return the same value, or
* re-throw the same failure, without re-running the handler.
*
* Any failure the handler throws is memoized and re-thrown verbatim on every later call — not
* just [IOException]. Handlers commonly throw **unchecked** exceptions (the Jackson `jsonHandler`
* throws `SerdeException`), so callers should not assume the only escape is [IOException].
*
* @return The parsed value (which may be `null` if the handler is typed `ResponseHandler<T?>`
* and produces `null`).
* @throws IOException If the handler failed with an [IOException] — cached and re-thrown. The
* `@Throws` declaration covers only the checked surface for Java callers; the handler may also
* propagate **unchecked** exceptions (e.g. `SerdeException` from the Jackson `jsonHandler`),
* which are memoized and re-thrown the same way.
*/
@Throws(IOException::class)
public fun value(): T {
outcome?.let { return it.get() }
return lock.withLock {
outcome?.let { return it.get() }
// Memoize the handler's outcome — success or failure — so neither re-runs the handler
// nor re-reads the (now consumed) body on a subsequent call.
val resolved: Outcome<T> =
try {
Outcome.Success(handler.handle(raw))
} catch (t: Throwable) {
// Catch Throwable, not Exception, on purpose: once the handler has touched the
// single-use body, re-running it would read an already-consumed stream. Even an
// Error (e.g. an OOM mid-parse) is memoized so a later call re-throws it rather
// than re-reading the body and masking the original failure.
Outcome.Failure(t)
}
outcome = resolved
resolved.get()
}
}

/**
* Releases the raw response body. Idempotent (forwards to [Response.close], which is itself
* idempotent). Safe to call whether or not [value] has run.
*
* @throws IOException If the underlying close fails.
*/
@Throws(IOException::class)
override fun close() {
raw.close()
}

private sealed class Outcome<out T> {
abstract fun get(): T

class Success<out T>(private val value: T) : Outcome<T>() {
override fun get(): T = value
}

class Failure(private val error: Throwable) : Outcome<Nothing>() {
override fun get(): Nothing = throw error
}
}

public companion object {
/**
* Creates a [ParsedResponse] that parses [response] with [handler] on first access.
* Java-friendly factory mirroring the Kotlin [Response.parsedWith] extension.
*
* @param response The raw response.
* @param handler Strategy that maps the response to the typed value.
* @return A lazily-parsing [ParsedResponse].
*/
@JvmStatic
public fun <T> of(
response: Response,
handler: ResponseHandler<T>,
): ParsedResponse<T> = ParsedResponse(response, handler)
}
}

/**
* Wraps this response in a [ParsedResponse] bound to [handler], so the typed value parses lazily
* and exactly once while raw status / headers stay accessible. Kotlin-ergonomic mirror of
* [ParsedResponse.of].
*
* @param handler Strategy that maps this response to the typed value.
* @return A lazily-parsing [ParsedResponse].
*/
public fun <T> Response.parsedWith(handler: ResponseHandler<T>): ParsedResponse<T> = ParsedResponse(this, handler)
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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.io.Io
import java.io.IOException
import java.nio.charset.StandardCharsets

/**
* Maps a raw [Response] to a typed result of type [T].
*
* A `ResponseHandler` is the seam generated service code dispatches against: the transport
* produces a raw [Response], and the handler decides how to turn it into a domain value — decode
* a JSON body into a DTO, read a body as text, or simply discard the body for an empty result.
*
* ## Body ownership
*
* A handler that reads the body **owns consuming and closing it**. [handle] is expected to leave
* the response closed when it returns (whether it read the body or not), so callers do not need a
* surrounding `use {}` block once they have delegated to a handler. The built-in [string] and
* [empty] handlers honor this. Because the body is single-use, a handler must read it at most
* once; pair a handler with [ParsedResponse] when the typed value must be exposed lazily and
* memoized so the body is consumed exactly once.
*
* ## Raw access first
*
* Reading the body is destructive, so any header / status inspection that must happen alongside
* parsing should read from the raw [Response] (or [ParsedResponse]'s raw accessors) **before**
* invoking the handler.
*
* ## Thread-safety
*
* Handlers are typically stateless and shared across requests; the built-in factories return
* stateless instances. A stateful handler must guard its own state.
*
* ## Nullability
*
* A handler that may legitimately produce `null` (e.g. an absent-but-valid payload) should be typed
* `ResponseHandler<T?>` so the nullability is visible to Kotlin and Java callers alike; otherwise a
* `null` slips through a non-null `T` as a platform value. [ParsedResponse.value] memoizes a `null`
* result correctly either way.
*
* @param T The typed result this handler produces.
*/
public fun interface ResponseHandler<out T> {
/**
* Consumes [response] and produces the typed result. Implementations that read the body must
* also close [response] before returning.
*
* @param response The raw response to map.
* @return The typed result.
* @throws IOException If reading the body fails.
*/
@Throws(IOException::class)
public fun handle(response: Response): T

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.
*
* **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.
* Unlike the bounded body-logging path elsewhere in the SDK, it applies no limit — use it
* only for trusted endpoints with bounded payloads, not for untrusted or large bodies.
*
* @return A stateless [String] handler.
*/
@JvmStatic
public fun string(): ResponseHandler<String> =
ResponseHandler { response ->
response.use {
val body = it.body ?: return@use ""
body.source().readString(StandardCharsets.UTF_8)
}
}

/**
* A handler that fully drains and closes the body, discarding its bytes, and returns
* [Unit]. Use for endpoints whose payload is irrelevant (e.g. a `DELETE` returning a
* status only) but whose connection must still be released.
*
* @return A stateless discarding handler.
*/
@JvmStatic
public fun empty(): ResponseHandler<Unit> =
ResponseHandler { response ->
response.use {
val body = it.body ?: return@use
val source = body.source()
// Pump into a throwaway scratch buffer (cleared each round) so the connection
// is released without materializing the whole body in memory. The buffer is
// closed deterministically so its segments are recycled even if the drain
// throws mid-stream, rather than leaning on the GC.
Io.provider.buffer().use { scratch ->
while (source.read(scratch, DRAIN_CHUNK_BYTES) != -1L) {
scratch.clear()
}
}
}
}

/**
* Per-read pump size for the discarding drain — a reasonable chunk size. `read` treats it
* as an upper bound, so the exact value is not load-bearing for correctness.
*/
private const val DRAIN_CHUNK_BYTES: Long = 8 * 1024
}
}
Loading
Loading