Skip to content

Commit 7b0138b

Browse files
authored
feat: add a ResponseHandler seam with lazy parse-once ParsedResponse (#96)
PR: #96
1 parent 96f02d5 commit 7b0138b

9 files changed

Lines changed: 816 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
247247
|---|---|
248248
| `client` | `HttpClient`, `AsyncHttpClient` — the two transport SPIs (sync and async). |
249249
| `http.request` | `Request`, `RequestBody`, `FileRequestBody`, `LoggableRequestBody`, `Method`. |
250-
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), `HttpResponseException`. |
250+
| `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>`. |
251251
| `http.response.exception` | Typed `HttpException` hierarchy (`BadRequestException`, `RequestTimeoutException`, `TooManyRequestsException`, `ServiceUnavailableException`, …) with `retryable` derived from `RetryUtils.isRetryable`, plus `NetworkException` and `HttpExceptionFactory`. |
252252
| `http.common` | `Headers`, `HttpHeaderName` (interned), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
253253
| `http.context` | `CallContext``DispatchContext``RequestContext``ExchangeContext` chain, `ContextStore`. |
@@ -258,7 +258,7 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
258258
| `http.paging` | `PagedIterable<T>`, `PagedResponse<T>`, `PagingOptions` with `byPage()` and `stream()` accessors. |
259259
| `pagination` | `Paginator<T>` (with a `maxPages` safety cap) over cursor / page-number / link-header `PaginationStrategy` implementations, plus `Page<T>` / `SimplePage<T>`. |
260260
| `pipeline` | Recovery-aware primitives: `RequestPipeline`, `ResponsePipeline`, `ExecutionPipeline` over a sealed `ResponseOutcome`, with steps (`pipeline.step`, `pipeline.step.retry`) like `RetryStep`, `ResponseRecoveryStep`, `IdempotencyKeyStep`, `ClientIdentityStep`. |
261-
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions and `Tristate<T>` (absent / null / present). |
261+
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions, `Tristate<T>` (absent / null / present), and `SerdeException` (the unchecked failure adapters translate codec errors into). |
262262
| `io` | `Source`, `Sink`, `Buffer`, `BufferedSource`, `BufferedSink`, `IoProvider`, `Io`, `TeeSink`. |
263263
| `instrumentation` | `ClientLogger` (zero-alloc disabled path), `LoggingEvent`, `UrlRedactor`, `Tracer` / `NoopTracer`, `Span` / `NoopSpan`, `InstrumentationContext`. |
264264
| `instrumentation.metrics` | `Meter`, `LongCounter`, `DoubleHistogram`, `NoopMeter`. |

sdk-core/api/sdk-core.api

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,27 @@ public final class org/dexpace/sdk/core/http/response/LoggableResponseBody : org
11531153
public fun source ()Lorg/dexpace/sdk/core/io/BufferedSource;
11541154
}
11551155

1156+
public final class org/dexpace/sdk/core/http/response/ParsedResponse : java/io/Closeable {
1157+
public static final field Companion Lorg/dexpace/sdk/core/http/response/ParsedResponse$Companion;
1158+
public fun close ()V
1159+
public final fun getHeaders ()Lorg/dexpace/sdk/core/http/common/Headers;
1160+
public final fun getMessage ()Ljava/lang/String;
1161+
public final fun getProtocol ()Lorg/dexpace/sdk/core/http/common/Protocol;
1162+
public final fun getRaw ()Lorg/dexpace/sdk/core/http/response/Response;
1163+
public final fun getRequest ()Lorg/dexpace/sdk/core/http/request/Request;
1164+
public final fun getStatus ()Lorg/dexpace/sdk/core/http/response/Status;
1165+
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;
1166+
public final fun value ()Ljava/lang/Object;
1167+
}
1168+
1169+
public final class org/dexpace/sdk/core/http/response/ParsedResponse$Companion {
1170+
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;
1171+
}
1172+
1173+
public final class org/dexpace/sdk/core/http/response/ParsedResponseKt {
1174+
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;
1175+
}
1176+
11561177
public final class org/dexpace/sdk/core/http/response/Response : java/io/Closeable {
11571178
public static final field Companion Lorg/dexpace/sdk/core/http/response/Response$Companion;
11581179
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
@@ -1218,6 +1239,18 @@ public final class org/dexpace/sdk/core/http/response/ResponseBody$Companion {
12181239
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;
12191240
}
12201241

1242+
public abstract interface class org/dexpace/sdk/core/http/response/ResponseHandler {
1243+
public static final field Companion Lorg/dexpace/sdk/core/http/response/ResponseHandler$Companion;
1244+
public static fun empty ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
1245+
public abstract fun handle (Lorg/dexpace/sdk/core/http/response/Response;)Ljava/lang/Object;
1246+
public static fun string ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
1247+
}
1248+
1249+
public final class org/dexpace/sdk/core/http/response/ResponseHandler$Companion {
1250+
public final fun empty ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
1251+
public final fun string ()Lorg/dexpace/sdk/core/http/response/ResponseHandler;
1252+
}
1253+
12211254
public final class org/dexpace/sdk/core/http/response/Status {
12221255
public static final field ACCEPTED Lorg/dexpace/sdk/core/http/response/Status;
12231256
public static final field ALREADY_REPORTED Lorg/dexpace/sdk/core/http/response/Status;
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.http.response
9+
10+
import org.dexpace.sdk.core.http.common.Headers
11+
import org.dexpace.sdk.core.http.common.Protocol
12+
import org.dexpace.sdk.core.http.request.Request
13+
import java.io.Closeable
14+
import java.io.IOException
15+
import java.util.concurrent.locks.ReentrantLock
16+
import kotlin.concurrent.withLock
17+
18+
/**
19+
* Pairs a raw [Response] with a [ResponseHandler] so the typed value can be parsed **lazily and
20+
* exactly once**, while the raw status / headers / metadata stay readable without forcing
21+
* deserialization.
22+
*
23+
* This is the raw-vs-parsed seam: header and status access (e.g. reading `ETag`, branching on a
24+
* `404`) goes straight to the underlying response and never touches the body, whereas the typed
25+
* value is produced on demand by the handler. Because the body is single-use, [value] memoizes
26+
* the handler's outcome — the first call runs the handler and every subsequent call returns the
27+
* same result (or re-throws the same failure) without re-invoking the handler or re-reading the
28+
* body.
29+
*
30+
* ## Body consumption
31+
*
32+
* The handler owns the body. Calling [value] runs the handler, which typically consumes and
33+
* closes the body (the built-in [ResponseHandler.string] / [ResponseHandler.empty] and adapter
34+
* JSON handlers do). **Read any raw headers / status before the first [value] call**, since the
35+
* body is gone afterwards. [close] is available for the path where the typed value is never
36+
* needed and the body must still be released.
37+
*
38+
* ## Thread-safety
39+
*
40+
* Raw accessors are immutable and safe to share. [value] is guarded by a [ReentrantLock]
41+
* (`synchronized` would pin a carrier thread under virtual threads): concurrent first calls block
42+
* until the single parse completes and then all observe the same memoized result. A `null` result
43+
* and a thrown failure are both memoized, so neither triggers a re-parse.
44+
*
45+
* @param T The typed value the handler produces.
46+
* @param raw The underlying raw response. Header / status / metadata access reads from here.
47+
* @param handler Strategy that maps [raw] to the typed value on first [value] access.
48+
*/
49+
public class ParsedResponse<out T> internal constructor(
50+
public val raw: Response,
51+
private val handler: ResponseHandler<T>,
52+
) : Closeable {
53+
private val lock = ReentrantLock()
54+
55+
// Holds the memoized outcome once the handler has run. A non-null holder means "parsed"
56+
// (success or failure); the wrapped value distinguishes the two. A holder (rather than a
57+
// bare value) lets a legitimately-null success memoize without being mistaken for "unparsed".
58+
@Volatile
59+
private var outcome: Outcome<T>? = null
60+
61+
/** The request that produced [raw]. Does not parse. */
62+
public val request: Request get() = raw.request
63+
64+
/** The negotiated wire protocol. Does not parse. */
65+
public val protocol: Protocol get() = raw.protocol
66+
67+
/** The HTTP status. Does not parse. */
68+
public val status: Status get() = raw.status
69+
70+
/** The status-line reason phrase, or `null` if absent. Does not parse. */
71+
public val message: String? get() = raw.message
72+
73+
/** The response headers. Does not parse. */
74+
public val headers: Headers get() = raw.headers
75+
76+
/**
77+
* Returns the typed value, parsing it on the first call and memoizing the outcome.
78+
*
79+
* The handler runs at most once: the first call invokes [ResponseHandler.handle] (which
80+
* typically consumes and closes the body); subsequent calls return the same value, or
81+
* re-throw the same failure, without re-running the handler.
82+
*
83+
* Any failure the handler throws is memoized and re-thrown verbatim on every later call — not
84+
* just [IOException]. Handlers commonly throw **unchecked** exceptions (the Jackson `jsonHandler`
85+
* throws `SerdeException`), so callers should not assume the only escape is [IOException].
86+
*
87+
* @return The parsed value (which may be `null` if the handler is typed `ResponseHandler<T?>`
88+
* and produces `null`).
89+
* @throws IOException If the handler failed with an [IOException] — cached and re-thrown. The
90+
* `@Throws` declaration covers only the checked surface for Java callers; the handler may also
91+
* propagate **unchecked** exceptions (e.g. `SerdeException` from the Jackson `jsonHandler`),
92+
* which are memoized and re-thrown the same way.
93+
*/
94+
@Throws(IOException::class)
95+
public fun value(): T {
96+
outcome?.let { return it.get() }
97+
return lock.withLock {
98+
outcome?.let { return it.get() }
99+
// Memoize the handler's outcome — success or failure — so neither re-runs the handler
100+
// nor re-reads the (now consumed) body on a subsequent call.
101+
val resolved: Outcome<T> =
102+
try {
103+
Outcome.Success(handler.handle(raw))
104+
} catch (t: Throwable) {
105+
// Catch Throwable, not Exception, on purpose: once the handler has touched the
106+
// single-use body, re-running it would read an already-consumed stream. Even an
107+
// Error (e.g. an OOM mid-parse) is memoized so a later call re-throws it rather
108+
// than re-reading the body and masking the original failure.
109+
Outcome.Failure(t)
110+
}
111+
outcome = resolved
112+
resolved.get()
113+
}
114+
}
115+
116+
/**
117+
* Releases the raw response body. Idempotent (forwards to [Response.close], which is itself
118+
* idempotent). Safe to call whether or not [value] has run.
119+
*
120+
* @throws IOException If the underlying close fails.
121+
*/
122+
@Throws(IOException::class)
123+
override fun close() {
124+
raw.close()
125+
}
126+
127+
private sealed class Outcome<out T> {
128+
abstract fun get(): T
129+
130+
class Success<out T>(private val value: T) : Outcome<T>() {
131+
override fun get(): T = value
132+
}
133+
134+
class Failure(private val error: Throwable) : Outcome<Nothing>() {
135+
override fun get(): Nothing = throw error
136+
}
137+
}
138+
139+
public companion object {
140+
/**
141+
* Creates a [ParsedResponse] that parses [response] with [handler] on first access.
142+
* Java-friendly factory mirroring the Kotlin [Response.parsedWith] extension.
143+
*
144+
* @param response The raw response.
145+
* @param handler Strategy that maps the response to the typed value.
146+
* @return A lazily-parsing [ParsedResponse].
147+
*/
148+
@JvmStatic
149+
public fun <T> of(
150+
response: Response,
151+
handler: ResponseHandler<T>,
152+
): ParsedResponse<T> = ParsedResponse(response, handler)
153+
}
154+
}
155+
156+
/**
157+
* Wraps this response in a [ParsedResponse] bound to [handler], so the typed value parses lazily
158+
* and exactly once while raw status / headers stay accessible. Kotlin-ergonomic mirror of
159+
* [ParsedResponse.of].
160+
*
161+
* @param handler Strategy that maps this response to the typed value.
162+
* @return A lazily-parsing [ParsedResponse].
163+
*/
164+
public fun <T> Response.parsedWith(handler: ResponseHandler<T>): ParsedResponse<T> = ParsedResponse(this, handler)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.http.response
9+
10+
import org.dexpace.sdk.core.io.Io
11+
import java.io.IOException
12+
import java.nio.charset.StandardCharsets
13+
14+
/**
15+
* Maps a raw [Response] to a typed result of type [T].
16+
*
17+
* A `ResponseHandler` is the seam generated service code dispatches against: the transport
18+
* produces a raw [Response], and the handler decides how to turn it into a domain value — decode
19+
* a JSON body into a DTO, read a body as text, or simply discard the body for an empty result.
20+
*
21+
* ## Body ownership
22+
*
23+
* A handler that reads the body **owns consuming and closing it**. [handle] is expected to leave
24+
* the response closed when it returns (whether it read the body or not), so callers do not need a
25+
* surrounding `use {}` block once they have delegated to a handler. The built-in [string] and
26+
* [empty] handlers honor this. Because the body is single-use, a handler must read it at most
27+
* once; pair a handler with [ParsedResponse] when the typed value must be exposed lazily and
28+
* memoized so the body is consumed exactly once.
29+
*
30+
* ## Raw access first
31+
*
32+
* Reading the body is destructive, so any header / status inspection that must happen alongside
33+
* parsing should read from the raw [Response] (or [ParsedResponse]'s raw accessors) **before**
34+
* invoking the handler.
35+
*
36+
* ## Thread-safety
37+
*
38+
* Handlers are typically stateless and shared across requests; the built-in factories return
39+
* stateless instances. A stateful handler must guard its own state.
40+
*
41+
* ## Nullability
42+
*
43+
* A handler that may legitimately produce `null` (e.g. an absent-but-valid payload) should be typed
44+
* `ResponseHandler<T?>` so the nullability is visible to Kotlin and Java callers alike; otherwise a
45+
* `null` slips through a non-null `T` as a platform value. [ParsedResponse.value] memoizes a `null`
46+
* result correctly either way.
47+
*
48+
* @param T The typed result this handler produces.
49+
*/
50+
public fun interface ResponseHandler<out T> {
51+
/**
52+
* Consumes [response] and produces the typed result. Implementations that read the body must
53+
* also close [response] before returning.
54+
*
55+
* @param response The raw response to map.
56+
* @return The typed result.
57+
* @throws IOException If reading the body fails.
58+
*/
59+
@Throws(IOException::class)
60+
public fun handle(response: Response): T
61+
62+
public companion object {
63+
/**
64+
* A handler that reads the entire response body as a UTF-8 [String] and closes the
65+
* response. A bodyless response (e.g. `204 No Content`) yields an empty string.
66+
*
67+
* **Unbounded.** This reads the whole body into a single in-memory [String] with no size
68+
* cap, so it is an unbounded-allocation vector against a hostile or misbehaving server.
69+
* Unlike the bounded body-logging path elsewhere in the SDK, it applies no limit — use it
70+
* only for trusted endpoints with bounded payloads, not for untrusted or large bodies.
71+
*
72+
* @return A stateless [String] handler.
73+
*/
74+
@JvmStatic
75+
public fun string(): ResponseHandler<String> =
76+
ResponseHandler { response ->
77+
response.use {
78+
val body = it.body ?: return@use ""
79+
body.source().readString(StandardCharsets.UTF_8)
80+
}
81+
}
82+
83+
/**
84+
* A handler that fully drains and closes the body, discarding its bytes, and returns
85+
* [Unit]. Use for endpoints whose payload is irrelevant (e.g. a `DELETE` returning a
86+
* status only) but whose connection must still be released.
87+
*
88+
* @return A stateless discarding handler.
89+
*/
90+
@JvmStatic
91+
public fun empty(): ResponseHandler<Unit> =
92+
ResponseHandler { response ->
93+
response.use {
94+
val body = it.body ?: return@use
95+
val source = body.source()
96+
// Pump into a throwaway scratch buffer (cleared each round) so the connection
97+
// is released without materializing the whole body in memory. The buffer is
98+
// closed deterministically so its segments are recycled even if the drain
99+
// throws mid-stream, rather than leaning on the GC.
100+
Io.provider.buffer().use { scratch ->
101+
while (source.read(scratch, DRAIN_CHUNK_BYTES) != -1L) {
102+
scratch.clear()
103+
}
104+
}
105+
}
106+
}
107+
108+
/**
109+
* Per-read pump size for the discarding drain — a reasonable chunk size. `read` treats it
110+
* as an upper bound, so the exact value is not load-bearing for correctness.
111+
*/
112+
private const val DRAIN_CHUNK_BYTES: Long = 8 * 1024
113+
}
114+
}

0 commit comments

Comments
 (0)