From 43a101e42fa2d0db9186d3eea4050dc90c9b0e1a Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:22:16 +0300 Subject: [PATCH 01/46] feat: add Status range predicates and delegate HttpExceptionFactory.isErrorStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add isInformational (1xx), isRedirect (3xx), isClientError (4xx), isServerError (5xx), isError (4xx–5xx) to Status alongside the existing isSuccess. HttpExceptionFactory.isErrorStatus now delegates to Status.fromCode(code).isError, making the two consistent. --- sdk-core/api/sdk-core.api | 16 ++++ .../dexpace/sdk/core/http/response/Status.kt | 15 ++++ .../exception/HttpExceptionFactory.kt | 3 +- .../sdk/core/http/response/StatusTest.kt | 90 +++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 49f62f44..d2e7ea99 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -1375,6 +1375,7 @@ 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 isSuccessful ()Z public final fun newBuilder ()Lorg/dexpace/sdk/core/http/response/Response$ResponseBuilder; public fun toString ()Ljava/lang/String; } @@ -1405,6 +1406,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 +1414,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 +1426,12 @@ 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/ResponseExtensionsKt { + 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 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 +1517,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; } 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..22e6a6bf 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 @@ -8,6 +8,7 @@ package org.dexpace.sdk.core.http.response.exception import org.dexpace.sdk.core.http.response.Response +import org.dexpace.sdk.core.http.response.Status /** * Maps a non-2xx [Response] to the matching [HttpException] subclass. @@ -73,7 +74,7 @@ public object HttpExceptionFactory { * use this rather than re-declaring the range. */ @JvmStatic - public fun isErrorStatus(code: Int): Boolean = code in SC_CLIENT_ERROR_MIN..SC_SERVER_ERROR_MAX + public fun isErrorStatus(code: Int): Boolean = Status.fromCode(code).isError /** * Maps [response] to its [HttpException] subclass, or returns `null` when the status is not 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) + } } From 6207a83219dd97eba0b3f566c42ffb85b99ab4d5 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:22:22 +0300 Subject: [PATCH 02/46] feat: add Response.isSuccessful and ResponseBody.string()/bytes() Response.isSuccessful delegates to status.isSuccess, eliminating the need to reach through the status property. ResponseBody gains string(charset) and bytes() convenience readers that both close the body in a finally block, removing the common null-chasing pattern of body?.source()?.readUtf8(). --- .../sdk/core/http/response/Response.kt | 3 + .../sdk/core/http/response/ResponseBody.kt | 31 ++++++ .../sdk/core/http/response/ResponseTest.kt | 99 +++++++++++++++++++ 3 files changed, 133 insertions(+) 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..e9d88e33 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,9 @@ public data class Response private constructor( val headers: Headers, val body: ResponseBody?, ) : Closeable { + /** True when [status] is in the 2xx success range. */ + public val isSuccessful: Boolean get() = status.isSuccess + /** * 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..566dcf87 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,7 @@ 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 /** * Represents the body of an HTTP response. @@ -61,6 +62,36 @@ 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 [Charsets.UTF_8]. + * @throws IOException If an I/O error occurs while reading. + */ + @JvmOverloads + @Throws(IOException::class) + public fun string(charset: 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. * 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..5bb362ca 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,63 @@ 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) + } } class ResponseBodyTest { @@ -423,4 +481,45 @@ 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()) + } } From a51d5d15859842a7c1f527101f803827e7d5a3f4 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:22:29 +0300 Subject: [PATCH 03/46] feat: add Response.throwOnError(), HttpException.bodyAs(), and Response.deserialize() throwOnError() buffers the error body into an in-memory ResponseBody before throwing so the exception's body remains readable even after the transport connection is released. HttpException.bodyAs() decodes the error body via a Deserializer, returning null on missing body or decode failure without propagating. Response.deserialize() streams the body through a Serde and closes the response in a finally block; a reified overload provides Kotlin-only type-inference sugar. --- .../core/http/response/ResponseExtensions.kt | 93 +++++++ .../http/response/ResponseExtensionsTest.kt | 241 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensions.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensionsTest.kt 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..daab3a5e --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensions.kt @@ -0,0 +1,93 @@ +/* + * 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.response.exception.HttpException +import org.dexpace.sdk.core.http.response.exception.HttpExceptionFactory +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.serde.Deserializer +import org.dexpace.sdk.core.serde.Serde + +/** + * Returns this response if [Response.isSuccessful]; otherwise buffers the body into an + * in-memory [ResponseBody], wraps it in a new [Response], and throws the matching + * [HttpException] produced by [HttpExceptionFactory.fromResponse]. + * + * Buffering guarantees that the exception's [HttpException.body] is still readable after the + * original transport connection is released or the response is consumed in a `use {}` block. + * + * @throws HttpException for any 4xx or 5xx response. + */ +public fun Response.throwOnError(): Response { + if (isSuccessful) return this + val buffer = Io.provider.buffer() + val originalBody = body + if (originalBody != null) { + originalBody.use { b -> buffer.writeAll(b.source()) } + } + val bufferedBody = originalBody?.let { ResponseBody.create(buffer, it.mediaType(), buffer.size) } + val bufferedResponse = newBuilder().body(bufferedBody).build() + throw HttpExceptionFactory.fromResponse(bufferedResponse) +} + +/** + * 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. + * + * Exceptions thrown by the deserializer are 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) { + null + } +} + +/** + * 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 IllegalStateException if the response body is `null`. + */ +public fun Response.deserialize( + serde: Serde, + type: Class, +): T { + val b = body ?: error("Response body is null — cannot deserialize into $type") + return try { + serde.deserializer.deserialize(b.source().inputStream(), type) + } 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` + * overload. + * + * @param serde The [Serde] bundle whose deserializer is used for decoding. + * @return The decoded value. + * @throws IllegalStateException if the response body is `null`. + */ +public inline fun Response.deserialize(serde: Serde): T = deserialize(serde, T::class.java) 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..3520df0d --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensionsTest.kt @@ -0,0 +1,241 @@ +/* + * 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.Method +import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.core.http.response.exception.HttpException +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 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 + +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 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 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) + } + + // ---- 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)) + } + + // ---- 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 IllegalStateException 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 delegates to class overload`() { + 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 ----------------------------------------------------------------------- + + @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", + ) + } + + private fun fakeSerde(deserializer: Deserializer): Serde = + object : Serde { + override val serializer: Serializer get() = throw UnsupportedOperationException() + override val deserializer: Deserializer = deserializer + } +} From 0ac3035238562087c8a5dc7ce481b22294abac34 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:37:38 +0300 Subject: [PATCH 04/46] feat: add MediaType companion shortcuts and Serde-backed RequestBody factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T4 — six @JvmField constants on MediaType.Companion (APPLICATION_JSON, TEXT_PLAIN, APPLICATION_OCTET_STREAM, APPLICATION_FORM_URLENCODED, TEXT_HTML, APPLICATION_XML) that delegate to CommonMediaTypes, giving callers a single canonical access point (MediaType.APPLICATION_JSON) without duplicating the registry. T8 — default contentType() method on Serde returns CommonMediaTypes.APPLICATION_JSON so implementations do not need to declare it unless they deviate from JSON. A matching RequestBody.create overload accepts (Any, Serde, MediaType = serde.contentType()), serializes via Serde.serializer.serialize, and delegates to the existing String create; no Jackson dependency required in sdk-core. --- .../dexpace/sdk/core/http/common/MediaType.kt | 25 ++++ .../sdk/core/http/request/RequestBody.kt | 22 ++++ .../org/dexpace/sdk/core/serde/Serde.kt | 13 +++ .../sdk/core/http/common/MediaTypeDxTest.kt | 73 ++++++++++++ .../core/http/request/RequestBodyDxTest.kt | 109 ++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/MediaTypeDxTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestBodyDxTest.kt 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..4675a587 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,31 @@ public data class MediaType private constructor( private fun isHeaderUnsafe(ch: Char): Boolean = isProhibitedInValue(ch.code) public companion object { + // --- Common media-type shortcuts ----------------------------------------------- + // Delegate to CommonMediaTypes (single source of truth). Exposed as @JvmField so + // Java callers see a plain static field (MediaType.APPLICATION_JSON) rather than a + // generated getter. + + /** `application/json` — delegates to [CommonMediaTypes.APPLICATION_JSON]. */ + @JvmField public val APPLICATION_JSON: MediaType = CommonMediaTypes.APPLICATION_JSON + + /** `text/plain` — delegates to [CommonMediaTypes.TEXT_PLAIN]. */ + @JvmField public val TEXT_PLAIN: MediaType = CommonMediaTypes.TEXT_PLAIN + + /** `application/octet-stream` — delegates to [CommonMediaTypes.APPLICATION_OCTET_STREAM]. */ + @JvmField public val APPLICATION_OCTET_STREAM: MediaType = CommonMediaTypes.APPLICATION_OCTET_STREAM + + /** `application/x-www-form-urlencoded` — delegates to [CommonMediaTypes.APPLICATION_FORM_URLENCODED]. */ + @JvmField public val APPLICATION_FORM_URLENCODED: MediaType = CommonMediaTypes.APPLICATION_FORM_URLENCODED + + /** `text/html` — delegates to [CommonMediaTypes.TEXT_HTML]. */ + @JvmField public val TEXT_HTML: MediaType = CommonMediaTypes.TEXT_HTML + + /** `application/xml` — delegates to [CommonMediaTypes.APPLICATION_XML]. */ + @JvmField public val APPLICATION_XML: MediaType = CommonMediaTypes.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/request/RequestBody.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt index 864d7f21..ef7187ea 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 a string. + * @param mediaType The body's media type; defaults to [Serde.contentType]. + * @return A replayable body whose bytes are the UTF-8 encoding of the serialized [value]. + */ + @JvmStatic + @JvmOverloads + public fun create( + value: Any, + serde: Serde, + mediaType: MediaType = serde.contentType(), + ): RequestBody = create(serde.serializer.serialize(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/serde/Serde.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/Serde.kt index 9679eea4..d29ebc92 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,9 @@ package org.dexpace.sdk.core.serde +import org.dexpace.sdk.core.http.common.CommonMediaTypes +import org.dexpace.sdk.core.http.common.MediaType + /** * Bundle of serialization / deserialization strategies for a single wire format (JSON, XML, etc.). * @@ -21,4 +24,14 @@ 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]. Implementations may + * override to reflect a different wire format (e.g. `application/xml`). + * + * Default: `application/json`. + */ + public fun contentType(): MediaType = CommonMediaTypes.APPLICATION_JSON } 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/request/RequestBodyDxTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestBodyDxTest.kt new file mode 100644 index 00000000..d33caad4 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestBodyDxTest.kt @@ -0,0 +1,109 @@ +/* + * 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() + } + } + + 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 `Serde default contentType returns APPLICATION_JSON`() { + assertEquals(CommonMediaTypes.APPLICATION_JSON, fakeSerde.contentType()) + } +} From e1283eead0c4250ab422a5e3d270c7bb6d466b20 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:37:53 +0300 Subject: [PATCH 05/46] refactor!: overhaul Request.Builder with typed headers, URI url, default GET, and verb shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T5 — five typed HttpHeaderName overloads on RequestBuilder mirror what Headers.Builder already exposes: addHeader/setHeader(name, String), addHeader/setHeader(name, List), and a convenience addHeader(name, MediaType) that takes the wire form of the media type. T6 — url(String) no longer declares @Throws(MalformedURLException). Internally it catches MalformedURLException and re-throws IllegalArgumentException("Invalid URL: …", cause) so callers do not have to handle a checked exception on a builder chain. A new url(URI) overload converts via URI.toURL() with the same wrapping. T7 — build() defaults the method to Method.GET when none was set, so a minimal Request.builder().url(url).build() no longer throws. Six verb shortcuts (get, post, put, delete, head, patch) set the method and body in one call; get() and head() also clear any previously-set body. Request.Companion gains static factories get(url) and post(url, body). --- sdk-core/api/sdk-core.api | 32 +++ .../dexpace/sdk/core/http/request/Request.kt | 203 +++++++++++++++++- .../sdk/core/http/request/RequestDxTest.kt | 202 +++++++++++++++++ .../sdk/core/http/request/RequestTest.kt | 14 +- sdk-serde-jackson/api/sdk-serde-jackson.api | 1 + 5 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestDxTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index d2e7ea99..63d79142 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -537,7 +537,13 @@ public final class org/dexpace/sdk/core/http/common/HttpRange$Companion { } 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; @@ -1231,17 +1237,21 @@ public final class org/dexpace/sdk/core/http/request/Request { public final fun component3 ()Lorg/dexpace/sdk/core/http/common/Headers; public final fun component4 ()Lorg/dexpace/sdk/core/http/request/RequestBody; 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 final fun newBuilder ()Lorg/dexpace/sdk/core/http/request/Request$RequestBuilder; + public static final fun post (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 get (Ljava/lang/String;)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 class org/dexpace/sdk/core/http/request/Request$RequestBuilder : org/dexpace/sdk/core/generics/Builder { @@ -1249,16 +1259,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 +1290,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 +1320,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 +1340,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; @@ -2642,10 +2669,15 @@ public abstract interface class org/dexpace/sdk/core/serde/Deserializer { } public abstract interface class org/dexpace/sdk/core/serde/Serde { + public 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; } +public final class org/dexpace/sdk/core/serde/Serde$DefaultImpls { + public static fun contentType (Lorg/dexpace/sdk/core/serde/Serde;)Lorg/dexpace/sdk/core/http/common/MediaType; +} + public class org/dexpace/sdk/core/serde/SerdeException : java/lang/RuntimeException { public fun ()V public fun (Ljava/lang/String;)V 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..f11e1dcd 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,41 @@ 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) { + 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 +238,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,6 +347,76 @@ public data class Request private constructor( headersBuilder.remove(name) } + /** + * 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 = + apply { + this.method = Method.GET + this.body = 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 = + apply { + this.method = Method.POST + this.body = 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 = + apply { + this.method = Method.PUT + this.body = body + } + + /** + * Sets the method to [Method.DELETE]. + * + * @return This builder. + */ + public fun delete(): RequestBuilder = + apply { + this.method = Method.DELETE + } + + /** + * 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 = + apply { + this.method = Method.HEAD + this.body = 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 = + apply { + this.method = Method.PATCH + this.body = body + } + /** * Builds the [Request]. * @@ -266,7 +433,7 @@ public data class Request private constructor( * ([Method.GET], [Method.HEAD], [Method.TRACE], or [Method.CONNECT]). */ override fun build(): Request { - val resolvedMethod = checkRequired("method", method) + val resolvedMethod = method ?: Method.GET 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 +457,29 @@ 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() } } 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..b98226ae --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestDxTest.kt @@ -0,0 +1,202 @@ +/* + * 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 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) + } +} 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..c0e041ed 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 diff --git a/sdk-serde-jackson/api/sdk-serde-jackson.api b/sdk-serde-jackson/api/sdk-serde-jackson.api index f14795d4..a9cf254f 100644 --- a/sdk-serde-jackson/api/sdk-serde-jackson.api +++ b/sdk-serde-jackson/api/sdk-serde-jackson.api @@ -6,6 +6,7 @@ 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; From db870b5bb5859b5597a94ac8a1e0fc60c7d2f83e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:45:36 +0300 Subject: [PATCH 06/46] fix: JacksonSerde.from(mapper) registers TristateModule by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from() now operates on a defensive copy of the caller's mapper (never mutating the original) and registers TristateModule on that copy so that Tristate PATCH fields — Present/Null/Absent — serialize correctly without silent data corruption. Adds a registerTristate: Boolean = true parameter (@JvmOverloads so Java callers keep the single-arg form) for callers who pre-registered the module themselves and want to opt out of the automatic registration. Tests cover all three Tristate states, the no-mutation guarantee, and the opt-out path. --- sdk-serde-jackson/api/sdk-serde-jackson.api | 7 ++ .../dexpace/sdk/serde/jackson/JacksonSerde.kt | 33 +++++++- .../sdk/serde/jackson/JacksonSerdeTest.kt | 84 ++++++++++++++++++- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/sdk-serde-jackson/api/sdk-serde-jackson.api b/sdk-serde-jackson/api/sdk-serde-jackson.api index a9cf254f..f49944d0 100644 --- a/sdk-serde-jackson/api/sdk-serde-jackson.api +++ b/sdk-serde-jackson/api/sdk-serde-jackson.api @@ -1,3 +1,7 @@ +public final class org/dexpace/sdk/serde/jackson/ExtensionsKt { + public static final fun jsonBody (Lorg/dexpace/sdk/core/serde/Serde;Ljava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody; +} + public final class org/dexpace/sdk/serde/jackson/JacksonObjectMappers { public static final field INSTANCE Lorg/dexpace/sdk/serde/jackson/JacksonObjectMappers; public final fun defaultObjectMapper ()Lcom/fasterxml/jackson/databind/ObjectMapper; @@ -11,6 +15,7 @@ public final class org/dexpace/sdk/serde/jackson/JacksonSerde : org/dexpace/sdk/ 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; @@ -19,6 +24,8 @@ 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; } 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..9560c6b0 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 @@ -92,14 +92,41 @@ 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. + * + * ## 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) + } } } 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..fc40630e 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 @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.dexpace.sdk.core.http.common.MediaType import org.dexpace.sdk.core.serde.DeserializationException import org.dexpace.sdk.core.serde.SerdeException import org.dexpace.sdk.core.serde.SerializationException @@ -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, @@ -182,6 +189,72 @@ class JacksonSerdeTest { assertEquals(0, tracker.closeCount, "from(plain mapper) must not close the caller InputStream") } + // ----- 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\"")) + } + + // ----- T8-jackson: jsonBody convenience ----- + + @Test + fun `jsonBody produces application-json body with expected content length and media type`() { + val serde = JacksonSerde.withDefaults() + val dto = Inner("hello", 42) + val body = jsonBody(serde, dto) + val expectedBytes = serde.serializer.serializeToByteArray(dto) + assertEquals(MediaType.parse("application/json"), body.mediaType()) + assertEquals(expectedBytes.size.toLong(), body.contentLength()) + assertTrue(body.isReplayable()) + } + // 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 +301,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 ----- From c5b456de098aa1ba72682c28a531d6667aa6ea49 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:45:42 +0300 Subject: [PATCH 07/46] feat: add jsonBody convenience to sdk-serde-jackson MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a thin public jsonBody(serde, value) function to Extensions.kt that delegates to RequestBody.create(value, serde) — a discoverable entry point for building a JSON request body without importing sdk-core factory methods directly from the Jackson adapter module. --- .../org/dexpace/sdk/serde/jackson/Extensions.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt index 0d0f824e..75962a5c 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt @@ -9,8 +9,25 @@ package org.dexpace.sdk.serde.jackson import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.type.TypeFactory +import org.dexpace.sdk.core.http.request.RequestBody +import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.Tristate +/** + * Creates a replayable [RequestBody] by serializing [value] via [serde]. + * + * This is a discoverable convenience wrapper over [RequestBody.create]. The body's media type + * defaults to [Serde.contentType] (typically `application/json`). + * + * @param serde The serde used to serialize [value]. + * @param value The object to serialize. + * @return A replayable body whose bytes are the UTF-8 encoding of the serialized [value]. + */ +public fun jsonBody( + serde: Serde, + value: Any, +): RequestBody = RequestBody.create(value, serde) + /** The element [JavaType] for `Object` — the fallback when a `Tristate` is raw or `Tristate<*>`. */ internal val ANY_TYPE: JavaType = TypeFactory.defaultInstance().constructType(Any::class.java) From 3a1482ff38e8b378cffec03858fe3a44da112225 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:56:41 +0300 Subject: [PATCH 08/46] feat: builders for HttpRetryOptions/HttpInstrumentationOptions/HttpRedirectOptions --- sdk-core/api/sdk-core.api | 54 +++++++++++ .../steps/HttpInstrumentationOptions.kt | 66 +++++++++++++ .../pipeline/steps/HttpRedirectOptions.kt | 70 ++++++++++++++ .../http/pipeline/steps/HttpRetryOptions.kt | 94 +++++++++++++++++++ .../HttpInstrumentationOptionsBuilderTest.kt | 41 ++++++++ .../steps/HttpRedirectOptionsBuilderTest.kt | 47 ++++++++++ .../steps/HttpRetryOptionsBuilderTest.kt | 60 ++++++++++++ 7 files changed, 432 insertions(+) create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpInstrumentationOptionsBuilderTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptionsBuilderTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/HttpRetryOptionsBuilderTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 63d79142..f83d4e06 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -961,6 +961,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 @@ -968,9 +969,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 { @@ -1007,6 +1023,7 @@ 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 @@ -1015,12 +1032,30 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions 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 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/EnumSet;)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 { @@ -1065,7 +1100,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; @@ -1074,10 +1111,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 { 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..7403549f 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 @@ -61,4 +61,74 @@ public class HttpRedirectOptions * after construction. */ public val allowedMethods: Set = EnumSet.copyOf(allowedMethods) + + /** + * 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(EnumSet.copyOf(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: EnumSet = 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 [build] time. */ + public fun allowedMethods(value: EnumSet): 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/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) + } +} From 275fc337cbfb9c0004f99caa9df284836f5c8ca5 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 02:56:46 +0300 Subject: [PATCH 09/46] feat: HttpRetryOptions.fromConfiguration and default JDK connect timeout --- .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 22 +++++-- ...kHttpTransportDefaultConnectTimeoutTest.kt | 65 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportDefaultConnectTimeoutTest.kt 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..a67e8d7d 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 @@ -277,6 +277,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 +308,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 +317,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 } 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 + } +} From c54e8cfef77eab01cb7ef66d95ebc201b5beb500 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 03:02:33 +0300 Subject: [PATCH 10/46] fix: close body in HttpException.bodyAs, bound throwOnError buffering, clear body on delete() --- .../dexpace/sdk/core/http/request/Request.kt | 6 +- .../core/http/response/ResponseExtensions.kt | 33 +++++++-- .../sdk/core/http/request/RequestDxTest.kt | 13 ++++ .../http/response/ResponseExtensionsTest.kt | 68 +++++++++++++++++++ 4 files changed, 114 insertions(+), 6 deletions(-) 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 f11e1dcd..9884295e 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 @@ -384,13 +384,17 @@ public data class Request private constructor( } /** - * Sets the method to [Method.DELETE]. + * 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 = apply { this.method = Method.DELETE + this.body = null } /** 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 index daab3a5e..a10b221f 100644 --- 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 @@ -9,17 +9,25 @@ package org.dexpace.sdk.core.http.response 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.Io import org.dexpace.sdk.core.serde.Deserializer import org.dexpace.sdk.core.serde.Serde +private val logger = ClientLogger("org.dexpace.sdk.core.http.response.ResponseExtensions") + +/** Maximum bytes buffered from the error body in [throwOnError]. Bodies exceeding this limit are truncated. */ +private const val MAX_BUFFERED_ERROR_BODY_BYTES: Long = 1L * 1024 * 1024 + /** * Returns this response if [Response.isSuccessful]; otherwise buffers the body into an * in-memory [ResponseBody], wraps it in a new [Response], and throws the matching * [HttpException] produced by [HttpExceptionFactory.fromResponse]. * - * Buffering guarantees that the exception's [HttpException.body] is still readable after the - * original transport connection is released or the response is consumed in a `use {}` block. + * 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 a single, in-memory byte sequence — it is not infinitely + * replayable; once read it is consumed. * * @throws HttpException for any 4xx or 5xx response. */ @@ -28,7 +36,15 @@ public fun Response.throwOnError(): Response { val buffer = Io.provider.buffer() val originalBody = body if (originalBody != null) { - originalBody.use { b -> buffer.writeAll(b.source()) } + originalBody.use { b -> + val src = b.source() + var remaining = MAX_BUFFERED_ERROR_BODY_BYTES + while (remaining > 0L) { + val n = src.read(buffer, remaining) + if (n < 0L) break + remaining -= n + } + } } val bufferedBody = originalBody?.let { ResponseBody.create(buffer, it.mediaType(), buffer.size) } val bufferedResponse = newBuilder().body(bufferedBody).build() @@ -39,8 +55,9 @@ public fun Response.throwOnError(): Response { * 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. * - * Exceptions thrown by the deserializer are swallowed rather than propagated; this function - * never 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. @@ -54,7 +71,13 @@ public fun HttpException.bodyAs( 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() } } 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 index b98226ae..c0ec689f 100644 --- 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 @@ -162,6 +162,19 @@ class RequestDxTest { 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 = 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 index 3520df0d..189a617b 100644 --- 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 @@ -8,10 +8,12 @@ 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 @@ -25,6 +27,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue class ResponseExtensionsTest { @BeforeTest @@ -127,6 +130,26 @@ class ResponseExtensionsTest { 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) @@ -160,6 +183,34 @@ class ResponseExtensionsTest { 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) @@ -193,6 +244,23 @@ class ResponseExtensionsTest { // ---- 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 { From 7e666595493cba8423ea36a46e9184063d4e2676 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 03:12:04 +0300 Subject: [PATCH 11/46] feat: ServiceLoader fallback resolves a single IoProvider without explicit install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no provider is explicitly installed and exactly one IoProvider is discoverable via ServiceLoader on the classpath, Io.provider resolves it automatically and caches the result. Explicit installProvider still wins. - Io: add @Volatile resolved field; double-checked lock for ServiceLoader scan - Io.selectProvider (internal): testable 0/1/many decision; 0 → actionable error, 1 → return it, many → ambiguity error naming all candidates - Io.installProvider / swapProvider: both clear resolved so explicit installs and test teardowns always take full effect - OkioIoProviderLoader: internal shim with public no-arg ctor allowing ServiceLoader to instantiate OkioIoProvider (Kotlin object, private ctor) - META-INF/services: registers OkioIoProviderLoader in sdk-io-okio3 - IoTest: adds selectProvider unit tests (0/1/2 candidates), ServiceLoader discovery assertion, and integration test proving Io.provider resolves without installProvider --- .../main/kotlin/org/dexpace/sdk/core/io/Io.kt | 86 ++++++++++++++++--- .../kotlin/org/dexpace/sdk/core/io/IoTest.kt | 61 +++++++++++++ .../dexpace/sdk/io/OkioIoProviderLoader.kt | 25 ++++++ .../org.dexpace.sdk.core.io.IoProvider | 1 + 4 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 sdk-io-okio3/src/main/kotlin/org/dexpace/sdk/io/OkioIoProviderLoader.kt create mode 100644 sdk-io-okio3/src/main/resources/META-INF/services/org.dexpace.sdk.core.io.IoProvider 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..624b898c 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,39 @@ package org.dexpace.sdk.core.io +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 and exactly one + * [IoProvider] implementation is discoverable via [ServiceLoader] on the classpath, + * it is resolved and cached automatically. The result is cached after the first resolution + * so the ServiceLoader scan runs at most once. + * 3. **Error** — zero discoverable providers throw with an actionable message; + * more than one throw listing all candidates so the ambiguity is obvious. * * ## 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. The ServiceLoader scan runs at most once + * through a double-checked locking pattern. */ public object Io { private val lock = ReentrantLock() @@ -39,15 +47,53 @@ public object Io { @Volatile private var installed: IoProvider? = null + @Volatile + private var resolved: IoProvider? = null + /** - * 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. */ 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 { return it } + return lock.withLock { + installed?.let { return it } + resolved?.let { return it } + val p = selectProvider(loadCandidates()) + resolved = p + 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.", + ) + } + } + + private fun loadCandidates(): List = + ServiceLoader.load(IoProvider::class.java, IoProvider::class.java.classLoader).toList() /** * Installs [provider] as the global I/O provider. @@ -61,6 +107,9 @@ 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]. + * + * Any previously cached ServiceLoader-resolved provider is cleared so the explicit + * install takes full effect on the next [provider] access. */ public fun installProvider(provider: IoProvider) { lock.withLock { @@ -74,6 +123,7 @@ public object Io { "Use withProvider { ... } from org.dexpace.sdk.core.testing for scoped overrides." } installed = provider + resolved = null } } @@ -81,7 +131,15 @@ public object Io { * 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 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 + previous + } } 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..328a6e74 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 @@ -9,10 +9,12 @@ package org.dexpace.sdk.core.io import org.dexpace.sdk.core.testing.withProvider import org.dexpace.sdk.io.OkioIoProvider +import java.util.ServiceLoader import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -124,4 +126,63 @@ 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) + } + } } 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/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 From 8d54cee1ee77d0c57da2c0d6466680196f0a1c82 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 03:37:44 +0300 Subject: [PATCH 12/46] feat: throwOnHttpError() step + async mirror on the stage pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a ThrowOnHttpErrorStep (and its AsyncHttpStep mirror) that maps a 4xx/5xx response to the matching typed HttpException, reusing Response.throwOnError() to buffer a bounded (<=1 MiB) copy of the error body so the exception stays readable after the connection is released while capping memory against rogue payloads. A 2xx (or any non-error status that reaches the step) is returned untouched — the success-path body is never read, consumed, or closed. The step occupies a new outermost non-pillar stage, PRE_REDIRECT, so it only evaluates the terminal response: it runs outside both the redirect and retry loops and therefore never throws on an intermediate 3xx hop nor preempts a retry of a retryable 5xx. Expose throwOnHttpError() on both pipeline builders to append the step in one call. --- sdk-core/api/sdk-core.api | 15 ++ .../http/pipeline/AsyncHttpPipelineBuilder.kt | 10 ++ .../core/http/pipeline/HttpPipelineBuilder.kt | 10 ++ .../dexpace/sdk/core/http/pipeline/Stage.kt | 3 + .../steps/AsyncThrowOnHttpErrorStep.kt | 56 ++++++ .../pipeline/steps/ThrowOnHttpErrorStep.kt | 68 ++++++++ .../steps/AsyncThrowOnHttpErrorStepTest.kt | 82 +++++++++ .../steps/ThrowOnHttpErrorStepTest.kt | 163 ++++++++++++++++++ 8 files changed, 407 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStep.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStep.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStepTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStepTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index f83d4e06..784c087d 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -759,6 +759,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 { @@ -806,6 +807,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 { @@ -833,6 +835,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; @@ -870,6 +873,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; @@ -1177,6 +1186,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 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..0b280ec0 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,6 +8,7 @@ package org.dexpace.sdk.core.http.pipeline import org.dexpace.sdk.core.client.AsyncHttpClient +import org.dexpace.sdk.core.http.pipeline.steps.AsyncThrowOnHttpErrorStep import org.dexpace.sdk.core.instrumentation.ClientLogger /** @@ -96,6 +97,15 @@ 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()) + /** Builds an immutable [AsyncHttpPipeline]. */ public fun build(): AsyncHttpPipeline { val ordered = steps.flatten() 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..9cb0cc67 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,6 +8,7 @@ package org.dexpace.sdk.core.http.pipeline import org.dexpace.sdk.core.client.HttpClient +import org.dexpace.sdk.core.http.pipeline.steps.ThrowOnHttpErrorStep import org.dexpace.sdk.core.instrumentation.ClientLogger /** @@ -107,6 +108,15 @@ 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()) + /** * Builds an immutable [HttpPipeline] in stage order. [Stage.SEND] is reserved for the * transport and is skipped. 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..2214539d 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,9 @@ package org.dexpace.sdk.core.http.pipeline */ @Suppress("unused") public enum class Stage(public val order: Int, public val isPillar: Boolean) { + // -- Outermost: sees only the final response -- + PRE_REDIRECT(50, false), // runs *outside* the redirect+retry loops; sees the terminal response last + // -- 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/steps/AsyncThrowOnHttpErrorStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStep.kt new file mode 100644 index 00000000..3f432188 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStep.kt @@ -0,0 +1,56 @@ +/* + * 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.exception.HttpExceptionFactory +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. + * + * ## 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 (!HttpExceptionFactory.isErrorStatus(response.status.code)) { + 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/ThrowOnHttpErrorStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStep.kt new file mode 100644 index 00000000..1e7f1891 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStep.kt @@ -0,0 +1,68 @@ +/* + * 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.exception.HttpExceptionFactory +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 + * [HttpExceptionFactory.isErrorStatus] 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. + if (!HttpExceptionFactory.isErrorStatus(response.status.code)) 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/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..26164f65 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncThrowOnHttpErrorStepTest.kt @@ -0,0 +1,82 @@ +/* + * 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.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 `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/ThrowOnHttpErrorStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStepTest.kt new file mode 100644 index 00000000..d9ccb7a3 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStepTest.kt @@ -0,0 +1,163 @@ +/* + * 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.HttpException +import org.dexpace.sdk.core.http.response.exception.NotFoundException +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 `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() +} From c84cdc288919176254831782da5729f2be732333 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 03:39:28 +0300 Subject: [PATCH 13/46] feat: appendStandardResilience() and HttpPipeline.standard(transport) preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a turnkey preset that assembles the standard resilience pillars in one call. HttpPipelineBuilder.appendStandardResilience() appends DefaultRedirectStep, DefaultRetryStep, and DefaultInstrumentationStep with their no-arg defaults, each landing in its own pillar slot so the run order is fixed by Stage. The HttpPipeline.standard(transport) companion is the shorthand for building that pipeline over a transport. The async mirror — AsyncHttpPipelineBuilder.appendStandardResilience(scheduler) and AsyncHttpPipeline.standard(transport, scheduler) — appends the async retry and instrumentation defaults. It takes a ScheduledExecutorService because the async retry step schedules its non-blocking backoff on one, and it omits a redirect step since the SDK ships no async redirect default. --- sdk-core/api/sdk-core.api | 6 ++ .../core/http/pipeline/AsyncHttpPipeline.kt | 14 +++ .../http/pipeline/AsyncHttpPipelineBuilder.kt | 19 ++++ .../sdk/core/http/pipeline/HttpPipeline.kt | 11 ++ .../core/http/pipeline/HttpPipelineBuilder.kt | 15 +++ .../http/pipeline/StandardResilienceTest.kt | 102 ++++++++++++++++++ 6 files changed, 167 insertions(+) create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StandardResilienceTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 784c087d..c120d0a7 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -740,10 +740,12 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline { 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 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 { @@ -751,6 +753,7 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineBuilder { public fun (Lorg/dexpace/sdk/core/client/AsyncHttpClient;)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; @@ -783,10 +786,12 @@ public final class org/dexpace/sdk/core/http/pipeline/HttpPipeline { 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 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 { @@ -799,6 +804,7 @@ public final class org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder { public fun (Lorg/dexpace/sdk/core/client/HttpClient;)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; 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..01ada777 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 @@ -12,6 +12,7 @@ import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.response.Response 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 @@ -69,5 +70,18 @@ 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]. As documented there, there is no + * async redirect default, and [scheduler] backs the retry step's non-blocking delays + * (the caller owns and shuts it down; the SDK never closes it). + */ + @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 0b280ec0..b94a5f20 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 @@ -9,7 +9,10 @@ package org.dexpace.sdk.core.http.pipeline import org.dexpace.sdk.core.client.AsyncHttpClient 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 org.dexpace.sdk.core.instrumentation.ClientLogger +import java.util.concurrent.ScheduledExecutorService /** * Async counterpart of [HttpPipelineBuilder]. Mirrors the same API verbatim but typed over @@ -106,6 +109,22 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) { */ public fun throwOnHttpError(): AsyncHttpPipelineBuilder = append(AsyncThrowOnHttpErrorStep()) + /** + * Appends the async standard resilience defaults: [DefaultAsyncRetryStep] ([Stage.RETRY]) and + * [DefaultAsyncInstrumentationStep] ([Stage.LOGGING]). + * + * There is **no async redirect default** — the SDK ships no [Stage.REDIRECT] `AsyncHttpStep`, + * so (unlike the synchronous [HttpPipelineBuilder.appendStandardResilience]) redirect following + * is omitted here. Add a custom async redirect step yourself if you need it. + * + * [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. + */ + public fun appendStandardResilience(scheduler: ScheduledExecutorService): AsyncHttpPipelineBuilder = + append(DefaultAsyncRetryStep(scheduler)) + .append(DefaultAsyncInstrumentationStep()) + /** Builds an immutable [AsyncHttpPipeline]. */ public fun build(): AsyncHttpPipeline { val ordered = steps.flatten() 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..a3744910 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 @@ -52,5 +52,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 9cb0cc67..b39d852f 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,6 +8,9 @@ package org.dexpace.sdk.core.http.pipeline import org.dexpace.sdk.core.client.HttpClient +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 import org.dexpace.sdk.core.instrumentation.ClientLogger @@ -117,6 +120,18 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) { */ 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, so the + * relative run order is fixed by [Stage] regardless of the call site. Combine with + * [throwOnHttpError] for turnkey error mapping on top of the resilience stack. + */ + public fun appendStandardResilience(): HttpPipelineBuilder = + append(DefaultRedirectStep()) + .append(DefaultRetryStep()) + .append(DefaultInstrumentationStep()) + /** * Builds an immutable [HttpPipeline] in stage order. [Stage.SEND] is reserved for the * transport and is skipped. 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..b13b83e7 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/StandardResilienceTest.kt @@ -0,0 +1,102 @@ +/* + * 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.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") + } + + private fun getRequest(): Request = Request.builder().method(Method.GET).url("https://api.example.com/").build() +} From b9196cb0089cb27a5813b5ce311885ea79ad395d Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 03:56:58 +0300 Subject: [PATCH 14/46] refactor!: adding a second pillar step fails fast instead of warn-and-replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pillar stage in the staged HTTP pipeline admits exactly one step (redirect, retry, auth, logging, serde). Previously, appending or prepending a second step to an already-occupied pillar silently overwrote the first and only logged an SLF4J warning, so a misconfiguration — e.g. two retry steps — was easy to miss until runtime. Installing a distinct second step on an occupied pillar now throws IllegalStateException naming the stage, the existing step's type, and the incoming step's type, and points the caller at replace() to swap. Re-adding the same instance stays idempotent, and the replace() path is unchanged. The behaviour is symmetric across the sync and async builders; the enforcement lives in the shared StagedSteps helper, so the per-builder warning callback and its logger are gone. BREAKING CHANGE: a second distinct pillar step now throws rather than replacing the first. Callers that relied on the last-write-wins overwrite must use replace() explicitly. --- .../http/pipeline/AsyncHttpPipelineBuilder.kt | 21 +----- .../core/http/pipeline/HttpPipelineBuilder.kt | 23 ++---- .../sdk/core/http/pipeline/StagedSteps.kt | 24 ++++-- .../http/pipeline/AsyncHttpPipelineTest.kt | 34 +++++++-- .../core/http/pipeline/HttpPipelineTest.kt | 52 ++++++------- .../sdk/core/http/pipeline/StagedStepsTest.kt | 73 ++++++++----------- 6 files changed, 108 insertions(+), 119 deletions(-) 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 b94a5f20..27685e49 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 @@ -11,28 +11,17 @@ import org.dexpace.sdk.core.client.AsyncHttpClient 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 org.dexpace.sdk.core.instrumentation.ClientLogger 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. */ 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) /** 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) } @@ -132,8 +121,6 @@ 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. */ @JvmStatic public fun from(pipeline: AsyncHttpPipeline): AsyncHttpPipelineBuilder = 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 b39d852f..fbbd671a 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 @@ -12,12 +12,10 @@ 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 -import org.dexpace.sdk.core.instrumentation.ClientLogger /** * 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 @@ -26,21 +24,12 @@ 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. */ 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) /** 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) } @@ -142,8 +131,6 @@ 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. */ @JvmStatic public fun from(pipeline: HttpPipeline): HttpPipelineBuilder = 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..7cb97935 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() @@ -169,7 +169,15 @@ internal class StagedSteps( ) { 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) + 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.", + ) + } pillars[stage] = step } } 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..949bf3d7 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 @@ -28,6 +28,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 @@ -347,15 +348,36 @@ 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(auth) + .append(auth) + .build() + 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(firstAuth) - .append(secondAuth) + .append(TestPillarStep(Stage.AUTH)) + .replace(replacement) .build() - assertSame(secondAuth, pipeline.steps.single()) + assertSame(replacement, pipeline.steps.single()) } @Test 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..53cc68aa 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,6 +9,8 @@ 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.response.Response @@ -130,23 +132,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 @@ -626,26 +635,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() - - pipeline.send(request()) + .prepend(DefaultRetryStep()) - // 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..fce6452b 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) } @@ -110,21 +90,26 @@ class StagedStepsTest { // -------- 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 -------- From c7a8cb5d3b0f6344a4bfe86d3d991f65a4a47d65 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 03:57:40 +0300 Subject: [PATCH 15/46] refactor!: HttpPipeline/AsyncHttpPipeline implement the HttpClient SPIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpPipeline now implements HttpClient (execute delegates to send) and AsyncHttpPipeline implements AsyncHttpClient (executeAsync delegates to sendAsync). send/sendAsync remain the primary entry points; the SPI methods are thin conformance delegates. This lets a fully configured pipeline stand in anywhere a transport is expected — most usefully as the client backing a Paginator/AsyncPaginator, so every page request runs through the pipeline's redirect / retry / auth / logging steps instead of hitting the raw transport. The inherited close() stays a no-op: a pipeline wraps a caller-supplied transport it does not own and must not close. BREAKING CHANGE: HttpPipeline and AsyncHttpPipeline gain a public supertype (HttpClient / AsyncHttpClient) and the corresponding execute/executeAsync/close members. The .api snapshot is updated accordingly. --- sdk-core/api/sdk-core.api | 8 ++- .../core/http/pipeline/AsyncHttpPipeline.kt | 15 ++++- .../sdk/core/http/pipeline/HttpPipeline.kt | 16 +++++- .../http/pipeline/AsyncHttpPipelineTest.kt | 54 ++++++++++++++++++ .../core/http/pipeline/HttpPipelineTest.kt | 57 +++++++++++++++++++ 5 files changed, 146 insertions(+), 4 deletions(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index c120d0a7..3afcce9b 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -734,8 +734,10 @@ 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 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; @@ -780,8 +782,10 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncPipelineNext { 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/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 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; 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 01ada777..be978c88 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 @@ -25,13 +25,20 @@ import java.util.concurrent.ScheduledExecutorService * 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() @@ -60,6 +67,12 @@ public class AsyncHttpPipeline internal constructor( return AsyncPipelineNext(state).processAsync() } + /** + * [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) + public companion object { /** * Builds a step-less [AsyncHttpPipeline] that forwards every `sendAsync` directly to 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 a3744910..1b455407 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 @@ -22,13 +22,20 @@ 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() @@ -43,6 +50,13 @@ public class HttpPipeline internal constructor( 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) + public companion object { /** * Builds a step-less [HttpPipeline] that forwards every `send` directly to [client]. 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 949bf3d7..f54cd587 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 @@ -14,6 +14,9 @@ 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.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 @@ -380,6 +383,57 @@ class AsyncHttpPipelineTest { 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 fun `installing a pillar at Stage_SEND is rejected`() { val sendStep = TestPillarStep(Stage.SEND) 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 53cc68aa..97255a4d 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 @@ -15,6 +15,9 @@ 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.pagination.PageInfo +import org.dexpace.sdk.core.pagination.PaginationStrategy +import org.dexpace.sdk.core.pagination.Paginator import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals @@ -69,6 +72,60 @@ class HttpPipelineTest { assertTrue(pipeline.steps.isEmpty()) } + @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() From f3830bf1ba671593720e9d438bd5aa5b223c5bec Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 04:17:23 +0300 Subject: [PATCH 16/46] feat: transports surface NetworkException for transport-level IO failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reference transports (OkHttp and java.net.http) now wrap a non-interrupt transport-level IOException — connection refused, DNS lookup failure, peer reset, TLS handshake failure, a non-interrupt timeout — in NetworkException before it leaves execute()/executeAsync(), instead of propagating the raw client exception. NetworkException is itself an IOException, so existing catch (IOException) sites are unaffected; callers that catch NetworkException now match the documented contract, and both retry stacks already classify it as retryable (it is a Retryable and an IOException). Interrupt semantics are preserved: an InterruptedIOException (including OkHttp's SocketTimeoutException and a thread interrupted mid-send on the JDK client) passes through untouched with the interrupt flag re-asserted, never repackaged as a NetworkException. The wrapping is scoped to the client dispatch call only, so a later response-adaptation failure keeps its own exception type. The JDK async path routes the exchange future's failure through a mapper that strips CompletionException/ExecutionException envelopes before classifying the root cause; the AsyncResponseBridge gains an opt-in error-mapping hook for that. --- .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 51 +++++++++----- .../jdkhttp/internal/AsyncResponseBridge.kt | 6 +- .../jdkhttp/internal/TransportFailures.kt | 48 +++++++++++++ .../transport/jdkhttp/JdkHttpTransportTest.kt | 65 +++++++++++++++++ .../jdkhttp/internal/TransportFailuresTest.kt | 70 +++++++++++++++++++ .../sdk/transport/okhttp/OkHttpTransport.kt | 25 ++++++- .../transport/okhttp/OkHttpTransportTest.kt | 64 +++++++++++++++++ 7 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailures.kt create mode 100644 sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailuresTest.kt 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 a67e8d7d..f2db8d1c 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 @@ -11,12 +11,14 @@ 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.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 @@ -107,23 +109,33 @@ public class JdkHttpTransport private constructor( @Throws(IOException::class) override fun execute(request: Request): Response { val jdkRequest = requestAdapter.adapt(request, responseTimeout) - return try { - val jdkResponse: HttpResponse = + // 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) } /** @@ -155,7 +167,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 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/TransportFailures.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailures.kt new file mode 100644 index 00000000..733c01c7 --- /dev/null +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/TransportFailures.kt @@ -0,0 +1,48 @@ +/* + * 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.util.concurrent.CompletionException +import java.util.concurrent.ExecutionException + +/** + * 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 [CompletionException] / + * [ExecutionException]. This strips those envelopes 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 = unwrapCompletion(error)) { + is InterruptedIOException -> error + is IOException -> NetworkException(cause.message, cause) + else -> error + } + +/** Strips [CompletionException] / [ExecutionException] envelopes to reach the underlying cause. */ +private fun unwrapCompletion(error: Throwable): Throwable { + var current = error + while (current is CompletionException || current is ExecutionException) { + val cause = current.cause ?: break + if (cause === current) break + current = cause + } + return current +} 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..ce18ec59 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 @@ -17,6 +17,7 @@ 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.response.exception.NetworkException import org.dexpace.sdk.core.instrumentation.DroppedHeaderLogging import org.dexpace.sdk.core.io.BufferedSink import org.dexpace.sdk.core.io.Io @@ -733,6 +734,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/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..2a327caa 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 @@ -16,6 +16,7 @@ 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.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 @@ -94,6 +95,15 @@ public class OkHttpTransport private constructor( } 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 timeout — produced no response on + // the wire. Surface it as the SDK's NetworkException. Because NetworkException is + // itself an IOException, existing `catch (IOException)` sites keep matching, while + // the retry classifier now sees the retryable transport-failure contract. (The + // InterruptedIOException branch above runs first, so an interrupt-derived failure — + // including OkHttp's SocketTimeoutException — is never wrapped here.) + throw asNetworkException(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 @@ -156,7 +166,10 @@ public class OkHttpTransport private constructor( call: Call, e: IOException, ) { - future.completeExceptionally(e) + // Mirror the sync path's transport-error contract: a non-interrupt IOException + // is delivered as a NetworkException, an interrupt-derived one passes through + // untouched. + future.completeExceptionally(asNetworkException(e)) } }, ) @@ -471,3 +484,13 @@ public class OkHttpTransport private constructor( } } } + +/** + * Maps an OkHttp transport-level [IOException] onto the SDK's [NetworkException] contract. An + * interrupt-derived failure ([InterruptedIOException], including OkHttp's `SocketTimeoutException`) + * is returned untouched so callers keep observing it as an interrupt; every other transport + * [IOException] is wrapped in a [NetworkException] — itself an [IOException], so existing + * `catch (IOException)` sites are unaffected and the retry classifier sees a retryable failure. + */ +private fun asNetworkException(e: IOException): IOException = + if (e is InterruptedIOException) e else NetworkException(e.message, e) 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..1fd81e1e 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 @@ -22,6 +22,7 @@ import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.request.RequestBody 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 @@ -521,6 +522,69 @@ class OkHttpTransportTest { 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: an InterruptedIOException surfaces as-is (with + // the interrupt flag re-asserted), never repackaged as a NetworkException. + val interceptedTransport = + OkHttpTransport.create( + OkHttpClient.Builder() + .addInterceptor { + throw java.io.InterruptedIOException("simulated interrupt") + } + .build(), + ) + val captured = + assertFails { + interceptedTransport.execute(simpleGet("/never-served")) + } + 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() + } + // -------- cancellation -------- @Test From eba18e1e856f59f9a61a9aaa37328f2a444944f6 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 04:19:13 +0300 Subject: [PATCH 17/46] feat: opt-in proxyFromEnvironment() on both transport builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a proxyFromEnvironment() method to the OkHttpTransport and JdkHttpTransport builders. It resolves proxy settings from the process-wide global Configuration via ProxyOptions.fromConfiguration — the standard HTTPS_PROXY / HTTP_PROXY / NO_PROXY environment variables plus their https.proxy* / http.nonProxyHosts system-property equivalents — and applies the resolved proxy to the client being built. The method is strictly opt-in: the environment is never consulted unless it is called, and it is a no-op when nothing there configures a proxy (or when NO_PROXY=* bypasses everything). It writes the same slot as proxy(...), so an explicit proxy() and proxyFromEnvironment() follow last-writer-wins. --- .../api/sdk-transport-jdkhttp.api | 1 + .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 18 +++++ .../transport/jdkhttp/JdkHttpTransportTest.kt | 81 +++++++++++++++++++ .../api/sdk-transport-okhttp.api | 1 + .../sdk/transport/okhttp/OkHttpTransport.kt | 18 +++++ .../transport/okhttp/OkHttpTransportTest.kt | 75 +++++++++++++++++ 6 files changed, 194 insertions(+) diff --git a/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api b/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api index 661e8c57..86521118 100644 --- a/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api +++ b/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api @@ -18,6 +18,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 f2db8d1c..ed8aa6f8 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 @@ -9,6 +9,7 @@ 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.config.Configuration import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.response.Response import org.dexpace.sdk.core.http.response.exception.NetworkException @@ -366,6 +367,23 @@ public class JdkHttpTransport private constructor( this.proxy = p } + /** + * Reads proxy settings from the process-wide [Configuration.getGlobalConfiguration] via + * [ProxyOptions.fromConfiguration] — the standard `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` + * environment variables and their `https.proxy*` / `http.nonProxyHosts` system-property + * equivalents — and applies the resolved proxy to the client being built. + * + * 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.fromConfiguration(Configuration.getGlobalConfiguration())?.let { + this.proxy = it + } + } + /** * Whether the JDK should follow 3xx redirects automatically. `true` → * [java.net.http.HttpClient.Redirect.NORMAL]; `false` (default) → 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 ce18ec59..e0dde084 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,6 +11,7 @@ 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 @@ -659,6 +660,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 diff --git a/sdk-transport-okhttp/api/sdk-transport-okhttp.api b/sdk-transport-okhttp/api/sdk-transport-okhttp.api index 529de89c..f4d6485e 100644 --- a/sdk-transport-okhttp/api/sdk-transport-okhttp.api +++ b/sdk-transport-okhttp/api/sdk-transport-okhttp.api @@ -17,6 +17,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 2a327caa..75946af8 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 @@ -14,6 +14,7 @@ import okhttp3.Credentials import okhttp3.OkHttpClient import org.dexpace.sdk.core.client.AsyncHttpClient import org.dexpace.sdk.core.client.HttpClient +import org.dexpace.sdk.core.config.Configuration import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.response.Response import org.dexpace.sdk.core.http.response.exception.NetworkException @@ -341,6 +342,23 @@ public class OkHttpTransport private constructor( this.proxy = p } + /** + * Reads proxy settings from the process-wide [Configuration.getGlobalConfiguration] via + * [ProxyOptions.fromConfiguration] — the standard `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` + * environment variables and their `https.proxy*` / `http.nonProxyHosts` system-property + * equivalents — and applies the resolved proxy to the client being built. + * + * 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.fromConfiguration(Configuration.getGlobalConfiguration())?.let { + this.proxy = it + } + } + /** * Whether OkHttp should follow 3xx redirects automatically. Defaults to `false` * because the SDK pipeline owns redirect handling via `DefaultRedirectStep`. 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 1fd81e1e..ae3c030e 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,6 +14,7 @@ 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 @@ -783,6 +784,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 From e39ae69f35eb0c298cc3e4652e0e41e5f6a7fa7e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 04:31:54 +0300 Subject: [PATCH 18/46] feat: async CompletableFuture.handleWith(handler) terminal Add a terminal operator that maps an async Response future to a typed result via a ResponseHandler, mirroring how a ResponseHandler is invoked on a synchronous Response: - CompletableFuture.handleWith(handler) applies handler.handle on success and guarantees the Response is closed afterward (idempotent double close is safe even for handlers that read nothing), and on failure fails the returned future with the unwrapped cause so callers see the real transport/step failure rather than a Completion/Execution wrapper. - AsyncHttpPipeline.sendAsync(request, handler) as a convenience over sendAsync(request).handleWith(handler). --- sdk-core/api/sdk-core.api | 5 + .../core/http/pipeline/AsyncHttpPipeline.kt | 12 ++ .../http/pipeline/AsyncResponseHandlers.kt | 50 +++++++ .../pipeline/AsyncResponseHandlersTest.kt | 134 ++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlers.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncResponseHandlersTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 3afcce9b..44909ab3 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -742,6 +742,7 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline : org/de 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/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; } @@ -782,6 +783,10 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncPipelineNext { public final fun processAsync (Lorg/dexpace/sdk/core/http/request/Request;)Ljava/util/concurrent/CompletableFuture; } +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 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 be978c88..6270692b 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 @@ -10,6 +10,7 @@ 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.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 @@ -67,6 +68,17 @@ public class AsyncHttpPipeline internal constructor( 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) + /** * [AsyncHttpClient] SPI conformance: delegates to [sendAsync] so this pipeline can be used as * an async transport. [sendAsync] remains the primary entry point. 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/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() +} From 0b228570cffeebea48ef0125ddc9df9db1d14d55 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 04:32:08 +0300 Subject: [PATCH 19/46] fix: unify asAsync/toAsync cancellation on the interrupting path HttpPipeline.toAsync(executor) returned a future whose cancel(true) interrupts the blocking worker, but HttpClient.asAsync(executor) used a plain supplyAsync whose cancel(true) did nothing and leaked the running request. Extract the interrupting-future construction into a single internal helper (Futures.interruptibleFuture) that captures the worker thread and interrupts it on cancel(true), and route both bridges through it so cancellation behaves identically: cancel(true) interrupts the in-flight blocking send, cancel(false) lets it run to completion, and a not-yet-started or already-finished task is never interrupted. --- .../sdk/core/client/AsyncHttpClient.kt | 17 +++-- .../http/pipeline/AsyncPipelineBridges.kt | 68 +----------------- .../org/dexpace/sdk/core/util/Futures.kt | 70 +++++++++++++++++++ .../sdk/core/client/AsyncHttpClientTest.kt | 67 ++++++++++++++++++ 4 files changed, 152 insertions(+), 70 deletions(-) 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..74731e21 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 @@ -12,6 +12,7 @@ package org.dexpace.sdk.core.client import org.dexpace.sdk.core.http.request.Request 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 @@ -94,13 +95,21 @@ 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) + interruptibleFuture(executor) { execute(request) } } /** 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..58935b4c 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 @@ -11,12 +11,9 @@ 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.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 +44,7 @@ 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) - } - - 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() - } - } - return result + return AsyncHttpPipeline.of { request -> interruptibleFuture(executor) { sync.send(request) } } } /** 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..db08d2e5 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,10 @@ 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.AtomicReference import java.time.Duration as JDuration /** @@ -88,3 +90,71 @@ 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() + + fun bindWorker(thread: Thread) { + worker.set(thread) + } + + 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 [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() + 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 task entirely. + if (result.isDone) return@execute + result.complete(task()) + } 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() + } + } + return result +} 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..1237ac28 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 @@ -25,6 +25,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails +import kotlin.test.assertFalse import kotlin.test.assertTrue class AsyncHttpClientTest { @@ -114,6 +115,72 @@ 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 `round-trip via asAsync then asBlocking preserves the response`() { val syncClient = HttpClient { request -> mockResponse(request, 201) } From 85f330740412838583083339ef57db7fc40a52d5 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 04:50:58 +0300 Subject: [PATCH 20/46] refactor!: pagination strategies take SAM extractors, not Function1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cursor, Link-header, and page-number pagination strategies each took a raw Kotlin function type (`(Response) -> CursorResult` / `-> List`) as their extractor constructor parameter. From Java that surfaced as `kotlin.jvm.functions.Function1` — an awkward, mangled type to implement. Replace each with a dedicated single-method functional interface — `CursorExtractor`, `LinkExtractor`, `PageNumberExtractor` — mirroring the exact signature it replaces. Kotlin call sites pass a lambda unchanged via SAM conversion; Java callers now implement a clean, named interface (or pass a lambda) instead of `Function1`. This is a source- and binary-breaking change to the three strategy constructors; the `.api` snapshot is updated accordingly. --- sdk-core/api/sdk-core.api | 32 +++++++++----- .../pagination/CursorPaginationStrategy.kt | 23 +++++++++- .../LinkHeaderPaginationStrategy.kt | 19 +++++++- .../PageNumberPaginationStrategy.kt | 19 +++++++- .../sdk/core/pagination/AsyncPaginatorTest.kt | 9 ++-- .../core/pagination/CursorPaginationTest.kt | 43 +++++++++++++++---- .../core/pagination/CursorSingleReadTest.kt | 18 ++++---- .../sdk/core/pagination/LazinessTest.kt | 10 ++--- .../pagination/LinkHeaderPaginationTest.kt | 33 ++++++++++++-- .../pagination/PageNumberPaginationTest.kt | 38 +++++++++++++--- .../core/pagination/PaginatorByPageTest.kt | 20 ++++----- .../sdk/core/pagination/PaginatorCapTest.kt | 10 ++--- .../sdk/core/pagination/PaginatorCloseTest.kt | 20 ++++----- 13 files changed, 218 insertions(+), 76 deletions(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 44909ab3..6b32fd90 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2397,10 +2397,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; } @@ -2421,10 +2425,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/LinkExtractor { + 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/LinkExtractor;)V + public fun (Lorg/dexpace/sdk/core/pagination/LinkExtractor;Ljava/lang/String;)V + public synthetic fun (Lorg/dexpace/sdk/core/pagination/LinkExtractor;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; } @@ -2459,11 +2467,15 @@ public final class org/dexpace/sdk/core/pagination/PageInfo { public final fun getNextRequest ()Lorg/dexpace/sdk/core/http/request/Request; } +public abstract interface class org/dexpace/sdk/core/pagination/PageNumberExtractor { + public abstract fun extract (Lorg/dexpace/sdk/core/http/response/Response;)Ljava/util/List; +} + 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/PageNumberExtractor;)V + public fun (Lorg/dexpace/sdk/core/pagination/PageNumberExtractor;Ljava/lang/String;)V + public fun (Lorg/dexpace/sdk/core/pagination/PageNumberExtractor;Ljava/lang/String;I)V + public synthetic fun (Lorg/dexpace/sdk/core/pagination/PageNumberExtractor;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; } 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/LinkHeaderPaginationStrategy.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt index 63538c04..929bc72d 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 @@ -12,6 +12,21 @@ import org.dexpace.sdk.core.http.response.Response import java.net.MalformedURLException import java.net.URL +/** + * Items extractor for [LinkHeaderPaginationStrategy]: reads the list of items from a page + * [Response] (the next-page URL comes from the `Link` header, not this extractor). + * + * 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. + * + * @param T Element type read from the response body. + */ +public fun interface LinkExtractor { + /** Reads the list of items from [response]. Must drain the response body synchronously. */ + public fun extract(response: Response): List +} + /** * RFC 5988 `Link` header [PaginationStrategy]. The server emits a `Link` header on each * page response with a `rel="next"` segment carrying the next page's full URL; the @@ -46,14 +61,14 @@ import java.net.URL public class LinkHeaderPaginationStrategy @JvmOverloads constructor( - private val itemsExtractor: (Response) -> List, + private val itemsExtractor: LinkExtractor, 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..89e907b8 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 @@ -10,6 +10,21 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.response.Response +/** + * Items extractor for [PageNumberPaginationStrategy]: reads the list of items from a page + * [Response] (the next page number is derived from the request URL, not this extractor). + * + * 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. + * + * @param T Element type read from the response body. + */ +public fun interface PageNumberExtractor { + /** Reads the list of items from [response]. Must drain the response body synchronously. */ + public fun extract(response: Response): List +} + /** * Page-number [PaginationStrategy]. The client sends a 1-based (or [startPage]-based) * page number as a query parameter; the server returns the corresponding slice of items. @@ -38,7 +53,7 @@ import org.dexpace.sdk.core.http.response.Response public class PageNumberPaginationStrategy @JvmOverloads constructor( - private val itemsExtractor: (Response) -> List, + private val itemsExtractor: PageNumberExtractor, private val pageParam: String = "page", private val startPage: Int = 1, ) : PaginationStrategy { @@ -46,7 +61,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/test/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginatorTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginatorTest.kt index 4fffa22f..cd8376b4 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 = + LinkExtractor { 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..a32a4e21 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 = + PageNumberExtractor { 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..1ca3e8ba 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 = + LinkExtractor { 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 LinkExtractor implementation, not a Kotlin function type. + val javaStyle = + object : LinkExtractor { + 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..e60a38d2 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 = + PageNumberExtractor { 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 PageNumberExtractor implementation, not a Kotlin function type. + val javaStyle = + object : PageNumberExtractor { + 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..8b49b660 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 = + LinkExtractor { 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..1f4f4995 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 @@ -32,15 +31,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 = From 7db7482faebcf29ee5c308436fe92247ed69d8e7 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 04:51:35 +0300 Subject: [PATCH 21/46] feat: dep-free TypeRef parametric decode + status-aware json handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decoding a parametric response (`List`, `Map`) previously forced the call site to name a Jackson `TypeReference`, coupling application code to the serde library the SPI exists to hide. A bare `Class` token can't carry the element type. Add `TypeRef`, a dependency-free type-capture base in sdk-core built purely on `java.lang.reflect` (the Gson `TypeToken` pattern): `object : TypeRef>() {}` records the generic superclass and reads the reflective `Type` and erased `rawClass` back out. `Deserializer` gains `deserialize(input, TypeRef)` for String and InputStream, with a default that delegates to the existing `Class` overload when the ref wraps a plain class and otherwise fails clearly — so a format-agnostic deserializer never silently returns a map. `JacksonSerde` overrides these to resolve the full type via `ObjectMapper.constructType`, so `List` decodes with no Jackson type at the call site. Also add `jsonHandlerOrThrow` (Class and TypeReference overloads): a status-aware response handler that decodes only on 2xx and, on a non-2xx response, throws the mapped `HttpException` (carrying the unconsumed error body) instead of trying to deserialize the error payload as the success type. The plain `jsonHandler` is unchanged. --- sdk-core/api/sdk-core.api | 13 ++++ .../dexpace/sdk/core/serde/Deserializer.kt | 53 +++++++++++++ .../org/dexpace/sdk/core/serde/TypeRef.kt | 75 +++++++++++++++++++ .../sdk/core/serde/DeserializerTest.kt | 31 ++++++++ .../org/dexpace/sdk/core/serde/TypeRefTest.kt | 61 +++++++++++++++ sdk-serde-jackson/api/sdk-serde-jackson.api | 2 + .../dexpace/sdk/serde/jackson/JacksonSerde.kt | 28 +++++++ .../sdk/serde/jackson/JsonResponseHandler.kt | 64 ++++++++++++++++ .../sdk/serde/jackson/JacksonSerdeTest.kt | 27 +++++++ .../serde/jackson/JsonResponseHandlerTest.kt | 42 ++++++++++- 10 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/TypeRef.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/TypeRefTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 6b32fd90..0c82b440 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2760,10 +2760,17 @@ 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 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 abstract interface class org/dexpace/sdk/core/serde/Serde { public fun contentType ()Lorg/dexpace/sdk/core/http/common/MediaType; public abstract fun getDeserializer ()Lorg/dexpace/sdk/core/serde/Deserializer; @@ -2841,6 +2848,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; 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..5625f013 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 @@ -60,6 +60,59 @@ 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) } +} + +/** + * 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).", + ) } /** Decode a complete document from the in-memory [input] string into the reified type [T]. */ 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..cfe78474 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/TypeRef.kt @@ -0,0 +1,75 @@ +/* + * 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>() {}." + } + type = superClass.actualTypeArguments.first() + rawClass = rawClassOf(type) + } + + private companion object { + /** 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/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..fada55f6 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/TypeRefTest.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.serde + +import java.lang.reflect.ParameterizedType +import kotlin.test.Test +import kotlin.test.assertEquals +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, + ) + + @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) + } +} diff --git a/sdk-serde-jackson/api/sdk-serde-jackson.api b/sdk-serde-jackson/api/sdk-serde-jackson.api index f49944d0..c257b9a6 100644 --- a/sdk-serde-jackson/api/sdk-serde-jackson.api +++ b/sdk-serde-jackson/api/sdk-serde-jackson.api @@ -32,6 +32,8 @@ public final class org/dexpace/sdk/serde/jackson/JacksonSerde$Companion { public final class org/dexpace/sdk/serde/jackson/JsonResponseHandlerKt { 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 9560c6b0..b973597a 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 @@ -17,6 +17,7 @@ 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 @@ -227,6 +228,33 @@ internal class JacksonDeserializer internal constructor( deserializing { mapper.readValue(p, type) } } } + + /** + * 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 = deserializing { mapper.readValue(input, mapper.constructType(ref.type)) } + + /** + * 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 -> + deserializing { mapper.readValue(p, mapper.constructType(ref.type)) } + } + } } /** 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..5318d661 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 @@ -10,6 +10,7 @@ package org.dexpace.sdk.serde.jackson import com.fasterxml.jackson.core.type.TypeReference import org.dexpace.sdk.core.http.response.Response import org.dexpace.sdk.core.http.response.ResponseHandler +import org.dexpace.sdk.core.http.response.exception.HttpExceptionFactory import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.SerdeException @@ -62,6 +63,69 @@ 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 [HttpExceptionFactory] to the matching + * [org.dexpace.sdk.core.http.response.exception.HttpException] subclass, which carries the + * still-unconsumed error [org.dexpace.sdk.core.http.response.ResponseBody] — the caught exception + * owns closing it (e.g. via `bodySnapshot()` or typed error-body deserialization). 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 [HttpExceptionFactory] exception + * (which retains the live error body for the caller to inspect and close); any other non-success + * status closes the response and raises a [SerdeException]. 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 (HttpExceptionFactory.isErrorStatus(status.code)) { + // Hand the still-unconsumed error body to the mapped HttpException; the caught exception owns + // reading and closing it. Do NOT close here or bodySnapshot()/typed error decode would fail. + throw HttpExceptionFactory.fromResponse(response) + } + response.close() + throw SerdeException( + "Unexpected non-success HTTP status ${status.code}; expected a 2xx response to deserialize.", + ) +} + /** * 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. 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 fc40630e..a1d6bc48 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 @@ -19,6 +19,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 @@ -148,6 +149,32 @@ 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 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() 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..346c6c6f 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,6 +16,7 @@ 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.exception.HttpException import org.dexpace.sdk.core.io.BufferedSource import org.dexpace.sdk.core.io.Io import org.dexpace.sdk.core.serde.SerdeException @@ -34,13 +35,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 +120,40 @@ 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 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 `jsonHandler composes with ParsedResponse for lazy parse-once`() { val serde = JacksonSerde.withDefaults() From fcbe65794099d1dcecaba6d936c53ddd8fdc920f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 04:58:08 +0300 Subject: [PATCH 22/46] refactor!: demote ETag/HttpRange to plain classes and add String conditional-header overloads ETag and HttpRange were @JvmInline value classes, so Kotlin mangled the JVM names of their factories and accessors (e.g. parse-1jReFK8, bytes-Oepi37k, Builder.ifMatch-a6HYibk). Those suffixes are not legal Java identifiers, which left the entire typed conditional-request / range surface unreachable from Java. Demote both to plain immutable final classes with hand-written equals/hashCode/ toString over the wrapped header string, preserving every factory, accessor, and flag. The factories now compile to clean, unmangled names (ETag.parse, ETag.strong, ETag.weak, HttpRange.bytes/from/suffix/parse) and ETag.ANY becomes a plain static field. Boxing is irrelevant for these header helpers. Add String overloads ifMatch(String)/ifNoneMatch(String) on RequestConditions.Builder that parse a raw RFC 7232 header value, giving Java callers a String escape hatch alongside the typed ETag overloads (which are also now unmangled). Blank/malformed values are rejected with IllegalArgumentException. --- sdk-core/api/sdk-core.api | 63 ++++++++---------- .../org/dexpace/sdk/core/http/common/ETag.kt | 21 ++++-- .../dexpace/sdk/core/http/common/HttpRange.kt | 12 +++- .../sdk/core/http/common/RequestConditions.kt | 25 ++++++++ .../dexpace/sdk/core/http/common/ETagTest.kt | 4 +- .../core/http/common/RequestConditionsTest.kt | 64 +++++++++++++++++++ 6 files changed, 142 insertions(+), 47 deletions(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 0c82b440..bef0480f 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -352,32 +352,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,27 +507,22 @@ 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 { @@ -641,9 +630,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; } 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..24bcaccb 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,12 +51,10 @@ 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("*") /** 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..afeaad80 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 @@ -17,14 +17,22 @@ package org.dexpace.sdk.core.http.common * * Round-trips with [toHeaderValue] / [parse]: parsing preserves the original textual * form, so unusual but valid whitespace and casing survive a parse/format cycle. + * + * 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)`. 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..7f13ae4c 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 @@ -79,9 +79,27 @@ 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 } @@ -95,6 +113,13 @@ public class RequestConditions private constructor( 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" } } public companion object { 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/RequestConditionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/RequestConditionsTest.kt index 3284bc0e..ed917d17 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,69 @@ 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 rc = + RequestConditions.builder() + .ifNoneMatch("W/\"v1\"") + .ifNoneMatch("*") + .build() + assertEquals(listOf(ETag.weak("v1"), ETag.ANY), rc.ifNoneMatch) + } + + @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() From 2f707ac0a622810d0442cca0d4ee2d0d8d977002 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 05:08:12 +0300 Subject: [PATCH 23/46] feat!: add RequestOptions carrier Introduce RequestOptions, an immutable per-request override carrier holding a timeout, an opaque tag map, and a maxRetries budget. Every field defaults to null/empty, meaning use-the-pipeline/transport-default, and a shared EMPTY instance represents override-nothing. Follows the SDK's private-ctor + Builder + newBuilder() convention with a @JvmStatic builder() and value equality. This is the model half of per-request overrides; later commits thread it through the transport SPIs, the pipeline, and the retry step. --- sdk-core/api/sdk-core.api | 28 ++++ .../sdk/core/http/request/RequestOptions.kt | 133 ++++++++++++++++ .../core/http/request/RequestOptionsTest.kt | 146 ++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestOptionsTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index bef0480f..dc2037f8 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -1424,6 +1424,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 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..fb3d1654 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt @@ -0,0 +1,133 @@ +/* + * 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. Applied by + * the transport as the per-call timeout (OkHttp: the call timeout; JDK: the per-request timeout). + * @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). + */ + public fun timeout(timeout: Duration?): Builder = + apply { + 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, 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/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..2b84a3a6 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestOptionsTest.kt @@ -0,0 +1,146 @@ +/* + * 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.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertNull +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 `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()) + } +} From a3687e8cd0a0dea4c9d75550e81b31f914df7891 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 05:12:58 +0300 Subject: [PATCH 24/46] feat: transports honor per-call RequestOptions.timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-call execute(request, options) / executeAsync(request, options) overload to the HttpClient and AsyncHttpClient SPIs. The overload is a default method that ignores options and delegates to the single-argument form, so both SPIs stay single-abstract-method fun interfaces and every existing implementer (SAM literals, older transports, test fakes) keeps compiling unchanged. Both reference transports override the overload to apply RequestOptions.timeout as the per-call timeout: OkHttp sets it on Call.timeout() (overriding the client's callTimeout for this call only), and the JDK transport resolves it against the configured responseTimeout and sets it via HttpRequest.timeout. A null timeout keeps the configured default. The single-argument execute paths now delegate to the overload with RequestOptions.EMPTY. Tests cover a short per-call timeout firing on a slow response, a generous per-call timeout not prematurely failing, and the EMPTY no-override path — sync and async, on both transports. --- sdk-core/api/sdk-core.api | 6 ++ .../sdk/core/client/AsyncHttpClient.kt | 14 ++++ .../org/dexpace/sdk/core/client/HttpClient.kt | 14 ++++ .../api/sdk-transport-jdkhttp.api | 2 + .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 56 +++++++++++--- .../transport/jdkhttp/JdkHttpTransportTest.kt | 67 +++++++++++++++++ .../api/sdk-transport-okhttp.api | 2 + .../sdk/transport/okhttp/OkHttpTransport.kt | 57 ++++++++++++-- .../transport/okhttp/OkHttpTransportTest.kt | 74 +++++++++++++++++++ 9 files changed, 276 insertions(+), 16 deletions(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index dc2037f8..b0d5cfce 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 { @@ -729,6 +733,7 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline : org/de 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; @@ -782,6 +787,7 @@ public final class org/dexpace/sdk/core/http/pipeline/HttpPipeline : org/dexpace 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; 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 74731e21..21163420 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,6 +10,7 @@ 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 @@ -72,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] 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..4a9b7c85 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,6 +8,7 @@ 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 /** @@ -50,6 +51,19 @@ public fun interface HttpClient : AutoCloseable { */ 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. + */ + 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-transport-jdkhttp/api/sdk-transport-jdkhttp.api b/sdk-transport-jdkhttp/api/sdk-transport-jdkhttp.api index 86521118..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 { 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 ed8aa6f8..3df98a44 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 @@ -11,6 +11,7 @@ import org.dexpace.sdk.core.client.AsyncHttpClient import org.dexpace.sdk.core.client.HttpClient import org.dexpace.sdk.core.config.Configuration 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 @@ -101,15 +102,27 @@ 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) + 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 = @@ -150,15 +163,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 = + 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): CompletableFuture { + 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 @@ -195,6 +222,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]: * 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 e0dde084..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 @@ -18,6 +18,7 @@ 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 @@ -196,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 diff --git a/sdk-transport-okhttp/api/sdk-transport-okhttp.api b/sdk-transport-okhttp/api/sdk-transport-okhttp.api index f4d6485e..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 { 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 75946af8..10301644 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 @@ -16,6 +16,7 @@ import org.dexpace.sdk.core.client.AsyncHttpClient import org.dexpace.sdk.core.client.HttpClient import org.dexpace.sdk.core.config.Configuration 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 @@ -82,14 +83,28 @@ 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. Honours `Thread.interrupt` + * via `InterruptedIOException`: the interrupt flag is re-asserted before the exception is + * rethrown so callers see a consistent interrupt state. + */ + @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() @@ -117,9 +132,24 @@ public class OkHttpTransport private constructor( * 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) @@ -139,6 +169,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 { @@ -190,6 +221,20 @@ 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 { call.timeout().timeout(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 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 ae3c030e..a4c8bb45 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 @@ -21,6 +21,7 @@ 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 @@ -523,6 +524,79 @@ class OkHttpTransportTest { 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() + val ex = + assertFails { + transport.execute(simpleGet("/slow-percall"), options).close() + } + assertTrue(ex is IOException, "expected IOException, got ${ex::class}") + // OkHttp's call-timeout path may leave the interrupt flag set; clear it and let + // MockWebServer's pending dispatch drain before @StartStop shuts it down. + Thread.interrupted() + 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 cause is the transport IOException. + assertTrue(ex is CompletionException, "expected CompletionException, got ${ex::class}") + assertTrue(ex.cause is IOException, "expected IOException cause, got ${ex.cause?.let { it::class }}") + Thread.interrupted() + Thread.sleep(900) + } + // -------- transport error contract: NetworkException -------- @Test From 5f8e494f91c393bd873a3d200fe7c6e61f8d7afb Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 05:20:32 +0300 Subject: [PATCH 25/46] feat: pipeline threads RequestOptions; retry honors per-call maxRetries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the caller's RequestOptions through the sync and async pipelines. HttpPipeline.send and AsyncHttpPipeline.sendAsync gain a (request, options) overload; the no-options forms delegate with RequestOptions.EMPTY, and the HttpClient / AsyncHttpClient per-call SPI overrides route through them so options survive when a pipeline is used as a transport. The options ride on the per-call state, are exposed to steps via PipelineNext.options / AsyncPipelineNext.options, and are threaded into the terminal transport dispatch so per-call timeouts take effect. The stage retry steps (DefaultRetryStep and DefaultAsyncRetryStep) now read RequestOptions.maxRetries for the in-flight call: a non-null value replaces the configured HttpRetryOptions.maxRetries budget for that call only, so a caller can fail fast (maxRetries=0) or widen/narrow the budget without a second pipeline; a null value keeps the configured default. Tags are carried on the options and readable by steps; no further wiring — the recovery-package steps are a later unit's concern. Tests cover options reaching the transport and steps (sync + async, empty and stepped pipelines) and per-call maxRetries overriding, capping, and falling back to the configured budget on both retry stacks. --- sdk-core/api/sdk-core.api | 4 + .../core/http/pipeline/AsyncHttpPipeline.kt | 29 ++++++- .../http/pipeline/AsyncPipelineCallState.kt | 17 +++- .../core/http/pipeline/AsyncPipelineNext.kt | 14 +++- .../sdk/core/http/pipeline/HttpPipeline.kt | 34 ++++++-- .../core/http/pipeline/PipelineCallState.kt | 15 +++- .../sdk/core/http/pipeline/PipelineNext.kt | 14 +++- .../pipeline/steps/DefaultAsyncRetryStep.kt | 10 ++- .../http/pipeline/steps/DefaultRetryStep.kt | 9 +- .../http/pipeline/AsyncHttpPipelineTest.kt | 46 ++++++++++ .../core/http/pipeline/HttpPipelineTest.kt | 84 +++++++++++++++++++ .../steps/DefaultAsyncRetryStepTest.kt | 39 +++++++++ .../core/http/pipeline/steps/RetryStepTest.kt | 63 ++++++++++++++ 13 files changed, 353 insertions(+), 25 deletions(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index b0d5cfce..9d01004d 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -738,6 +738,7 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline : org/de 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/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; } @@ -775,6 +776,7 @@ 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; } @@ -792,6 +794,7 @@ public final class org/dexpace/sdk/core/http/pipeline/HttpPipeline : org/dexpace 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; } @@ -833,6 +836,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; } 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 6270692b..93c1f962 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,6 +9,7 @@ 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 @@ -53,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 @@ -64,7 +77,7 @@ public class AsyncHttpPipeline internal constructor( Futures.failed(e) } } - val state = AsyncPipelineCallState(this, request) + val state = AsyncPipelineCallState(this, request, options) return AsyncPipelineNext(state).processAsync() } @@ -85,6 +98,16 @@ public class AsyncHttpPipeline internal constructor( */ 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) + public companion object { /** * Builds a step-less [AsyncHttpPipeline] that forwards every `sendAsync` directly to 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/HttpPipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipeline.kt index 1b455407..a2a88a60 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 @@ -40,13 +41,26 @@ public class HttpPipeline internal constructor( 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 { - if (stepArray.isEmpty()) return httpClient.execute(request) - val state = PipelineCallState(this, request) + 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, + options: RequestOptions, + ): Response { + if (stepArray.isEmpty()) return httpClient.execute(request, options) + val state = PipelineCallState(this, request, options) return PipelineNext(state).process() } @@ -57,6 +71,16 @@ public class HttpPipeline internal constructor( @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) + public companion object { /** * Builds a step-less [HttpPipeline] that forwards every `send` directly to [client]. 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/steps/DefaultAsyncRetryStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncRetryStep.kt index c9c7f024..54cff4aa 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 @@ -115,7 +115,10 @@ public open class DefaultAsyncRetryStep next: AsyncPipelineNext, ): CompletableFuture { val result = CompletableFuture() - val driver = RetryDriver(next, support.isRetrySafe(request), result) + // Per-call RequestOptions.maxRetries overrides the configured budget for THIS call + // only; null falls back to the step's clamped HttpRetryOptions.maxRetries. + val maxRetries = next.options.maxRetries ?: support.options.maxRetries + val driver = RetryDriver(next, support.isRetrySafe(request), maxRetries, result) driver.drive() return result } @@ -129,6 +132,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 @@ -223,7 +227,7 @@ public open class DefaultAsyncRetryStep 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 @@ -284,7 +288,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/DefaultRetryStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/DefaultRetryStep.kt index e62e2ad3..01441075 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 @@ -182,6 +182,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 overrides the configured budget for THIS call + // only; null falls back to the step's clamped HttpRetryOptions.maxRetries. Read once + // per call — the options are constant across retry re-drives. + val maxRetries = next.options.maxRetries ?: support.options.maxRetries + // 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 +204,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++ @@ -227,7 +232,7 @@ public open class DefaultRetryStep 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++ 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 f54cd587..d73f0f1a 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,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.dexpace.sdk.core.pagination.AsyncPaginator @@ -52,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 @@ -500,6 +530,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/HttpPipelineTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineTest.kt index 97255a4d..fea48f00 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 @@ -13,15 +13,19 @@ 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 @@ -42,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, @@ -72,6 +98,64 @@ 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() 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..27c0dfef 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 @@ -76,6 +77,44 @@ 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 `retries a 503 until a 200 within the budget`() { val client = QueueClient().enqueue(503).enqueue(503).enqueue(200) 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..9b6f9202 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 @@ -90,6 +91,68 @@ 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 `first attempt succeeds returns immediately`() { val fake = FakeHttpClient().enqueue { status(200) } From 2d8df24ca91cca1f40c5cc8f213976f195aa299f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 05:48:10 +0300 Subject: [PATCH 26/46] refactor!: rename the recovery pipeline stack to RecoveryChain/RetryRecovery to end the naming collision The recovery-aware primitives in `org.dexpace.sdk.core.pipeline` shared "Pipeline" and "RetryStep"/"ThrowOnHttpErrorStep" names with the user-facing stage-based dispatch pipeline in `org.dexpace.sdk.core.http.pipeline`, so it was impossible to tell which layer a given type belonged to. Give the recovery layer a distinct "Recovery" vocabulary: - ExecutionPipeline -> RecoveryChain (dispatch method execute -> recover) - RequestPipeline -> RequestRecoveryChain (dispatch method execute -> recover) - ResponsePipeline -> ResponseRecoveryChain (fold entry point stays `apply`) - pipeline.step.retry.RetryStep -> RetryRecovery - pipeline.step.ThrowOnHttpErrorStep -> ThrowOnHttpErrorRecovery The colliding stage-layer types (http.pipeline.steps.RetryStep / ThrowOnHttpErrorStep) are unchanged. Step interfaces and the shared retry utilities (RequestPipelineStep, ResponsePipelineStep, ResponseRecoveryStep, RetrySettings, BackoffCalculator, RetryAfterParser) keep their names. ClientIdentityStep previously exposed both the RequestPipelineStep `execute` override and a public `apply(request)`; collapse to a single public verb (`execute`), moving the transform to a private helper. This is a source- and binary-breaking rename (pre-1.0). Docs (pipelines.md, architecture.md, http.md) and the package maps in README/CLAUDE are updated to the new names, an orientation note distinguishes the dispatch pipeline from the recovery layer, and the stale claim that a duplicate pillar step "emits a warning" is corrected to reflect that a distinct second pillar step throws IllegalStateException. --- CLAUDE.md | 4 +- README.md | 2 +- docs/architecture.md | 25 ++-- docs/http.md | 8 +- docs/pipelines.md | 134 ++++++++++-------- sdk-core/api/sdk-core.api | 55 ++++--- .../sdk/core/client/AsyncHttpClient.kt | 2 +- .../sdk/core/http/common/HttpHeaderName.kt | 2 +- .../core/http/response/exception/Retryable.kt | 2 +- .../dexpace/sdk/core/http/sse/SseStream.kt | 2 +- ...{ExecutionPipeline.kt => RecoveryChain.kt} | 28 ++-- ...estPipeline.kt => RequestRecoveryChain.kt} | 14 +- .../sdk/core/pipeline/ResponseOutcome.kt | 8 +- ...sePipeline.kt => ResponseRecoveryChain.kt} | 2 +- .../core/pipeline/step/ClientIdentityStep.kt | 26 ++-- .../sdk/core/pipeline/step/PipelineStep.kt | 4 +- .../core/pipeline/step/RequestPipelineStep.kt | 2 +- .../pipeline/step/ResponsePipelineStep.kt | 4 +- .../pipeline/step/ResponseRecoveryStep.kt | 10 +- ...rorStep.kt => ThrowOnHttpErrorRecovery.kt} | 8 +- .../pipeline/step/retry/BackoffCalculator.kt | 2 +- .../retry/{RetryStep.kt => RetryRecovery.kt} | 12 +- .../core/pipeline/step/retry/RetrySettings.kt | 22 +-- .../core/http/pipeline/steps/RetryStepTest.kt | 4 +- ...neTest.kt => ResponseRecoveryChainTest.kt} | 58 ++++---- .../pipeline/step/ClientIdentityStepTest.kt | 42 +++--- ...est.kt => ThrowOnHttpErrorRecoveryTest.kt} | 28 ++-- .../retry/RetryDefaultsReconciliationTest.kt | 8 +- ...{RetryStepTest.kt => RetryRecoveryTest.kt} | 66 ++++----- 29 files changed, 301 insertions(+), 283 deletions(-) rename sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/{ExecutionPipeline.kt => RecoveryChain.kt} (79%) rename sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/{RequestPipeline.kt => RequestRecoveryChain.kt} (83%) rename sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/{ResponsePipeline.kt => ResponseRecoveryChain.kt} (99%) rename sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/{ThrowOnHttpErrorStep.kt => ThrowOnHttpErrorRecovery.kt} (96%) rename sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/{RetryStep.kt => RetryRecovery.kt} (97%) rename sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/{ResponsePipelineTest.kt => ResponseRecoveryChainTest.kt} (90%) rename sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/{ThrowOnHttpErrorStepTest.kt => ThrowOnHttpErrorRecoveryTest.kt} (86%) rename sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/{RetryStepTest.kt => RetryRecoveryTest.kt} (92%) diff --git a/CLAUDE.md b/CLAUDE.md index f3c3fff6..59f22443 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,8 +86,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 diff --git a/README.md b/README.md index c9028c68..086ddfb3 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,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`. | 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/pipelines.md b/docs/pipelines.md index 4b223147..fb4d8689 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 requestPipeline: RequestRecoveryChain = RequestRecoveryChain(), + public val responsePipeline: 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( + requestPipeline = RequestRecoveryChain(listOf(authStep, userAgentStep)), + responsePipeline = 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/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 9d01004d..3125b1e0 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2550,23 +2550,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 getRequestPipeline ()Lorg/dexpace/sdk/core/pipeline/RequestRecoveryChain; + public final fun getResponsePipeline ()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 { @@ -2599,7 +2599,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 @@ -2617,7 +2617,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; @@ -2697,8 +2696,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; } @@ -2717,6 +2716,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 @@ -2765,21 +2779,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 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 21163420..8dde234a 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 @@ -147,7 +147,7 @@ public fun AsyncHttpClient.asBlocking(): HttpClient = } 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. + // 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") 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/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/pipeline/ExecutionPipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt similarity index 79% 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..6ba5dad1 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,30 +23,30 @@ 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 [requestPipeline], + * [responsePipeline], 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]. @@ -55,12 +55,12 @@ import org.dexpace.sdk.core.http.response.Response * @property httpClient Transport that produces the raw [Response]. * @property responsePipeline 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 requestPipeline: RequestRecoveryChain = RequestRecoveryChain(), + public val responsePipeline: ResponseRecoveryChain = ResponseRecoveryChain(), ) { /** * Dispatches [request] through the full pipeline: @@ -76,7 +76,7 @@ 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 { @@ -99,7 +99,7 @@ public class ExecutionPipeline ): ResponseOutcome { val transformedRequest = try { - requestPipeline.execute(request, context) + requestPipeline.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..20196879 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 @@ -75,13 +75,20 @@ 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 tokens -> no change (request returned unchanged) + * - header absent -> header set to the joined token line + * - header present + Append -> header set to `existing + " " + joined` + * - header present + Replace -> header set to joined */ 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,17 +105,8 @@ 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(" ") 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 96% 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..dacb4319 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 @@ -22,7 +22,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 +30,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. @@ -65,7 +65,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. 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 97% 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..5b4b8aed 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 @@ -80,7 +80,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 +92,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 +112,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 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/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 9b6f9202..b6a0ca1e 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 @@ -755,7 +755,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) } @@ -781,7 +781,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) } 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..1f50e940 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(), + requestPipeline = RequestRecoveryChain(), + responsePipeline = 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)), + responsePipeline = 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)), + responsePipeline = 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)), + responsePipeline = 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( + 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)), + requestPipeline = RequestRecoveryChain(listOf(failingRequestStep)), + responsePipeline = 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)), + responsePipeline = 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)), + responsePipeline = 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..da48b5ef 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,7 +107,7 @@ 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) @@ -121,7 +121,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 +134,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 +167,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 +191,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 +210,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 +220,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 +240,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 92% 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..f90f99df 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,7 @@ class RetryStepTest { } @Test - fun `reused RetryStep retries correctly on a second top-level call`() { + 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 +351,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 +363,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 +376,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 +399,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 +420,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 +462,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 +479,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 +488,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 +502,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 +531,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 +568,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 +584,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 +600,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 +613,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 +635,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 +656,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 +683,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 +708,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 +734,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. From d36b3ddf8dfcad7b558482c7538fa93a6997a1a3 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 05:58:05 +0300 Subject: [PATCH 27/46] docs: installation section, ergonomic quick start, how-do-I snippets, and a minimal GET sample Add Installation section with Gradle/Maven coordinates. Rewrite Quick start to lead with HttpPipeline.of + Request.get and show parsedWith(jsonHandler(...)).value() for typed reads. Add Coordinate column to Modules table. Add How-do-I section with bearer auth, throwOnHttpError, per-request RequestOptions, pagination, and jsonBody snippets. Rewrite ExampleApp.createUser to use the ergonomic jsonBody / parsedWith / throwOnError path. Add SimpleGetApp.kt (minimal plain-HTTP GET sample, ~30 lines) and SimpleGetAppTest smoke test. --- README.md | 205 +++++++++++++++--- .../org/dexpace/sdk/example/ExampleApp.kt | 37 ++-- .../org/dexpace/sdk/example/SimpleGetApp.kt | 49 +++++ .../dexpace/sdk/example/SimpleGetAppTest.kt | 22 ++ 4 files changed, 259 insertions(+), 54 deletions(-) create mode 100644 sdk-example/src/main/kotlin/org/dexpace/sdk/example/SimpleGetApp.kt create mode 100644 sdk-example/src/test/kotlin/org/dexpace/sdk/example/SimpleGetAppTest.kt diff --git a/README.md b/README.md index 086ddfb3..8aee7f0f 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 = jsonBody(serde, CreateUserRequest(name = "Ada", email = "ada@example.org")) +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,6 +308,79 @@ 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 non-2xx responses to typed +`HttpException` subclasses (`BadRequestException`, `TooManyRequestsException`, etc.): + +```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 +val paginator = Paginator( + firstPageFetcher = { pipeline.send(Request.get("https://api.example.com/items")) }, + nextPageFetcher = { cursor -> + pipeline.send(Request.get("https://api.example.com/items?page_token=$cursor")) + }, + strategy = CursorPaginationStrategy(items = { parseItems(it) }, cursorExtractor = { nextToken(it) }), +) + +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 +// Option A — ergonomic factory from sdk-serde-jackson (sets Content-Type automatically) +val body = jsonBody(serde, myPayload) + +// Option B — explicit factory from sdk-core (Content-Type must be set manually) +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`. 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..65312ea4 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.jsonBody +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(jsonBody(serde, request)) + .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() + } +} From bc0b08d750ebf42180db99b4406bcba3a3e20fae Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 05:59:26 +0300 Subject: [PATCH 28/46] chore: reconcile sdk-async-virtualthreads API snapshot for the async per-call overload --- sdk-async-virtualthreads/api/sdk-async-virtualthreads.api | 1 + 1 file changed, 1 insertion(+) 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; } From 3ef4028e08be5aa266b9909fc47d3b7b567ba56f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 06:20:38 +0300 Subject: [PATCH 29/46] fix: clamp negative per-call maxRetries to the configured default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-call RequestOptions.maxRetries override was applied verbatim by the sync and async retry steps. A negative value therefore collapsed the retry budget to zero attempts, which is both surprising and inconsistent with the configured retry path — where a negative HttpRetryOptions.maxRetries is already clamped to the default budget. Treat a negative per-call override the same way: fall back to the configured budget rather than disabling retries. A caller that genuinely wants no retries still passes 0. Covers both DefaultRetryStep and DefaultAsyncRetryStep with a regression test on each stack. --- .../pipeline/steps/DefaultAsyncRetryStep.kt | 6 +++-- .../http/pipeline/steps/DefaultRetryStep.kt | 8 ++++--- .../steps/DefaultAsyncRetryStepTest.kt | 14 +++++++++++ .../core/http/pipeline/steps/RetryStepTest.kt | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) 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 54cff4aa..b2c65208 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 @@ -116,8 +116,10 @@ public open class DefaultAsyncRetryStep ): CompletableFuture { val result = CompletableFuture() // Per-call RequestOptions.maxRetries overrides the configured budget for THIS call - // only; null falls back to the step's clamped HttpRetryOptions.maxRetries. - val maxRetries = next.options.maxRetries ?: support.options.maxRetries + // only; null (or a negative override) falls back to the step's clamped + // HttpRetryOptions.maxRetries — mirroring RetryPolicySupport's clamp of the configured + // path so a negative per-call value means "use default", not "0 retries". + val maxRetries = next.options.maxRetries?.takeIf { it >= 0 } ?: support.options.maxRetries val driver = RetryDriver(next, support.isRetrySafe(request), maxRetries, result) driver.drive() return result 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 01441075..8d70c740 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 @@ -183,9 +183,11 @@ public open class DefaultRetryStep var suppressed: MutableList? = null // Per-call RequestOptions.maxRetries overrides the configured budget for THIS call - // only; null falls back to the step's clamped HttpRetryOptions.maxRetries. Read once - // per call — the options are constant across retry re-drives. - val maxRetries = next.options.maxRetries ?: support.options.maxRetries + // only; null (or a negative override) falls back to the step's clamped + // HttpRetryOptions.maxRetries — mirroring RetryPolicySupport's clamp of the configured + // path so a negative per-call value means "use default", not "0 retries". Read once per + // call — the options are constant across retry re-drives. + val maxRetries = next.options.maxRetries?.takeIf { it >= 0 } ?: support.options.maxRetries // 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 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 27c0dfef..6e5bdedc 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 @@ -115,6 +115,20 @@ class DefaultAsyncRetryStepTest { 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) 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 b6a0ca1e..08579d9c 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 @@ -153,6 +153,29 @@ class RetryStepTest { 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) } From cfce016e46933af1ae099290ccc1fcc4ca3d4b80 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 06:20:44 +0300 Subject: [PATCH 30/46] refactor!: rename RecoveryChain pipeline properties to *Chain RecoveryChain and its collaborators were renamed to the RequestRecoveryChain / ResponseRecoveryChain "chain" vocabulary, but the two constructor properties were left as requestPipeline / responsePipeline, so the getters and KDoc still spoke of "pipelines" while every referenced type said "chain". Rename the properties to requestChain / responseChain to match, and update the KDoc and references. This renames the public getters (getRequestChain / getResponseChain), a binary-incompatible change. --- docs/pipelines.md | 8 +++---- .../sdk/core/pipeline/RecoveryChain.kt | 22 +++++++++---------- .../pipeline/ResponseRecoveryChainTest.kt | 20 ++++++++--------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/pipelines.md b/docs/pipelines.md index fb4d8689..3d7ecc8d 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -309,8 +309,8 @@ terminal failure). ```kotlin public class RecoveryChain( public val httpClient: HttpClient, - public val requestPipeline: RequestRecoveryChain = RequestRecoveryChain(), - public val responsePipeline: ResponseRecoveryChain = ResponseRecoveryChain(), + public val requestChain: RequestRecoveryChain = RequestRecoveryChain(), + public val responseChain: ResponseRecoveryChain = ResponseRecoveryChain(), ) ``` @@ -628,8 +628,8 @@ val mapToTypedException = ResponseRecoveryStep { outcome -> val pipeline = RecoveryChain( httpClient = transport, - requestPipeline = RequestRecoveryChain(listOf(authStep, userAgentStep)), - responsePipeline = ResponseRecoveryChain( + requestChain = RequestRecoveryChain(listOf(authStep, userAgentStep)), + responseChain = ResponseRecoveryChain( responseSteps = listOf(decodingStep), recoverySteps = listOf(retryStep, mapToTypedException), ), diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt index 6ba5dad1..2352ce65 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/RecoveryChain.kt @@ -41,8 +41,8 @@ import org.dexpace.sdk.core.http.response.Response * defect where a `BeforeRequest` exception bypassed `AfterError`. * * ## Thread-safety - * The chain is immutable after construction (the underlying [requestPipeline], - * [responsePipeline], and [httpClient] references are final). Concurrent calls to [recover] + * 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 @@ -51,22 +51,22 @@ import org.dexpace.sdk.core.http.response.Response * 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 RecoveryChain @JvmOverloads constructor( public val httpClient: HttpClient, - public val requestPipeline: RequestRecoveryChain = RequestRecoveryChain(), - public val responsePipeline: ResponseRecoveryChain = ResponseRecoveryChain(), + 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. * @@ -81,7 +81,7 @@ public class RecoveryChain 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 RecoveryChain } /** - * 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 RecoveryChain ): ResponseOutcome { val transformedRequest = try { - requestPipeline.recover(request, context) + requestChain.recover(request, context) } catch (t: Throwable) { return failureOf(t) } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChainTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChainTest.kt index 1f50e940..9d0b323a 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChainTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/ResponseRecoveryChainTest.kt @@ -128,8 +128,8 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - requestPipeline = RequestRecoveryChain(), - responsePipeline = ResponseRecoveryChain(), + requestChain = RequestRecoveryChain(), + responseChain = ResponseRecoveryChain(), ) val out = pipeline.recover(req, ctx()) @@ -156,7 +156,7 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - responsePipeline = ResponseRecoveryChain(recoverySteps = listOf(rescue)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(rescue)), ) val out = pipeline.recover(req, ctx()) @@ -175,7 +175,7 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - responsePipeline = ResponseRecoveryChain(recoverySteps = listOf(passthrough)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(passthrough)), ) val thrown = assertThrows(IOException::class.java) { pipeline.recover(req, ctx()) } @@ -200,7 +200,7 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - responsePipeline = ResponseRecoveryChain(recoverySteps = listOf(replace)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(replace)), ) val thrown = assertThrows(IllegalStateException::class.java) { pipeline.recover(req, ctx()) } @@ -234,7 +234,7 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - responsePipeline = + responseChain = ResponseRecoveryChain( responseSteps = listOf(failingResponseStep), recoverySteps = listOf(recoverFromResponseStep), @@ -321,8 +321,8 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - requestPipeline = RequestRecoveryChain(listOf(failingRequestStep)), - responsePipeline = ResponseRecoveryChain(recoverySteps = listOf(recoverFromRequestStep)), + requestChain = RequestRecoveryChain(listOf(failingRequestStep)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(recoverFromRequestStep)), ) val thrown = assertThrows(IllegalArgumentException::class.java) { pipeline.recover(req, ctx()) } @@ -356,7 +356,7 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - responsePipeline = ResponseRecoveryChain(recoverySteps = listOf(observeTransportFailure)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(observeTransportFailure)), ) val thrown = assertThrows(IOException::class.java) { pipeline.recover(req, ctx()) } @@ -421,7 +421,7 @@ class ResponseRecoveryChainTest { val pipeline = RecoveryChain( httpClient = transport, - responsePipeline = ResponseRecoveryChain(recoverySteps = listOf(tag1, rescue, tag3)), + responseChain = ResponseRecoveryChain(recoverySteps = listOf(tag1, rescue, tag3)), ) val out = pipeline.recover(req, ctx()) From e752dbcaad6220ffd07ac49e3c9124c2a4bd60da Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 06:20:56 +0300 Subject: [PATCH 31/46] refactor!: remove redundant jsonBody in favor of RequestBody.create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsonBody(serde, value) was a thin pass-through to RequestBody.create(value, serde) with the arguments reversed and a name implying JSON-specific behavior it did not have — RequestBody.create already defaults the media type to the serde's content type. The two entry points did the same thing and invited confusion about which to reach for. Remove jsonBody and point callers (README, example app) at RequestBody.create(value, serde). Drops jsonBody from the public API. --- README.md | 7 ++----- .../org/dexpace/sdk/example/ExampleApp.kt | 4 ++-- sdk-serde-jackson/api/sdk-serde-jackson.api | 4 ---- .../org/dexpace/sdk/serde/jackson/Extensions.kt | 17 ----------------- .../sdk/serde/jackson/JacksonSerdeTest.kt | 14 -------------- 5 files changed, 4 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 8aee7f0f..f137ee5d 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ val pipeline = HttpPipeline.standard(transport) ```kotlin val serde = JacksonSerde.withDefaults() -val body = jsonBody(serde, CreateUserRequest(name = "Ada", email = "ada@example.org")) +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() @@ -368,10 +368,7 @@ strategy expects a transport — every page request then runs through the same r ### Send a JSON body ```kotlin -// Option A — ergonomic factory from sdk-serde-jackson (sets Content-Type automatically) -val body = jsonBody(serde, myPayload) - -// Option B — explicit factory from sdk-core (Content-Type must be set manually) +// RequestBody.create defaults the Content-Type to the serde's media type (application/json). val body = RequestBody.create(myPayload, serde) val request = Request.builder() 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 65312ea4..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 @@ -25,12 +25,12 @@ 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.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.io.OkioIoProvider import org.dexpace.sdk.serde.jackson.JacksonSerde -import org.dexpace.sdk.serde.jackson.jsonBody import org.dexpace.sdk.serde.jackson.jsonHandler import org.dexpace.sdk.transport.okhttp.OkHttpTransport import java.net.URL @@ -181,7 +181,7 @@ public fun createUser( .send( Request.builder() .url(endpoint) - .post(jsonBody(serde, request)) + .post(RequestBody.create(request, serde)) .addHeader(HttpHeaderName.ACCEPT, CommonMediaTypes.APPLICATION_JSON) .build(), ) diff --git a/sdk-serde-jackson/api/sdk-serde-jackson.api b/sdk-serde-jackson/api/sdk-serde-jackson.api index c257b9a6..8ec2e925 100644 --- a/sdk-serde-jackson/api/sdk-serde-jackson.api +++ b/sdk-serde-jackson/api/sdk-serde-jackson.api @@ -1,7 +1,3 @@ -public final class org/dexpace/sdk/serde/jackson/ExtensionsKt { - public static final fun jsonBody (Lorg/dexpace/sdk/core/serde/Serde;Ljava/lang/Object;)Lorg/dexpace/sdk/core/http/request/RequestBody; -} - public final class org/dexpace/sdk/serde/jackson/JacksonObjectMappers { public static final field INSTANCE Lorg/dexpace/sdk/serde/jackson/JacksonObjectMappers; public final fun defaultObjectMapper ()Lcom/fasterxml/jackson/databind/ObjectMapper; diff --git a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt index 75962a5c..0d0f824e 100644 --- a/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt +++ b/sdk-serde-jackson/src/main/kotlin/org/dexpace/sdk/serde/jackson/Extensions.kt @@ -9,25 +9,8 @@ package org.dexpace.sdk.serde.jackson import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.type.TypeFactory -import org.dexpace.sdk.core.http.request.RequestBody -import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.Tristate -/** - * Creates a replayable [RequestBody] by serializing [value] via [serde]. - * - * This is a discoverable convenience wrapper over [RequestBody.create]. The body's media type - * defaults to [Serde.contentType] (typically `application/json`). - * - * @param serde The serde used to serialize [value]. - * @param value The object to serialize. - * @return A replayable body whose bytes are the UTF-8 encoding of the serialized [value]. - */ -public fun jsonBody( - serde: Serde, - value: Any, -): RequestBody = RequestBody.create(value, serde) - /** The element [JavaType] for `Object` — the fallback when a `Tristate` is raw or `Tristate<*>`. */ internal val ANY_TYPE: JavaType = TypeFactory.defaultInstance().constructType(Any::class.java) 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 a1d6bc48..7c085c1d 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 @@ -14,7 +14,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import org.dexpace.sdk.core.http.common.MediaType import org.dexpace.sdk.core.serde.DeserializationException import org.dexpace.sdk.core.serde.SerdeException import org.dexpace.sdk.core.serde.SerializationException @@ -269,19 +268,6 @@ class JacksonSerdeTest { assertTrue(json.contains("\"v\"")) } - // ----- T8-jackson: jsonBody convenience ----- - - @Test - fun `jsonBody produces application-json body with expected content length and media type`() { - val serde = JacksonSerde.withDefaults() - val dto = Inner("hello", 42) - val body = jsonBody(serde, dto) - val expectedBytes = serde.serializer.serializeToByteArray(dto) - assertEquals(MediaType.parse("application/json"), body.mediaType()) - assertEquals(expectedBytes.size.toLong(), body.contentLength()) - assertTrue(body.isReplayable()) - } - // 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) From 5d95eeaf5a37f87fd3b983840cb5fcc16caa3a58 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 06:21:04 +0300 Subject: [PATCH 32/46] feat: add remaining Request verb factories and parametric Response.deserialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Request companion offered only get(url) and post(url, body) static factories even though the builder exposes all six verbs, and Response.deserialize could only decode via a Class token or a reified Kotlin type — neither of which lets a Java caller decode a parametric target such as List. Add symmetric put/patch (with body) and delete/head (body-less) static factories mirroring the builder verbs, and a Response.deserialize(serde, TypeRef) overload that streams the body through the serde's deserializer for parametric decodes, closing the response afterward exactly like the Class overload. --- sdk-core/api/sdk-core.api | 13 ++++- .../dexpace/sdk/core/http/request/Request.kt | 48 +++++++++++++++++++ .../core/http/response/ResponseExtensions.kt | 28 +++++++++++ .../sdk/core/http/request/RequestDxTest.kt | 30 ++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 3125b1e0..1f8cec8e 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -1321,6 +1321,7 @@ 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; @@ -1328,15 +1329,22 @@ public final class org/dexpace/sdk/core/http/request/Request { 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 { @@ -1569,6 +1577,7 @@ public final class org/dexpace/sdk/core/http/response/ResponseBody$Companion { public final class org/dexpace/sdk/core/http/response/ResponseExtensionsKt { 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; } @@ -2556,8 +2565,8 @@ public final class org/dexpace/sdk/core/pipeline/RecoveryChain { 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/RequestRecoveryChain; - public final fun getResponsePipeline ()Lorg/dexpace/sdk/core/pipeline/ResponseRecoveryChain; + 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; } 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 9884295e..18fac4e8 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 @@ -485,5 +485,53 @@ public data class Request private constructor( 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/response/ResponseExtensions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensions.kt index a10b221f..6c7ff546 100644 --- 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 @@ -13,6 +13,7 @@ import org.dexpace.sdk.core.instrumentation.ClientLogger 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.TypeRef private val logger = ClientLogger("org.dexpace.sdk.core.http.response.ResponseExtensions") @@ -102,6 +103,33 @@ public fun Response.deserialize( } } +/** + * 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 ?: error("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. 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 index c0ec689f..88645e32 100644 --- 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 @@ -212,4 +212,34 @@ class RequestDxTest { 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) + } } From 02bec41f018aa7a4ae782abc072cf4b28c831a2a Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 06:21:15 +0300 Subject: [PATCH 33/46] fix: buffer the error body in jsonHandlerOrThrow to prevent use-after-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a non-2xx response, jsonHandlerOrThrow threw an HttpException that carried the live, still-open response body. Handlers are typically run inside response.use { ... }, so as the exception unwound the block closed the response — and any caller that later read the exception body hit a use-after-close on a released stream. Route the error path through Response.throwOnError, which buffers a bounded (<= 1 MiB) copy of the body into the thrown exception. The body now stays readable after the response is closed. The 2xx path still streams and decodes as before; KDoc updated to state the error body is buffered. --- .../sdk/serde/jackson/JsonResponseHandler.kt | 32 +++++++++++-------- .../serde/jackson/JsonResponseHandlerTest.kt | 27 ++++++++++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) 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 5318d661..ef9516b5 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 @@ -10,7 +10,9 @@ package org.dexpace.sdk.serde.jackson import com.fasterxml.jackson.core.type.TypeReference 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.exception.HttpExceptionFactory +import org.dexpace.sdk.core.http.response.throwOnError import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.SerdeException @@ -67,13 +69,14 @@ public fun jsonHandler( * 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 [HttpExceptionFactory] to the matching - * [org.dexpace.sdk.core.http.response.exception.HttpException] subclass, which carries the - * still-unconsumed error [org.dexpace.sdk.core.http.response.ResponseBody] — the caught exception - * owns closing it (e.g. via `bodySnapshot()` or typed error-body deserialization). 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]. + * 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. @@ -107,18 +110,19 @@ public fun jsonHandlerOrThrow( } /** - * Throws when [response] is not a 2xx: a 4xx / 5xx is mapped to its [HttpExceptionFactory] exception - * (which retains the live error body for the caller to inspect and close); any other non-success - * status closes the response and raises a [SerdeException]. Returns normally on a 2xx so the caller - * proceeds to decode. + * 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 closes the response and raises a [SerdeException]. 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 (HttpExceptionFactory.isErrorStatus(status.code)) { - // Hand the still-unconsumed error body to the mapped HttpException; the caught exception owns - // reading and closing it. Do NOT close here or bodySnapshot()/typed error decode would fail. - throw HttpExceptionFactory.fromResponse(response) + // 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() } response.close() throw SerdeException( 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 346c6c6f..f944e34f 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,10 +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 @@ -140,6 +142,23 @@ class JsonResponseHandlerTest { 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() @@ -154,6 +173,14 @@ class JsonResponseHandlerTest { } } + @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() From 08ae5294bb41bdb06cdab5ed2f403e4c58b01224 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 5 Jul 2026 14:06:14 +0300 Subject: [PATCH 34/46] fix!: close correctness and plumbing gaps in the DX conveniences Several of the new developer-experience conveniences had edge-case bugs or incomplete threading. This tightens them: - MediaType's common constants (APPLICATION_JSON, TEXT_PLAIN, ...) are built directly via of() instead of delegating to CommonMediaTypes, breaking a class-initialization cycle that could leave the constants null depending on which class initialized first. - Request.build() defaults to GET only when no body is present; a body with no method now reports the missing method rather than the misleading "GET must not carry a request body". - Response.throwOnError() returns 1xx/3xx responses unchanged with their body intact instead of draining the body and throwing IllegalArgumentException; only 4xx/5xx map to an HttpException. - Per-call RequestOptions now thread through every sync<->async bridge (asAsync/asBlocking/toAsync/toBlocking) and through the virtual-thread and coroutine adapters, which previously dropped them via the one-arg SPI default. - OkHttp timeouts surface as a retryable NetworkException instead of a thread interrupt on both the sync and async paths, matching the JDK transport, and no longer set a spurious interrupt flag; a sub-millisecond per-call timeout is clamped so it is not truncated to okio's "no timeout"; RequestOptions rejects a non-positive timeout at construction. - interruptibleFuture closes a Response that loses the completion/cancel race instead of leaking its connection. - Io.provider consults the thread-context classloader before the defining one so ServiceLoader discovery works under nested classloaders, and warns when an explicit install replaces a provider that was already resolved and handed out. - appendStandardResilience() validates its target pillars up front, so a call that collides with an already-configured pillar leaves the builder unchanged, and its documentation states the precondition. - Serde.contentType() is abstract (JacksonSerde declares application/json) so a non-JSON serde cannot silently stamp the wrong Content-Type; JacksonSerde.from documents the ObjectMapper.copy() subclass requirement. - RequestBody.create(value, serde) serializes straight to bytes; the sync and async retry steps share one max-retries-override helper; error-status checks use Status.isError; the JDK transport reuses Futures.unwrap; both transports share ProxyOptions.fromEnvironment(). Public API snapshots are regenerated: Serde.contentType() is now abstract and ProxyOptions gains fromEnvironment(). --- CLAUDE.md | 12 +- .../sdk/async/coroutines/Coroutines.kt | 13 +- .../sdk/async/coroutines/CoroutinesTest.kt | 22 +++ .../async/virtualthreads/VirtualThreads.kt | 6 + .../virtualthreads/VirtualThreadsTest.kt | 24 +++ sdk-core/api/sdk-core.api | 8 +- .../sdk/core/client/AsyncHttpClient.kt | 68 +++++--- .../dexpace/sdk/core/http/common/MediaType.kt | 34 ++-- .../http/pipeline/AsyncHttpPipelineBuilder.kt | 38 ++++- .../http/pipeline/AsyncPipelineBridges.kt | 24 ++- .../core/http/pipeline/HttpPipelineBuilder.kt | 48 +++++- .../steps/AsyncThrowOnHttpErrorStep.kt | 15 +- .../pipeline/steps/DefaultAsyncRetryStep.kt | 8 +- .../http/pipeline/steps/DefaultRetryStep.kt | 10 +- .../http/pipeline/steps/RetryPolicySupport.kt | 15 ++ .../pipeline/steps/ThrowOnHttpErrorStep.kt | 9 +- .../dexpace/sdk/core/http/request/Request.kt | 11 +- .../sdk/core/http/request/RequestBody.kt | 6 +- .../sdk/core/http/request/RequestOptions.kt | 13 +- .../core/http/response/ResponseExtensions.kt | 13 +- .../exception/HttpExceptionFactory.kt | 12 +- .../main/kotlin/org/dexpace/sdk/core/io/Io.kt | 134 +++++++++++++-- .../org/dexpace/sdk/core/serde/Serde.kt | 10 +- .../org/dexpace/sdk/core/util/Futures.kt | 24 ++- .../org/dexpace/sdk/core/util/ProxyOptions.kt | 24 +++ .../sdk/core/client/AsyncHttpClientTest.kt | 49 ++++++ .../sdk/core/http/common/MediaTypeTest.kt | 21 +++ .../http/pipeline/AsyncHttpPipelineTest.kt | 38 +++++ .../http/pipeline/StandardResilienceTest.kt | 83 +++++++++ .../steps/AsyncThrowOnHttpErrorStepTest.kt | 15 ++ .../pipeline/steps/RetryPolicySupportTest.kt | 61 +++++++ .../steps/ThrowOnHttpErrorStepTest.kt | 35 ++++ .../core/http/request/MultipartBodyTest.kt | 2 + .../core/http/request/RequestBodyDxTest.kt | 11 +- .../core/http/request/RequestOptionsTest.kt | 30 ++++ .../sdk/core/http/request/RequestTest.kt | 14 ++ .../http/response/ResponseExtensionsTest.kt | 24 +++ .../exception/HttpExceptionFactoryTest.kt | 20 +++ .../kotlin/org/dexpace/sdk/core/io/IoTest.kt | 160 ++++++++++++++++++ .../org/dexpace/sdk/core/util/FuturesTest.kt | 51 ++++++ .../dexpace/sdk/core/util/ProxyOptionsTest.kt | 66 ++++++++ .../dexpace/sdk/serde/jackson/JacksonSerde.kt | 10 +- .../sdk/serde/jackson/JsonResponseHandler.kt | 3 +- .../serde/jackson/JsonResponseHandlerTest.kt | 31 ++++ .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 12 +- .../jdkhttp/internal/TransportFailures.kt | 22 +-- .../sdk/transport/okhttp/OkHttpTransport.kt | 99 +++++++---- .../transport/okhttp/OkHttpTransportTest.kt | 92 +++++++--- 48 files changed, 1353 insertions(+), 197 deletions(-) create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryPolicySupportTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 59f22443..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`. @@ -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/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..a0beaaa0 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,6 +19,7 @@ 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 @@ -85,7 +86,15 @@ 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(MDCContext()) { runInterruptible(Dispatchers.IO) { client.execute(request, options) } } } } 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-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..59e062cf 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 @@ -76,6 +77,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 1f8cec8e..789e996a 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2809,15 +2809,11 @@ public final class org/dexpace/sdk/core/serde/Deserializer$DefaultImpls { } public abstract interface class org/dexpace/sdk/core/serde/Serde { - public fun contentType ()Lorg/dexpace/sdk/core/http/common/MediaType; + 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; } -public final class org/dexpace/sdk/core/serde/Serde$DefaultImpls { - public static fun contentType (Lorg/dexpace/sdk/core/serde/Serde;)Lorg/dexpace/sdk/core/http/common/MediaType; -} - public class org/dexpace/sdk/core/serde/SerdeException : java/lang/RuntimeException { public fun ()V public fun (Ljava/lang/String;)V @@ -2926,6 +2922,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; @@ -2938,6 +2935,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 8dde234a..3bebd938 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 @@ -121,10 +121,21 @@ public fun interface AsyncHttpClient : AutoCloseable { * 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 -> - interruptibleFuture(executor) { execute(request) } +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()` @@ -139,25 +150,36 @@ public fun HttpClient.asAsync(executor: Executor): AsyncHttpClient = * 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 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) +public fun AsyncHttpClient.asBlocking(): HttpClient { + val async = this + return object : HttpClient { + 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. + 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/http/common/MediaType.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/MediaType.kt index 4675a587..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 @@ -160,27 +160,31 @@ public data class MediaType private constructor( public companion object { // --- Common media-type shortcuts ----------------------------------------------- - // Delegate to CommonMediaTypes (single source of truth). Exposed as @JvmField so - // Java callers see a plain static field (MediaType.APPLICATION_JSON) rather than a - // generated getter. + // 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` — delegates to [CommonMediaTypes.APPLICATION_JSON]. */ - @JvmField public val APPLICATION_JSON: MediaType = CommonMediaTypes.APPLICATION_JSON + /** `application/json`. */ + @JvmField public val APPLICATION_JSON: MediaType = of("application", "json") - /** `text/plain` — delegates to [CommonMediaTypes.TEXT_PLAIN]. */ - @JvmField public val TEXT_PLAIN: MediaType = CommonMediaTypes.TEXT_PLAIN + /** `text/plain`. */ + @JvmField public val TEXT_PLAIN: MediaType = of("text", "plain") - /** `application/octet-stream` — delegates to [CommonMediaTypes.APPLICATION_OCTET_STREAM]. */ - @JvmField public val APPLICATION_OCTET_STREAM: MediaType = CommonMediaTypes.APPLICATION_OCTET_STREAM + /** `application/octet-stream`. */ + @JvmField public val APPLICATION_OCTET_STREAM: MediaType = of("application", "octet-stream") - /** `application/x-www-form-urlencoded` — delegates to [CommonMediaTypes.APPLICATION_FORM_URLENCODED]. */ - @JvmField public val APPLICATION_FORM_URLENCODED: MediaType = CommonMediaTypes.APPLICATION_FORM_URLENCODED + /** `application/x-www-form-urlencoded`. */ + @JvmField public val APPLICATION_FORM_URLENCODED: MediaType = of("application", "x-www-form-urlencoded") - /** `text/html` — delegates to [CommonMediaTypes.TEXT_HTML]. */ - @JvmField public val TEXT_HTML: MediaType = CommonMediaTypes.TEXT_HTML + /** `text/html`. */ + @JvmField public val TEXT_HTML: MediaType = of("text", "html") - /** `application/xml` — delegates to [CommonMediaTypes.APPLICATION_XML]. */ - @JvmField public val APPLICATION_XML: MediaType = CommonMediaTypes.APPLICATION_XML + /** `application/xml`. */ + @JvmField public val APPLICATION_XML: MediaType = of("application", "xml") // ------------------------------------------------------------------------------- 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 27685e49..cdbb1d51 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 @@ -109,10 +109,42 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) { * [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 = - append(DefaultAsyncRetryStep(scheduler)) - .append(DefaultAsyncInstrumentationStep()) + public fun appendStandardResilience(scheduler: ScheduledExecutorService): AsyncHttpPipelineBuilder { + val additions: List = + listOf( + DefaultAsyncRetryStep(scheduler), + DefaultAsyncInstrumentationStep(), + ) + requireStandardPillarsAvailable(additions) + return appendAll(additions) + } + + /** + * Precondition for [appendStandardResilience]: none of the pillar stages [additions] will fill + * may already be occupied. Validating up front turns what would otherwise be a + * partial-mutation-then-throw (the retry pillar installed before the later [append] rejects the + * conflict) into an atomic all-or-nothing check. + */ + private fun requireStandardPillarsAvailable(additions: List) { + val occupied = additions.map { it.stage }.filter { it.isPillar && steps.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()." + } + } /** Builds an immutable [AsyncHttpPipeline]. */ public fun build(): AsyncHttpPipeline { 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 58935b4c..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 @@ -11,8 +11,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 /** @@ -44,7 +48,20 @@ import java.util.concurrent.Executor */ public fun HttpPipeline.toAsync(executor: Executor): AsyncHttpPipeline { val sync = this - return AsyncHttpPipeline.of { request -> interruptibleFuture(executor) { sync.send(request) } } + val asyncClient = + object : AsyncHttpClient { + override fun executeAsync(request: Request): CompletableFuture = + executeAsync(request, RequestOptions.EMPTY) + + // 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 AsyncHttpPipeline.of(asyncClient) } /** @@ -57,4 +74,7 @@ public fun HttpPipeline.toAsync(executor: Executor): AsyncHttpPipeline { * 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/HttpPipelineBuilder.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipelineBuilder.kt index fbbd671a..730b24f7 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 @@ -112,14 +112,48 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) { /** * 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, so the - * relative run order is fixed by [Stage] regardless of the call site. Combine with - * [throwOnHttpError] for turnkey error mapping on top of the resilience stack. + * [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 = - append(DefaultRedirectStep()) - .append(DefaultRetryStep()) - .append(DefaultInstrumentationStep()) + public fun appendStandardResilience(): HttpPipelineBuilder { + val additions: List = + listOf( + DefaultRedirectStep(), + DefaultRetryStep(), + DefaultInstrumentationStep(), + ) + requireStandardPillarsAvailable(additions) + return appendAll(additions) + } + + /** + * Precondition for [appendStandardResilience]: none of the pillar stages [additions] will fill + * may already be occupied. Validating up front turns what would otherwise be a + * partial-mutation-then-throw (the first pillar installed before a later [append] rejects the + * conflict) into an atomic all-or-nothing check. + */ + private fun requireStandardPillarsAvailable(additions: List) { + val occupied = additions.map { it.stage }.filter { it.isPillar && steps.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()." + } + } /** * Builds an immutable [HttpPipeline] in stage order. [Stage.SEND] is reserved for the 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 index 3f432188..6b41e20f 100644 --- 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 @@ -12,7 +12,6 @@ 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.exception.HttpExceptionFactory import org.dexpace.sdk.core.http.response.throwOnError import java.util.concurrent.CompletableFuture @@ -33,6 +32,18 @@ import java.util.concurrent.CompletableFuture * 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. @@ -45,7 +56,7 @@ public class AsyncThrowOnHttpErrorStep : AsyncHttpStep { next: AsyncPipelineNext, ): CompletableFuture = next.processAsync().thenApply { response -> - if (!HttpExceptionFactory.isErrorStatus(response.status.code)) { + if (!response.status.isError) { response } else { // throwOnError buffers the bounded error body and throws the mapped exception; 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 b2c65208..bdee59a7 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 @@ -115,11 +115,9 @@ public open class DefaultAsyncRetryStep next: AsyncPipelineNext, ): CompletableFuture { val result = CompletableFuture() - // Per-call RequestOptions.maxRetries overrides the configured budget for THIS call - // only; null (or a negative override) falls back to the step's clamped - // HttpRetryOptions.maxRetries — mirroring RetryPolicySupport's clamp of the configured - // path so a negative per-call value means "use default", not "0 retries". - val maxRetries = next.options.maxRetries?.takeIf { it >= 0 } ?: support.options.maxRetries + // 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 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 8d70c740..3cf04782 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 @@ -182,12 +182,10 @@ 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 overrides the configured budget for THIS call - // only; null (or a negative override) falls back to the step's clamped - // HttpRetryOptions.maxRetries — mirroring RetryPolicySupport's clamp of the configured - // path so a negative per-call value means "use default", not "0 retries". Read once per - // call — the options are constant across retry re-drives. - val maxRetries = next.options.maxRetries?.takeIf { it >= 0 } ?: support.options.maxRetries + // 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 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 index 1e7f1891..82b0a7d3 100644 --- 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 @@ -12,7 +12,6 @@ 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.exception.HttpExceptionFactory import org.dexpace.sdk.core.http.response.throwOnError import java.io.IOException @@ -41,8 +40,8 @@ import java.io.IOException * 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 - * [HttpExceptionFactory.isErrorStatus] so a non-error status (which would trip + * 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 @@ -59,8 +58,8 @@ public class ThrowOnHttpErrorStep : HttpStep { ): 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. - if (!HttpExceptionFactory.isErrorStatus(response.status.code)) return response + // 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/Request.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Request.kt index 18fac4e8..f66c2889 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 @@ -424,6 +424,12 @@ public data class Request private constructor( /** * 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 @@ -432,12 +438,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 = method ?: Method.GET + 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 " + 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 ef7187ea..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 @@ -198,9 +198,9 @@ public abstract class RequestBody { * 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 a string. + * @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 UTF-8 encoding of the serialized [value]. + * @return A replayable body whose bytes are the [serde]-encoded form of [value]. */ @JvmStatic @JvmOverloads @@ -208,7 +208,7 @@ public abstract class RequestBody { value: Any, serde: Serde, mediaType: MediaType = serde.contentType(), - ): RequestBody = create(serde.serializer.serialize(value), mediaType) + ): RequestBody = create(serde.serializer.serializeToByteArray(value), mediaType) /** * Creates a replayable `application/x-www-form-urlencoded` body from [formData]. 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 index fb3d1654..07880e27 100644 --- 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 @@ -88,9 +88,19 @@ public class RequestOptions private constructor( /** * 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 } @@ -113,7 +123,8 @@ public class RequestOptions private constructor( } /** Builds an immutable [RequestOptions]; [tags] is snapshotted into a read-only copy. */ - override fun build(): RequestOptions = RequestOptions(timeout, LinkedHashMap(tags), maxRetries) + override fun build(): RequestOptions = + RequestOptions(timeout, if (tags.isEmpty()) emptyMap() else LinkedHashMap(tags), maxRetries) } public companion object { 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 index 6c7ff546..4271a0cd 100644 --- 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 @@ -21,9 +21,14 @@ private val logger = ClientLogger("org.dexpace.sdk.core.http.response.ResponseEx private const val MAX_BUFFERED_ERROR_BODY_BYTES: Long = 1L * 1024 * 1024 /** - * Returns this response if [Response.isSuccessful]; otherwise buffers the body into an - * in-memory [ResponseBody], wraps it in a new [Response], and throws the matching - * [HttpException] produced by [HttpExceptionFactory.fromResponse]. + * 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 @@ -33,7 +38,7 @@ private const val MAX_BUFFERED_ERROR_BODY_BYTES: Long = 1L * 1024 * 1024 * @throws HttpException for any 4xx or 5xx response. */ public fun Response.throwOnError(): Response { - if (isSuccessful) return this + if (!status.isError) return this val buffer = Io.provider.buffer() val originalBody = body if (originalBody != null) { 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 22e6a6bf..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 @@ -8,7 +8,6 @@ package org.dexpace.sdk.core.http.response.exception import org.dexpace.sdk.core.http.response.Response -import org.dexpace.sdk.core.http.response.Status /** * Maps a non-2xx [Response] to the matching [HttpException] subclass. @@ -69,12 +68,15 @@ 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 = Status.fromCode(code).isError + public fun isErrorStatus(code: Int): Boolean = code in SC_CLIENT_ERROR_MIN..SC_SERVER_ERROR_MAX /** * Maps [response] to its [HttpException] subclass, or returns `null` when the status is not 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 624b898c..c306b5e5 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,6 +7,7 @@ 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 @@ -27,13 +28,23 @@ import kotlin.concurrent.withLock * ## Provider resolution order * * 1. **Explicit install** via [installProvider] — always wins. - * 2. **ServiceLoader auto-discovery** — when no explicit install exists and exactly one - * [IoProvider] implementation is discoverable via [ServiceLoader] on the classpath, - * it is resolved and cached automatically. The result is cached after the first resolution - * so the ServiceLoader scan runs at most once. + * 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. When exactly + * one implementation is discoverable it is resolved and cached automatically — the scan + * runs at most once. * 3. **Error** — zero discoverable providers throw with an actionable message; * more than one throw listing all candidates so the ambiguity is obvious. * + * ## 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 `@Volatile` fields so callers see the install effect without @@ -50,6 +61,20 @@ public object Io { @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 active provider: explicit install first, then ServiceLoader resolution. * @@ -59,12 +84,19 @@ public object Io { public val provider: IoProvider get() { installed?.let { return it } - resolved?.let { return it } + resolved?.let { + resolvedReturned = true + return it + } return lock.withLock { installed?.let { return it } - resolved?.let { return it } + resolved?.let { + resolvedReturned = true + return it + } val p = selectProvider(loadCandidates()) resolved = p + resolvedReturned = true p } } @@ -92,8 +124,52 @@ public object Io { } } - private fun loadCandidates(): List = - ServiceLoader.load(IoProvider::class.java, IoProvider::class.java.classLoader).toList() + /** + * 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. @@ -108,8 +184,11 @@ public object Io { * unconditionally; subsequent installs of the same instance are no-ops; subsequent * installs of a different provider throw [IllegalStateException]. * - * Any previously cached ServiceLoader-resolved provider is cleared so the explicit - * install takes full effect on the next [provider] access. + * 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. */ public fun installProvider(provider: IoProvider) { lock.withLock { @@ -122,24 +201,55 @@ 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 + if (!resolvedReturned || previouslyResolved === incoming) 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 so tests that reset state to null - * trigger a fresh resolution on the next [provider] access. + * 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 { val previous = installed installed = provider resolved = null + resolvedReturned = false previous } } 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 d29ebc92..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,7 +7,6 @@ package org.dexpace.sdk.core.serde -import org.dexpace.sdk.core.http.common.CommonMediaTypes import org.dexpace.sdk.core.http.common.MediaType /** @@ -28,10 +27,11 @@ public interface Serde { /** * 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]. Implementations may - * override to reflect a different wire format (e.g. `application/xml`). + * [org.dexpace.sdk.core.http.request.RequestBody.Companion.create]. * - * Default: `application/json`. + * 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 = CommonMediaTypes.APPLICATION_JSON + public fun contentType(): MediaType } 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 db08d2e5..8dffd11e 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 @@ -146,7 +146,16 @@ internal fun interruptibleFuture( // 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@execute - result.complete(task()) + 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 { @@ -158,3 +167,16 @@ internal fun interruptibleFuture( } return result } + +/** + * Best-effort close on a discard path. Used when a computed value loses the completion race to a + * cancelled future: the value is already being dropped, so a failure to close it has nothing + * actionable to surface. Mirrors the native transports' identically-named helper. + */ +private fun closeQuietly(closeable: AutoCloseable) { + 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/ProxyOptions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ProxyOptions.kt index 4cf5131f..b0567dbc 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 @@ -177,6 +177,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 1237ac28..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 @@ -26,6 +27,7 @@ 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 { @@ -181,6 +183,53 @@ class AsyncHttpClientTest { 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/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/pipeline/AsyncHttpPipelineTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/AsyncHttpPipelineTest.kt index d73f0f1a..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 @@ -199,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 @@ -284,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. 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 index b13b83e7..729f747e 100644 --- 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 @@ -28,6 +28,9 @@ 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 { @@ -98,5 +101,85 @@ class StandardResilienceTest { 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 index 26164f65..957856ab 100644 --- 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 @@ -15,6 +15,7 @@ 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 @@ -50,6 +51,20 @@ class AsyncThrowOnHttpErrorStepTest { 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") 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/ThrowOnHttpErrorStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ThrowOnHttpErrorStepTest.kt index d9ccb7a3..0ba7fa04 100644 --- 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 @@ -12,8 +12,10 @@ 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 @@ -77,6 +79,39 @@ class ThrowOnHttpErrorStepTest { 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")) } 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..565ece5b 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 { 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 index d33caad4..9a3bdfb7 100644 --- 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 @@ -70,6 +70,8 @@ class RequestBodyDxTest { type: Class, ): T = throw UnsupportedOperationException() } + + override fun contentType() = CommonMediaTypes.APPLICATION_JSON } private fun drain(body: RequestBody): ByteArray { @@ -103,7 +105,12 @@ class RequestBodyDxTest { } @Test - fun `Serde default contentType returns APPLICATION_JSON`() { - assertEquals(CommonMediaTypes.APPLICATION_JSON, fakeSerde.contentType()) + 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/RequestOptionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestOptionsTest.kt index 2b84a3a6..365247f4 100644 --- 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 @@ -10,9 +10,11 @@ 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 { @@ -39,6 +41,34 @@ class RequestOptionsTest { 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 = 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 c0e041ed..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 @@ -215,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 index 189a617b..dd5b30ed 100644 --- 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 @@ -73,6 +73,28 @@ class ResponseExtensionsTest { 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"}""") @@ -305,5 +327,7 @@ class ResponseExtensionsTest { 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/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/io/IoTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/io/IoTest.kt index 328a6e74..36b0e33d 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,20 @@ 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 @@ -185,4 +193,156 @@ class IoTest { 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 DIFFERENT provider still wins, but warns. + val explicit = object : IoProvider by OkioIoProvider {} + 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 `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/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-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 b973597a..8931fea2 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,6 +12,7 @@ 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 @@ -53,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. @@ -94,7 +98,11 @@ 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. + * 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 * 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 ef9516b5..0637cc38 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 @@ -11,7 +11,6 @@ import com.fasterxml.jackson.core.type.TypeReference 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.exception.HttpExceptionFactory import org.dexpace.sdk.core.http.response.throwOnError import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.SerdeException @@ -119,7 +118,7 @@ public fun jsonHandlerOrThrow( private fun throwIfNotSuccess(response: Response) { val status = response.status if (status.isSuccess) return - if (HttpExceptionFactory.isErrorStatus(status.code)) { + 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() 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 f944e34f..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 @@ -173,6 +173,37 @@ class JsonResponseHandlerTest { } } + @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() 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 3df98a44..09b314a1 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 @@ -9,7 +9,6 @@ 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.config.Configuration import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.request.RequestOptions import org.dexpace.sdk.core.http.response.Response @@ -404,10 +403,9 @@ public class JdkHttpTransport private constructor( } /** - * Reads proxy settings from the process-wide [Configuration.getGlobalConfiguration] via - * [ProxyOptions.fromConfiguration] — the standard `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` - * environment variables and their `https.proxy*` / `http.nonProxyHosts` system-property - * equivalents — and applies the resolved proxy to the client being built. + * 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 @@ -415,9 +413,7 @@ public class JdkHttpTransport private constructor( */ public fun proxyFromEnvironment(): Builder = apply { - ProxyOptions.fromConfiguration(Configuration.getGlobalConfiguration())?.let { - this.proxy = it - } + ProxyOptions.fromEnvironment()?.let { this.proxy = it } } /** 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 index 733c01c7..1c407827 100644 --- 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 @@ -8,17 +8,18 @@ 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 -import java.util.concurrent.CompletionException -import java.util.concurrent.ExecutionException /** * 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 [CompletionException] / - * [ExecutionException]. This strips those envelopes and then classifies the root cause: + * 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 @@ -30,19 +31,8 @@ import java.util.concurrent.ExecutionException * - Anything else is returned untouched. */ internal fun mapTransportFailure(error: Throwable): Throwable = - when (val cause = unwrapCompletion(error)) { + when (val cause = Futures.unwrap(error)) { is InterruptedIOException -> error is IOException -> NetworkException(cause.message, cause) else -> error } - -/** Strips [CompletionException] / [ExecutionException] envelopes to reach the underlying cause. */ -private fun unwrapCompletion(error: Throwable): Throwable { - var current = error - while (current is CompletionException || current is ExecutionException) { - val cause = current.cause ?: break - if (cause === current) break - current = cause - } - return current -} 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 10301644..6949fe51 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 @@ -14,7 +14,6 @@ import okhttp3.Credentials import okhttp3.OkHttpClient import org.dexpace.sdk.core.client.AsyncHttpClient import org.dexpace.sdk.core.client.HttpClient -import org.dexpace.sdk.core.config.Configuration import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.request.RequestOptions import org.dexpace.sdk.core.http.response.Response @@ -29,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 @@ -93,9 +93,14 @@ public class OkHttpTransport private constructor( * 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. Honours `Thread.interrupt` - * via `InterruptedIOException`: the interrupt flag is re-asserted before the exception is - * rethrown so callers see a consistent interrupt state. + * `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( @@ -108,18 +113,8 @@ public class OkHttpTransport private constructor( val okResponse = try { call.execute() - } 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 timeout — produced no response on - // the wire. Surface it as the SDK's NetworkException. Because NetworkException is - // itself an IOException, existing `catch (IOException)` sites keep matching, while - // the retry classifier now sees the retryable transport-failure contract. (The - // InterruptedIOException branch above runs first, so an interrupt-derived failure — - // including OkHttp's SocketTimeoutException — is never wrapped here.) - throw asNetworkException(e) + 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 @@ -127,6 +122,31 @@ 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 @@ -198,9 +218,10 @@ public class OkHttpTransport private constructor( call: Call, e: IOException, ) { - // Mirror the sync path's transport-error contract: a non-interrupt IOException - // is delivered as a NetworkException, an interrupt-derived one passes through - // untouched. + // Every failure OkHttp reports here produced no response, so surface it as a + // retryable NetworkException (see asNetworkException) — including a call-timeout, + // matching the sync path and the JDK transport. Cancellation is handled + // separately via call.cancel() after the future is already cancelled. future.completeExceptionally(asNetworkException(e)) } }, @@ -232,7 +253,15 @@ public class OkHttpTransport private constructor( call: Call, options: RequestOptions, ) { - options.timeout?.let { call.timeout().timeout(it.toMillis(), TimeUnit.MILLISECONDS) } + 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) + } } /** @@ -388,10 +417,9 @@ public class OkHttpTransport private constructor( } /** - * Reads proxy settings from the process-wide [Configuration.getGlobalConfiguration] via - * [ProxyOptions.fromConfiguration] — the standard `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` - * environment variables and their `https.proxy*` / `http.nonProxyHosts` system-property - * equivalents — and applies the resolved proxy to the client being built. + * 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 @@ -399,9 +427,7 @@ public class OkHttpTransport private constructor( */ public fun proxyFromEnvironment(): Builder = apply { - ProxyOptions.fromConfiguration(Configuration.getGlobalConfiguration())?.let { - this.proxy = it - } + ProxyOptions.fromEnvironment()?.let { this.proxy = it } } /** @@ -549,11 +575,18 @@ public class OkHttpTransport private constructor( } /** - * Maps an OkHttp transport-level [IOException] onto the SDK's [NetworkException] contract. An - * interrupt-derived failure ([InterruptedIOException], including OkHttp's `SocketTimeoutException`) - * is returned untouched so callers keep observing it as an interrupt; every other transport - * [IOException] is wrapped in a [NetworkException] — itself an [IOException], so existing - * `catch (IOException)` sites are unaffected and the retry classifier sees a retryable failure. + * 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`), and future + * cancellation is delivered via `call.cancel()` — surfacing as a plain `IOException("Canceled")` + * after the future is already cancelled — so `onFailure`'s exception is discarded in that race + * rather than observed by a caller. */ -private fun asNetworkException(e: IOException): IOException = - if (e is InterruptedIOException) e else NetworkException(e.message, e) +private fun asNetworkException(e: IOException): IOException = NetworkException(e.message, e) 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 a4c8bb45..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 @@ -511,16 +511,17 @@ class OkHttpTransportTest { OkHttpTransport.builder() .readTimeout(Duration.ofMillis(200)) .build() - val ex = - assertFails { - shortReadTransport.execute(simpleGet("/slow")).close() - } - 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. - Thread.interrupted() + // 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) } @@ -539,14 +540,50 @@ class OkHttpTransportTest { .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 = - assertFails { + assertFailsWith { transport.execute(simpleGet("/slow-percall"), options).close() } - assertTrue(ex is IOException, "expected IOException, got ${ex::class}") - // OkHttp's call-timeout path may leave the interrupt flag set; clear it and let - // MockWebServer's pending dispatch drain before @StartStop shuts it down. - Thread.interrupted() + 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) } @@ -590,10 +627,12 @@ class OkHttpTransportTest { 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 cause is the transport IOException. + // 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}") - assertTrue(ex.cause is IOException, "expected IOException cause, got ${ex.cause?.let { it::class }}") - Thread.interrupted() + 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) } @@ -635,12 +674,17 @@ class OkHttpTransportTest { @Test fun interruptIsNotWrappedAsNetworkException() { - // The interrupt path must remain unchanged: an InterruptedIOException surfaces as-is (with - // the interrupt flag re-asserted), never repackaged as a NetworkException. + // 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(), @@ -668,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") }, ) From e0f17542ebd1d2e127a61dc32258fcfdca7c31bf Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 13:44:22 +0300 Subject: [PATCH 35/46] fix: harden response reading and request value types - ResponseBody.string()/ResponseHandler.string() decode using the charset declared in the body's Content-Type, falling back to UTF-8, instead of hard-coding UTF-8 (silent mojibake on non-UTF-8 responses). - ResponseBody.create() close() is now idempotent so a source that throws on a second close cannot turn a successful call into a failure. - Response gains isInformational/isRedirect/isClientError/isServerError/isError alongside isSuccessful so callers need not reach into status.*. - CommonMediaTypes' six overlapping constants delegate to the MediaType companion constants, removing duplicate instances. - ETag.strong()/weak() reject characters outside the RFC 7232 etagc set. - RequestConditions.build() rejects mixing '*' with concrete entity-tags and collapses repeated '*' (RFC 7232 grammar). - Request builder verbs share one private helper; url(URI) also maps the unwrapped IllegalArgumentException from a non-absolute URI to the contextual message. - HttpRange KDoc corrected to describe the grammar the parser actually accepts. --- .superpowers/sdd/progress.md | 67 +++++++++++++++++++ .../sdk/core/http/common/CommonMediaTypes.kt | 15 +++-- .../org/dexpace/sdk/core/http/common/ETag.kt | 57 +++++++++++++++- .../dexpace/sdk/core/http/common/HttpRange.kt | 20 +++--- .../sdk/core/http/common/RequestConditions.kt | 31 ++++++++- .../dexpace/sdk/core/http/request/Request.kt | 54 +++++++-------- .../sdk/core/http/response/Response.kt | 15 +++++ .../sdk/core/http/response/ResponseBody.kt | 15 ++++- .../sdk/core/http/response/ResponseHandler.kt | 8 ++- .../core/http/common/RequestConditionsTest.kt | 25 ++++++- 10 files changed, 249 insertions(+), 58 deletions(-) create mode 100644 .superpowers/sdd/progress.md 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/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 24bcaccb..bc797a08 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 @@ -60,20 +60,26 @@ public class ETag private constructor(private val raw: String) { /** * 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 @@ -105,8 +111,53 @@ public 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 + + // RFC 7232 §2.3 etagc boundaries. + /** First printable ASCII byte allowed in `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/HttpRange.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpRange.kt index afeaad80..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,8 +15,10 @@ 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 @@ -76,14 +78,16 @@ public 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/RequestConditions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/RequestConditions.kt index 7f13ae4c..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 @@ -106,10 +108,18 @@ public class RequestConditions private constructor( /** 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, ) @@ -120,6 +130,21 @@ public class RequestConditions private constructor( * 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/request/Request.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Request.kt index f66c2889..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 @@ -163,6 +163,11 @@ public data class Request private constructor( 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) } } @@ -347,17 +352,26 @@ 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 = - apply { - this.method = Method.GET - this.body = null - } + public fun get(): RequestBuilder = verb(Method.GET, null) /** * Sets the method to [Method.POST] and the body to [body]. @@ -365,11 +379,7 @@ public data class Request private constructor( * @param body The request body. * @return This builder. */ - public fun post(body: RequestBody): RequestBuilder = - apply { - this.method = Method.POST - this.body = body - } + public fun post(body: RequestBody): RequestBuilder = verb(Method.POST, body) /** * Sets the method to [Method.PUT] and the body to [body]. @@ -377,11 +387,7 @@ public data class Request private constructor( * @param body The request body. * @return This builder. */ - public fun put(body: RequestBody): RequestBuilder = - apply { - this.method = Method.PUT - this.body = body - } + public fun put(body: RequestBody): RequestBuilder = verb(Method.PUT, body) /** * Sets the method to [Method.DELETE] and clears any previously-set body. Equivalent to @@ -391,11 +397,7 @@ public data class Request private constructor( * * @return This builder. */ - public fun delete(): RequestBuilder = - apply { - this.method = Method.DELETE - this.body = null - } + public fun delete(): RequestBuilder = verb(Method.DELETE, null) /** * Sets the method to [Method.HEAD] and clears any previously-set body. Equivalent to @@ -403,11 +405,7 @@ public data class Request private constructor( * * @return This builder. */ - public fun head(): RequestBuilder = - apply { - this.method = Method.HEAD - this.body = null - } + public fun head(): RequestBuilder = verb(Method.HEAD, null) /** * Sets the method to [Method.PATCH] and the body to [body]. @@ -415,11 +413,7 @@ public data class Request private constructor( * @param body The request body. * @return This builder. */ - public fun patch(body: RequestBody): RequestBuilder = - apply { - this.method = Method.PATCH - this.body = body - } + public fun patch(body: RequestBody): RequestBuilder = verb(Method.PATCH, body) /** * Builds the [Request]. 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 e9d88e33..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,9 +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 566dcf87..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 @@ -12,6 +12,7 @@ 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. @@ -66,12 +67,14 @@ public abstract class ResponseBody : Closeable { * 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 [Charsets.UTF_8]. + * @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 = Charsets.UTF_8): String = + public fun string(charset: Charset = mediaType()?.charset ?: Charsets.UTF_8): String = try { source().readString(charset) } finally { @@ -128,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 @@ -135,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/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/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 ed917d17..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 @@ -288,12 +288,33 @@ class RequestConditionsTest { @Test fun `ifNoneMatch String overload parses weak and star forms`() { - val rc = + 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() - assertEquals(listOf(ETag.weak("v1"), ETag.ANY), rc.ifNoneMatch) + } + 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 From 9f3852af0143cdcabf003c8406fe4f3808b09b28 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 13:49:29 +0300 Subject: [PATCH 36/46] fix: correct Io provider ergonomics and redirect-options defensive copy - Io.installProvider and Io.provider are now @JvmStatic so Java callers use the documented Io.installProvider(...) / Io.getProvider() spelling directly. - The provider fast path no longer issues a volatile store on every read once a provider is resolved. - IoProvider gains an 'underlying' hook; the install-after-resolve WARN compares through it, so installing a singleton after its ServiceLoader shim was auto-resolved no longer logs a spurious mixed-provider warning. - Clarified the resolution KDoc: the scan repeats until it succeeds, a resolved provider is cached process-wide, and hierarchical-classloader deployments with distinct per-loader providers should install explicitly. - HttpRedirectOptions takes Set (matching its getter and the sibling option builders) and makes a single defensive copy per build. - RequestOptions.timeout KDoc documents the per-transport scope difference and that the timeout is per-attempt, not an overall deadline. --- .../pipeline/steps/HttpRedirectOptions.kt | 18 ++++++------- .../sdk/core/http/request/RequestOptions.kt | 12 +++++++-- .../main/kotlin/org/dexpace/sdk/core/io/Io.kt | 26 ++++++++++++++----- .../org/dexpace/sdk/core/io/IoProvider.kt | 12 +++++++++ .../kotlin/org/dexpace/sdk/core/io/IoTest.kt | 9 +++++-- 5 files changed, 57 insertions(+), 20 deletions(-) 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 7403549f..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,18 +49,18 @@ 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 @@ -69,7 +69,7 @@ public class HttpRedirectOptions public fun newBuilder(): Builder = Builder().apply { maxHops(this@HttpRedirectOptions.maxHops) - allowedMethods(EnumSet.copyOf(this@HttpRedirectOptions.allowedMethods)) + allowedMethods(this@HttpRedirectOptions.allowedMethods) locationHeader(this@HttpRedirectOptions.locationHeader) follow303(this@HttpRedirectOptions.follow303) allowSchemeDowngrade(this@HttpRedirectOptions.allowSchemeDowngrade) @@ -82,7 +82,7 @@ public class HttpRedirectOptions */ public class Builder : org.dexpace.sdk.core.generics.Builder { private var maxHops: Int = 3 - private var allowedMethods: EnumSet = EnumSet.of(Method.GET, Method.HEAD) + 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 @@ -91,8 +91,8 @@ public class HttpRedirectOptions /** 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 [build] time. */ - public fun allowedMethods(value: EnumSet): Builder = apply { allowedMethods = 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 } 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 index 07880e27..9ac2a3e2 100644 --- 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 @@ -33,8 +33,16 @@ import org.dexpace.sdk.core.generics.Builder as SdkBuilder * 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. Applied by - * the transport as the per-call timeout (OkHttp: the call timeout; JDK: the per-request timeout). + * @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. 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 c306b5e5..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 @@ -31,12 +31,20 @@ import kotlin.concurrent.withLock * 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. When exactly - * one implementation is discoverable it is resolved and cached automatically — the scan - * runs at most once. + * 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 @@ -49,8 +57,8 @@ import kotlin.concurrent.withLock * * 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. The ServiceLoader scan runs at most once - * through a double-checked locking pattern. + * 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() @@ -81,11 +89,12 @@ public object Io { * 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?.let { return it } resolved?.let { - resolvedReturned = true + if (!resolvedReturned) resolvedReturned = true return it } return lock.withLock { @@ -190,6 +199,7 @@ public object Io { * [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 @@ -222,7 +232,9 @@ public object Io { private fun warnIfReplacingResolvedProvider(incoming: IoProvider) { if (installed != null) return val previouslyResolved = resolved ?: return - if (!resolvedReturned || previouslyResolved === incoming) 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()) 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/test/kotlin/org/dexpace/sdk/core/io/IoTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/io/IoTest.kt index 36b0e33d..9d483695 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 @@ -259,8 +259,13 @@ class IoTest { // Force ServiceLoader resolution AND hand-out (library code touching Io.provider early). val handedOut = Io.provider assertNotNull(handedOut) - // A late explicit install of a DIFFERENT provider still wins, but warns. - val explicit = object : IoProvider by OkioIoProvider {} + // 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") From 8c018bf572d6099b23e547bd4b11a89fa694a4ba Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 13:55:37 +0300 Subject: [PATCH 37/46] fix: make generic decoding type-safe and error bodies replayable - TypeRef rejects a captured unresolved type variable at construction instead of silently resolving it to Object (heap pollution / late ClassCastException). - The reified Deserializer.deserialize and Response.deserialize extensions capture T as a TypeRef so a parametric target (List) routes through the generic overloads rather than erasing to the raw class. - Deserializer gains a ByteArray + TypeRef overload for parity with the String and InputStream forms. - throwOnError buffers the error body into a replayable in-memory copy through a shared helper, so bodyAs(), string(), and bodySnapshot() can each read it; the buffer is acquired inside the body's use block so a provider-resolution failure still closes the connection. - Response.deserialize throws SerdeException (not a raw IllegalStateException) for a null body, staying inside the serde exception hierarchy callers catch. - ResponseExtensions gets @file:JvmName so Java reaches the helpers on a clean facade class. --- .../core/http/response/ResponseExtensions.kt | 73 ++++++++++++++----- .../dexpace/sdk/core/serde/Deserializer.kt | 31 ++++++-- .../org/dexpace/sdk/core/serde/TypeRef.kt | 25 ++++++- .../http/response/ResponseExtensionsTest.kt | 7 +- 4 files changed, 107 insertions(+), 29 deletions(-) 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 index 4271a0cd..fe1bab52 100644 --- 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 @@ -5,13 +5,18 @@ * 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.SerdeException import org.dexpace.sdk.core.serde.Serde import org.dexpace.sdk.core.serde.TypeRef @@ -32,29 +37,58 @@ private const val MAX_BUFFERED_ERROR_BODY_BYTES: Long = 1L * 1024 * 1024 * * 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 a single, in-memory byte sequence — it is not infinitely - * replayable; once read it is consumed. + * 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 - val buffer = Io.provider.buffer() - val originalBody = body - if (originalBody != null) { - originalBody.use { b -> + 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 = MAX_BUFFERED_ERROR_BODY_BYTES + var remaining = maxBytes while (remaining > 0L) { val n = src.read(buffer, remaining) if (n < 0L) break remaining -= n } + buffer.readByteArray() } - } - val bufferedBody = originalBody?.let { ResponseBody.create(buffer, it.mediaType(), buffer.size) } - val bufferedResponse = newBuilder().body(bufferedBody).build() - throw HttpExceptionFactory.fromResponse(bufferedResponse) + 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() } /** @@ -94,13 +128,13 @@ public fun HttpException.bodyAs( * @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 IllegalStateException if the response body is `null`. + * @throws SerdeException if the response body is `null`. */ public fun Response.deserialize( serde: Serde, type: Class, ): T { - val b = body ?: error("Response body is null — cannot deserialize into $type") + val b = body ?: throw SerdeException("Response body is null — cannot deserialize into $type") return try { serde.deserializer.deserialize(b.source().inputStream(), type) } finally { @@ -127,7 +161,7 @@ public fun Response.deserialize( serde: Serde, ref: TypeRef, ): T { - val b = body ?: error("Response body is null — cannot deserialize into ${ref.type}") + val b = body ?: throw SerdeException("Response body is null — cannot deserialize into ${ref.type}") return try { serde.deserializer.deserialize(b.source().inputStream(), ref) } finally { @@ -139,11 +173,14 @@ public fun Response.deserialize( * 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` - * overload. + * 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 IllegalStateException if the response body is `null`. + * @throws SerdeException if the response body is `null`. */ -public inline fun Response.deserialize(serde: Serde): T = deserialize(serde, T::class.java) +public inline fun Response.deserialize(serde: Serde): T = deserialize(serde, object : TypeRef() {}) 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 5625f013..9fb7bf1b 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 @@ -93,6 +94,18 @@ public interface Deserializer { 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) } } /** @@ -115,15 +128,19 @@ private inline fun deserializeViaRawClass( ) } +// 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/TypeRef.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/serde/TypeRef.kt index cfe78474..5b76cadf 100644 --- 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 @@ -55,11 +55,34 @@ public abstract class TypeRef protected constructor() { "TypeRef must be created as an anonymous subclass with a type argument, " + "e.g. object : TypeRef>() {}." } - type = superClass.actualTypeArguments.first() + 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) { 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 index dd5b30ed..0e3e8212 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -247,16 +248,16 @@ class ResponseExtensionsTest { } @Test - fun `deserialize throws IllegalStateException when body is null`() { + fun `deserialize throws SerdeException when body is null`() { val serde = fakeSerde(throwingDeserializer()) val r = response(Status.OK, null) - assertFailsWith { + assertFailsWith { r.deserialize(serde, User::class.java) } } @Test - fun `deserialize reified delegates to class overload`() { + fun `deserialize reified decodes a concrete type`() { val expected = User(99) val serde = fakeSerde(constantDeserializer(expected)) val r = response(Status.OK, """{"id":99}""") From e07aa031c7b95b11adeadb9b1d47de7cb2c0a786 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 14:06:58 +0300 Subject: [PATCH 38/46] fix: correct retry classification and recovery re-evaluation - DefaultRetryStep / DefaultAsyncRetryStep no longer misclassify a SocketTimeoutException (a subclass of InterruptedIOException) as cancellation; a read timeout is now left to the normal retry classification and retried. - DefaultRetryStep lets an unchecked exception (e.g. a typed HttpException thrown by an inner throwOnHttpError pipeline used as its transport) propagate as-is instead of burying it under IOException("HTTP pipeline failure"). - DefaultAsyncRetryStep stops the retry loop and closes any in-flight response once the returned future is cancelled or completed, so a cancelled call launches no further network attempts. - RetryRecovery re-classifies each re-sent response: a status in retryableStatuses is mapped to the matching HttpException (body buffered into a bounded replayable copy, connection released), so a 503,503,200 sequence keeps retrying to the 200 and the terminal outcome is a typed exception, not a raw error response. - RetryRecovery treats retryableStatuses as authoritative for an HttpException, so a configured status the built-in classifier does not mark retryable (e.g. 425) is now retried, and the default set is the effective set. --- .../pipeline/steps/DefaultAsyncRetryStep.kt | 31 +++++++++++---- .../http/pipeline/steps/DefaultRetryStep.kt | 27 ++++++------- .../core/http/response/ResponseExtensions.kt | 7 +++- .../core/pipeline/step/retry/RetryRecovery.kt | 39 ++++++++++++++----- 4 files changed, 72 insertions(+), 32 deletions(-) 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 bdee59a7..097c55be 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 @@ -180,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 } @@ -223,6 +226,12 @@ 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 = @@ -265,6 +274,9 @@ public open class DefaultAsyncRetryStep } 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 @@ -274,12 +286,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 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 3cf04782..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 /** @@ -226,7 +227,11 @@ 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) @@ -243,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/response/ResponseExtensions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/response/ResponseExtensions.kt index fe1bab52..b6a0ff98 100644 --- 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 @@ -22,8 +22,11 @@ import org.dexpace.sdk.core.serde.TypeRef private val logger = ClientLogger("org.dexpace.sdk.core.http.response.ResponseExtensions") -/** Maximum bytes buffered from the error body in [throwOnError]. Bodies exceeding this limit are truncated. */ -private const val MAX_BUFFERED_ERROR_BODY_BYTES: Long = 1L * 1024 * 1024 +/** + * 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` — diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery.kt index 5b4b8aed..b96c55d1 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecovery.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 @@ -156,12 +159,7 @@ public class RetryRecovery 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 RetryRecovery */ 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 RetryRecovery * (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 } /** From 6b30e5fe402597f265cc72b2d36fd2c26b16ab0d Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 14:22:59 +0300 Subject: [PATCH 39/46] fix: make pipeline builders, close(), and auth replay safe - StagedSteps.reload is now all-or-nothing: it validates the rebuilt pillar state in scratch collections and commits only on success, so a pillar collision no longer leaves the builder half-reloaded with steps dropped. - HttpPipelineBuilder/AsyncHttpPipelineBuilder gain explicit flattening constructors that take a pipeline (from() delegates to them); the generic transport constructor still nests. KDoc spells out flatten vs nest so the common Builder(pipeline) call no longer silently nests. - The duplicated pillar-vacancy check moves onto the shared StagedSteps. - HttpPipeline/AsyncHttpPipeline document that close() does NOT close the underlying transport, so use{}/try-with-resources over a pipeline no longer implies the transport was released. - AsyncHttpPipeline gains sendAsync(request, options, handler). - AuthStep gates its 401-challenge replay on body replayability: a non-replayable body surfaces the real 401 instead of a masked IOException. - The async standard preset documents that it follows no redirects. - ThrowOnHttpErrorRecovery reuses the shared bufferErrorBody helper. - Stage.PRE_REDIRECT documents its outermost terminal-response role. --- .../core/http/pipeline/AsyncHttpPipeline.kt | 42 +++++++++- .../http/pipeline/AsyncHttpPipelineBuilder.kt | 70 +++++++++++------ .../sdk/core/http/pipeline/HttpPipeline.kt | 17 ++++ .../core/http/pipeline/HttpPipelineBuilder.kt | 58 +++++++++----- .../dexpace/sdk/core/http/pipeline/Stage.kt | 11 ++- .../sdk/core/http/pipeline/StagedSteps.kt | 59 +++++++++++++- .../sdk/core/http/pipeline/steps/AuthStep.kt | 19 ++++- .../pipeline/step/ThrowOnHttpErrorRecovery.kt | 77 ++++--------------- 8 files changed, 236 insertions(+), 117 deletions(-) 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 93c1f962..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 @@ -92,6 +92,20 @@ public class AsyncHttpPipeline internal constructor( 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. @@ -108,6 +122,23 @@ public class AsyncHttpPipeline internal constructor( 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 @@ -122,9 +153,14 @@ public class AsyncHttpPipeline internal constructor( /** * Builds an async pipeline over [transport] wired with the async standard resilience * defaults — retry with backoff and instrumentation — via - * [AsyncHttpPipelineBuilder.appendStandardResilience]. As documented there, there is no - * async redirect default, and [scheduler] backs the retry step's non-blocking delays - * (the caller owns and shuts it down; the SDK never closes it). + * [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( 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 cdbb1d51..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 @@ -19,10 +19,33 @@ import java.util.concurrent.ScheduledExecutorService * 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) + /** + * 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) } @@ -42,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) @@ -49,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) @@ -102,9 +133,15 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) { * Appends the async standard resilience defaults: [DefaultAsyncRetryStep] ([Stage.RETRY]) and * [DefaultAsyncInstrumentationStep] ([Stage.LOGGING]). * - * There is **no async redirect default** — the SDK ships no [Stage.REDIRECT] `AsyncHttpStep`, - * so (unlike the synchronous [HttpPipelineBuilder.appendStandardResilience]) redirect following - * is omitted here. Add a custom async redirect step yourself if you need it. + * ## 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 @@ -126,26 +163,10 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) { DefaultAsyncRetryStep(scheduler), DefaultAsyncInstrumentationStep(), ) - requireStandardPillarsAvailable(additions) + steps.requirePillarsVacant(additions) return appendAll(additions) } - /** - * Precondition for [appendStandardResilience]: none of the pillar stages [additions] will fill - * may already be occupied. Validating up front turns what would otherwise be a - * partial-mutation-then-throw (the retry pillar installed before the later [append] rejects the - * conflict) into an atomic all-or-nothing check. - */ - private fun requireStandardPillarsAvailable(additions: List) { - val occupied = additions.map { it.stage }.filter { it.isPillar && steps.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()." - } - } - /** Builds an immutable [AsyncHttpPipeline]. */ public fun build(): AsyncHttpPipeline { val ordered = steps.flatten() @@ -153,9 +174,12 @@ public class AsyncHttpPipelineBuilder(private val httpClient: AsyncHttpClient) { } public companion object { - /** 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/HttpPipeline.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/HttpPipeline.kt index a2a88a60..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 @@ -81,6 +81,23 @@ public class HttpPipeline internal constructor( 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]. 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 730b24f7..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 @@ -27,10 +27,32 @@ import org.dexpace.sdk.core.http.pipeline.steps.ThrowOnHttpErrorStep * 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) + /** + * 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) } @@ -54,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) @@ -61,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) @@ -135,26 +165,10 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) { DefaultRetryStep(), DefaultInstrumentationStep(), ) - requireStandardPillarsAvailable(additions) + steps.requirePillarsVacant(additions) return appendAll(additions) } - /** - * Precondition for [appendStandardResilience]: none of the pillar stages [additions] will fill - * may already be occupied. Validating up front turns what would otherwise be a - * partial-mutation-then-throw (the first pillar installed before a later [append] rejects the - * conflict) into an atomic all-or-nothing check. - */ - private fun requireStandardPillarsAvailable(additions: List) { - val occupied = additions.map { it.stage }.filter { it.isPillar && steps.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()." - } - } - /** * Builds an immutable [HttpPipeline] in stage order. [Stage.SEND] is reserved for the * transport and is skipped. @@ -165,10 +179,12 @@ public class HttpPipelineBuilder(private val httpClient: HttpClient) { } public companion object { - /** 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/Stage.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/Stage.kt index 2214539d..ef47a2d3 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 @@ -26,7 +26,16 @@ package org.dexpace.sdk.core.http.pipeline @Suppress("unused") public enum class Stage(public val order: Int, public val isPillar: Boolean) { // -- Outermost: sees only the final response -- - PRE_REDIRECT(50, false), // runs *outside* the redirect+retry loops; sees the terminal response last + + /** + * 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 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 7cb97935..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 @@ -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,9 +204,22 @@ 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] + val existing = target[stage] if (existing != null && existing !== step) { val existingType = existing::class.simpleName ?: "" val newType = step::class.simpleName ?: "" @@ -178,7 +229,7 @@ internal class StagedSteps( "replace<$existingType>(newStep) instead of append/prepend.", ) } - pillars[stage] = step + target[stage] = step } } 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..ffe10207 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 @@ -98,6 +104,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 +132,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/pipeline/step/ThrowOnHttpErrorRecovery.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecovery.kt index dacb4319..a7cd08b6 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ThrowOnHttpErrorRecovery.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]. @@ -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 @@ -79,60 +80,8 @@ public object ThrowOnHttpErrorRecovery : 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()), + ) } } From 129bc6bd819a5b83214dd8730558ef440423859f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 14:29:44 +0300 Subject: [PATCH 40/46] fix: redact URL-valued headers and harden the async bridge SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Location / Content-Location header values are redacted through UrlRedactor (new redactUrlValue helper handling absolute and relative values) before logging, so an OAuth code, pre-signed signature, or implicit-flow token in a redirect target is no longer written to logs verbatim — matching url.full. - interruptibleFuture surfaces a RejectedExecutionException from a shut-down or saturated executor through the returned future instead of throwing synchronously, honouring the async-only failure-delivery contract. - InterruptibleFuture.cancel(true) hands off the interrupt under a short guard so a stale interrupt cannot land on the worker after it returns to the pool. - HttpClient.execute overloads declare @Throws(IOException) (NetworkException extends IOException), so a Java transport can throw it and a Java caller can catch it at the call site; the @Throws moves off asBlocking()'s pure-factory onto the wrapper's execute where the blocking wait happens. - A shared closeQuietly util replaces the duplicated silent-close copy in Futures. --- .../sdk/core/client/AsyncHttpClient.kt | 7 +- .../org/dexpace/sdk/core/client/HttpClient.kt | 8 ++ .../steps/DefaultAsyncInstrumentationStep.kt | 22 ++++- .../steps/DefaultInstrumentationStep.kt | 24 ++++- .../sdk/core/instrumentation/UrlRedactor.kt | 23 +++++ .../org/dexpace/sdk/core/util/Closeables.kt | 25 +++++ .../org/dexpace/sdk/core/util/Futures.kt | 91 +++++++++++-------- 7 files changed, 154 insertions(+), 46 deletions(-) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Closeables.kt 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 3bebd938..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 @@ -145,18 +145,21 @@ 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 { 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, 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 4a9b7c85..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 @@ -10,6 +10,7 @@ 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 @@ -48,7 +49,13 @@ 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 /** @@ -59,6 +66,7 @@ public fun interface HttpClient : AutoCloseable { * 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, 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/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/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/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 8dffd11e..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 @@ -12,6 +12,7 @@ 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 @@ -102,18 +103,35 @@ public object Futures { 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) { - worker.getAndSet(null)?.interrupt() + val thread = worker.getAndSet(null) + if (thread != null) { + interrupting.set(true) + try { + thread.interrupt() + } finally { + interrupting.set(false) + } + } } return cancelled } @@ -138,45 +156,42 @@ internal fun interruptibleFuture( task: () -> T, ): CompletableFuture { val result = InterruptibleFuture() - 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 task entirely. - if (result.isDone) return@execute - 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) + 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() } - } 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() } - } - return result -} - -/** - * Best-effort close on a discard path. Used when a computed value loses the completion race to a - * cancelled future: the value is already being dropped, so a failure to close it has nothing - * actionable to surface. Mirrors the native transports' identically-named helper. - */ -private fun closeQuietly(closeable: AutoCloseable) { + // 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 { - closeable.close() - } catch (ignored: Throwable) { - // Intentionally ignored: the value is already being discarded. + executor.execute(worker) + } catch (t: Throwable) { + result.completeExceptionally(t) } + return result } From 49dbe2151790a0501f256df97d224a3514a1b17f Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 14:46:55 +0300 Subject: [PATCH 41/46] fix: unify pagination extractors, thread options, and plug page leaks - PageNumberExtractor and LinkExtractor (byte-identical SAMs) collapse into one ItemsExtractor; CursorExtractor stays distinct. - Paginator/AsyncPaginator take an optional RequestOptions and pass it to every page fetch, so per-call overrides reach pagination when a pipeline drives it. - CloseablePages owns any page prefetched by a hasNext() probe and closes both the current and the prefetched page on close(), so an emptiness probe or a peeking wrapper no longer leaks a connection. - ClientIdentityStep append mode preserves all values of a multi-valued header and treats an all-blank token list as a no-op instead of emitting a blank one. - A serde-encoded multipart part defaults its Content-Type to the serde's media type when the caller gives none. --- .../sdk/core/http/request/MultipartBody.kt | 14 ++- .../sdk/core/pagination/AsyncPaginator.kt | 8 +- .../sdk/core/pagination/CloseablePages.kt | 72 ++++++++++++-- .../sdk/core/pagination/ItemsExtractor.kt | 31 ++++++ .../LinkHeaderPaginationStrategy.kt | 17 +--- .../PageNumberPaginationStrategy.kt | 17 +--- .../dexpace/sdk/core/pagination/Paginator.kt | 8 +- .../core/pipeline/step/ClientIdentityStep.kt | 54 +++++++---- .../core/http/request/MultipartBodyTest.kt | 14 +++ .../sdk/core/pagination/AsyncPaginatorTest.kt | 2 +- .../sdk/core/pagination/LazinessTest.kt | 2 +- .../pagination/LinkHeaderPaginationTest.kt | 6 +- .../pagination/PageNumberPaginationTest.kt | 6 +- .../sdk/core/pagination/PaginatorCapTest.kt | 2 +- .../sdk/core/pagination/PaginatorCloseTest.kt | 27 ++++++ .../core/pagination/PaginatorOptionsTest.kt | 97 +++++++++++++++++++ .../pipeline/step/ClientIdentityStepTest.kt | 34 +++++++ 17 files changed, 334 insertions(+), 77 deletions(-) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/ItemsExtractor.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorOptionsTest.kt 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/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..3976a9a0 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,11 +57,19 @@ 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. * * @throws IllegalStateException if called more than once on this view. */ @@ -56,16 +78,24 @@ public class CloseablePages 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 +133,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/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 929bc72d..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 @@ -12,21 +12,6 @@ import org.dexpace.sdk.core.http.response.Response import java.net.MalformedURLException import java.net.URL -/** - * Items extractor for [LinkHeaderPaginationStrategy]: reads the list of items from a page - * [Response] (the next-page URL comes from the `Link` header, not this extractor). - * - * 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. - * - * @param T Element type read from the response body. - */ -public fun interface LinkExtractor { - /** Reads the list of items from [response]. Must drain the response body synchronously. */ - public fun extract(response: Response): List -} - /** * RFC 5988 `Link` header [PaginationStrategy]. The server emits a `Link` header on each * page response with a `rel="next"` segment carrying the next page's full URL; the @@ -61,7 +46,7 @@ public fun interface LinkExtractor { public class LinkHeaderPaginationStrategy @JvmOverloads constructor( - private val itemsExtractor: LinkExtractor, + private val itemsExtractor: ItemsExtractor, private val linkHeader: String = "Link", ) : PaginationStrategy { override fun parse( 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 89e907b8..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 @@ -10,21 +10,6 @@ package org.dexpace.sdk.core.pagination import org.dexpace.sdk.core.http.request.Request import org.dexpace.sdk.core.http.response.Response -/** - * Items extractor for [PageNumberPaginationStrategy]: reads the list of items from a page - * [Response] (the next page number is derived from the request URL, not this extractor). - * - * 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. - * - * @param T Element type read from the response body. - */ -public fun interface PageNumberExtractor { - /** Reads the list of items from [response]. Must drain the response body synchronously. */ - public fun extract(response: Response): List -} - /** * Page-number [PaginationStrategy]. The client sends a 1-based (or [startPage]-based) * page number as a query parameter; the server returns the corresponding slice of items. @@ -53,7 +38,7 @@ public fun interface PageNumberExtractor { public class PageNumberPaginationStrategy @JvmOverloads constructor( - private val itemsExtractor: PageNumberExtractor, + private val itemsExtractor: ItemsExtractor, private val pageParam: String = "page", private val startPage: Int = 1, ) : PaginationStrategy { 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/step/ClientIdentityStep.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStep.kt index 20196879..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 * @@ -80,10 +81,11 @@ public class ClientIdentityStep * is unused — the transform depends only on the request's existing headers. * * Resolution rules: - * - empty tokens -> no change (request returned unchanged) - * - header absent -> header set to the joined token line - * - header present + Append -> header set to `existing + " " + joined` - * - header present + Replace -> header set to joined + * - 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, @@ -110,18 +112,30 @@ public class ClientIdentityStep 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 - val newValue = + // 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 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/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 565ece5b..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 @@ -321,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/pagination/AsyncPaginatorTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginatorTest.kt index cd8376b4..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 @@ -36,7 +36,7 @@ import kotlin.test.assertTrue class AsyncPaginatorTest { private val itemsExtractor = - LinkExtractor { resp -> + ItemsExtractor { resp -> val body = resp.body!!.source().use { it.readUtf8() } if (body.isEmpty()) emptyList() else body.split(",") } 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 a32a4e21..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 @@ -27,7 +27,7 @@ class LazinessTest { .build() private val itemsExtractor = - PageNumberExtractor { resp -> + ItemsExtractor { resp -> val body = resp.body!!.source().use { it.readUtf8() } if (body.isEmpty()) emptyList() else body.split(",") } 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 1ca3e8ba..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 @@ -27,7 +27,7 @@ class LinkHeaderPaginationTest { .build() private val itemsExtractor = - LinkExtractor { resp -> + ItemsExtractor { resp -> val body = resp.body!!.source().use { it.readUtf8() } if (body.isEmpty()) emptyList() else body.split(",") } @@ -45,9 +45,9 @@ class LinkHeaderPaginationTest { client.on("https://api.example.com/repo/issues?page=2") { req -> textResponse(req, "i3,i4") } - // Java caller path: an explicit LinkExtractor implementation, not a Kotlin function type. + // Java caller path: an explicit ItemsExtractor implementation, not a Kotlin function type. val javaStyle = - object : LinkExtractor { + object : ItemsExtractor { override fun extract(response: Response): List { val body = response.body!!.source().use { it.readUtf8() } return if (body.isEmpty()) emptyList() else body.split(",") 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 e60a38d2..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 @@ -27,7 +27,7 @@ class PageNumberPaginationTest { .build() private val itemsExtractor = - PageNumberExtractor { resp -> + ItemsExtractor { resp -> val body = resp.body!!.source().use { it.readUtf8() } if (body.isEmpty()) { emptyList() @@ -48,9 +48,9 @@ class PageNumberPaginationTest { client.on("https://api.example.com/things?page=3") { req -> textResponse(req, "") } - // Java caller path: an explicit PageNumberExtractor implementation, not a Kotlin function type. + // Java caller path: an explicit ItemsExtractor implementation, not a Kotlin function type. val javaStyle = - object : PageNumberExtractor { + object : ItemsExtractor { override fun extract(response: Response): List { val body = response.body!!.source().use { it.readUtf8() } return if (body.isEmpty()) emptyList() else body.split(",") 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 8b49b660..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 @@ -27,7 +27,7 @@ class PaginatorCapTest { .build() private val itemsExtractor = - LinkExtractor { resp -> + ItemsExtractor { resp -> val body = resp.body!!.source().use { it.readUtf8() } if (body.isEmpty()) emptyList() else body.split(",") } 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 1f4f4995..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 @@ -16,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 @@ -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..d089108d --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorOptionsTest.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.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/step/ClientIdentityStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/ClientIdentityStepTest.kt index da48b5ef..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 @@ -114,6 +114,40 @@ class ClientIdentityStepTest { 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 = From 731388e2b831c01d1f4187fac142f1dbcc9bc89e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 14:56:16 +0300 Subject: [PATCH 42/46] fix: keep the Okio ServiceLoader shim under R8 and harden Jackson decoding - sdk-io-okio3 ships a consumer keep rule for OkioIoProviderLoader and its no-arg constructor, so the ServiceLoader-based zero-config provider survives R8/ProGuard shrinking instead of being tree-shaken away. - JacksonSerde.deserializeAs(InputStream, TypeReference) drives a per-call parser with AUTO_CLOSE_SOURCE disabled, so it no longer closes the caller's stream. - Every non-null Jackson decode overload throws DeserializationException when the body is the JSON literal null instead of returning null through a non-null type. - JacksonDeserializer overrides deserialize(ByteArray, TypeRef) for parametric parity. - jsonHandlerOrThrow lands on a clean JsonResponseHandlers Java facade. - decode() lets a genuine mid-stream IOException propagate unwrapped instead of rebranding it as a SerdeException. - A non-2xx non-error status is reported as a protocol condition (with the status code and ETag/Location captured) rather than a deserialization failure. --- .../META-INF/proguard/sdk-io-okio3.pro | 6 +++ sdk-serde-jackson/api/sdk-serde-jackson.api | 2 +- .../dexpace/sdk/serde/jackson/JacksonSerde.kt | 51 ++++++++++++++++--- .../sdk/serde/jackson/JsonResponseHandler.kt | 49 ++++++++++++++---- 4 files changed, 88 insertions(+), 20 deletions(-) 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-serde-jackson/api/sdk-serde-jackson.api b/sdk-serde-jackson/api/sdk-serde-jackson.api index 8ec2e925..4180831a 100644 --- a/sdk-serde-jackson/api/sdk-serde-jackson.api +++ b/sdk-serde-jackson/api/sdk-serde-jackson.api @@ -25,7 +25,7 @@ public final class org/dexpace/sdk/serde/jackson/JacksonSerde$Companion { 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; 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 8931fea2..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 @@ -66,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. @@ -76,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 @@ -87,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. */ @@ -216,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( @@ -233,7 +241,7 @@ 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) } } @@ -246,7 +254,18 @@ internal class JacksonDeserializer internal constructor( override fun deserialize( input: String, ref: TypeRef, - ): T = deserializing { mapper.readValue(input, mapper.constructType(ref.type)) } + ): 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 @@ -260,7 +279,7 @@ internal class JacksonDeserializer internal constructor( ): T { val parser = mapper.factory.createParser(inputStream).disable(JsonParser.Feature.AUTO_CLOSE_SOURCE) return parser.use { p -> - deserializing { mapper.readValue(p, mapper.constructType(ref.type)) } + nonNull(deserializing { mapper.readValue(p, mapper.constructType(ref.type)) }, ref.type.typeName) } } } @@ -297,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 0637cc38..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,15 +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 @@ -24,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 @@ -50,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. @@ -111,9 +117,14 @@ public fun jsonHandlerOrThrow( /** * 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 closes the response and raises a [SerdeException]. Returns normally on - * a 2xx so the caller proceeds to decode. + * 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 @@ -123,16 +134,25 @@ private fun throwIfNotSuccess(response: Response) { // 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( - "Unexpected non-success HTTP status ${status.code}; expected a 2xx response to deserialize.", - ) + 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, @@ -149,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) } From 0ceb9d8ca58cb39e4ab0629ca19c60b7361c1aa3 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 15:01:03 +0300 Subject: [PATCH 43/46] docs: correct pagination snippets, stage diagram, and renamed-type references - Rewrite the README cursor-pagination example against the real Paginator and CursorPaginationStrategy/CursorExtractor signatures (the previous snippet used parameters that do not exist and would not compile). - Add PRE_REDIRECT to the pipeline-stages diagram and note its outermost, final-response-only role. - Clarify that throwOnHttpError maps 4xx/5xx and passes 1xx/3xx/2xx through. - Update docs/refs-comparison.md and docs/implementation-plan.md to the current recovery-stack type names (RequestRecoveryChain / ResponseRecoveryChain / RecoveryChain / RetryRecovery), leaving the *Step interfaces unchanged. --- README.md | 32 ++++++++++++++++++++------------ docs/implementation-plan.md | 14 +++++++------- docs/refs-comparison.md | 12 ++++++------ 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f137ee5d..9befe5a2 100644 --- a/README.md +++ b/README.md @@ -323,8 +323,9 @@ val pipeline = HttpPipelineBuilder(transport) ### Throw typed exceptions on 4xx / 5xx -Add `throwOnHttpError()` to the pipeline builder — it maps non-2xx responses to typed -`HttpException` subclasses (`BadRequestException`, `TooManyRequestsException`, etc.): +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) @@ -351,12 +352,15 @@ 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( - firstPageFetcher = { pipeline.send(Request.get("https://api.example.com/items")) }, - nextPageFetcher = { cursor -> - pipeline.send(Request.get("https://api.example.com/items?page_token=$cursor")) - }, - strategy = CursorPaginationStrategy(items = { parseItems(it) }, cursorExtractor = { nextToken(it) }), + 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) } @@ -383,12 +387,15 @@ val request = Request.builder() 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`) @@ -417,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/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/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/`] From c8f8c77fa7343af5ea6d7d3fc6e1c15594dc529d Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 15:15:55 +0300 Subject: [PATCH 44/46] fix: transport cancellation, Content-Type parity, and adapter options - OkHttp: a call cancelled inside OkHttp (dispatcher/interceptor) completes with a non-retryable InterruptedIOException instead of a retryable NetworkException, so a deliberately cancelled request is not re-sent; a call-timeout is discriminated by exception type and stays retryable. - OkHttp: an explicit caller Content-Type header now wins over the body's auto-stamped media type (previously BridgeInterceptor overwrote it). - JDK transport: emits Content-Type from the body's media type when the caller set none, so the RequestBody.create(value, serde) pattern carries a Content-Type on both transports; documents the residual bodyless-DELETE Content-Length: 0 difference the java.net.http API cannot avoid. - Documented the OkHttp vs JDK default response-timeout divergence and the JDK HTTPS-CONNECT Basic-proxy-auth limitation (with the workaround). - Coroutines/reactor/netty adapters gain per-call RequestOptions overloads so options are no longer dropped; asAsyncCoroutines closes a Response discarded in the cancellation race; corrected the coroutines and virtual-threads KDocs. --- .../api/sdk-async-coroutines.api | 2 + .../sdk/async/coroutines/Coroutines.kt | 84 +++++++++++++++++-- sdk-async-netty/api/sdk-async-netty.api | 2 + .../org/dexpace/sdk/async/netty/Netty.kt | 23 +++++ sdk-async-reactor/api/sdk-async-reactor.api | 2 + .../org/dexpace/sdk/async/reactor/Reactor.kt | 21 +++++ .../async/virtualthreads/VirtualThreads.kt | 9 +- .../org/dexpace/sdk/core/util/ProxyOptions.kt | 10 ++- .../sdk/transport/jdkhttp/JdkHttpTransport.kt | 16 ++++ .../jdkhttp/internal/RequestAdapter.kt | 50 +++++++++++ .../sdk/transport/okhttp/OkHttpTransport.kt | 61 +++++++++++--- .../okhttp/internal/FileRequestBodyAdapter.kt | 15 +++- .../okhttp/internal/RequestAdapter.kt | 21 +++-- .../okhttp/internal/SdkRequestBodyAdapter.kt | 21 +++-- 14 files changed, 303 insertions(+), 34 deletions(-) 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 a0beaaa0..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 @@ -23,6 +23,7 @@ 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 @@ -32,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 @@ -94,7 +117,56 @@ public fun HttpClient.asAsyncCoroutines(scope: CoroutineScope): AsyncHttpClient override fun executeAsync( request: Request, options: RequestOptions, - ): CompletableFuture = - scope.future(MDCContext()) { runInterruptible(Dispatchers.IO) { client.execute(request, options) } } + ): 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-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/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 59e062cf..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 @@ -62,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 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 b0567dbc..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 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 09b314a1..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 @@ -410,6 +410,10 @@ public class JdkHttpTransport private constructor( * 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 { @@ -505,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/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-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 6949fe51..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 @@ -218,11 +218,33 @@ public class OkHttpTransport private constructor( call: Call, e: IOException, ) { - // Every failure OkHttp reports here produced no response, so surface it as a - // retryable NetworkException (see asNetworkException) — including a call-timeout, - // matching the sync path and the JDK transport. Cancellation is handled - // separately via call.cancel() after the future is already cancelled. - future.completeExceptionally(asNetworkException(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)) + } } }, ) @@ -374,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 @@ -390,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 @@ -584,9 +622,12 @@ public class OkHttpTransport private constructor( * 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`), and future - * cancellation is delivered via `call.cancel()` — surfacing as a plain `IOException("Canceled")` - * after the future is already cancelled — so `onFailure`'s exception is discarded in that race - * rather than observed by a caller. + * 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() From cada2999d3d317fa2c142e5ae894d2ff9ce825b4 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 15:26:17 +0300 Subject: [PATCH 45/46] chore: regenerate API snapshots and satisfy quality gates - apiDump for the accumulated sdk-core / sdk-io-okio3 public-API changes. - Update two RetryStepTest cases that asserted the old IOException-wrapping to expect the unchecked exception propagating as-is. - ktlint formatting and detekt suppressions (ReturnCount on the auth/async-retry early-exit paths; IteratorHasNextCallsNextMethod on the deliberate page prefetch) for the new code. --- sdk-core/api/sdk-core.api | 64 ++++++++++++------- .../org/dexpace/sdk/core/http/common/ETag.kt | 3 +- .../dexpace/sdk/core/http/pipeline/Stage.kt | 2 - .../sdk/core/http/pipeline/steps/AuthStep.kt | 1 + .../pipeline/steps/DefaultAsyncRetryStep.kt | 1 + .../core/http/response/ResponseExtensions.kt | 2 +- .../sdk/core/pagination/CloseablePages.kt | 6 ++ .../dexpace/sdk/core/serde/Deserializer.kt | 3 +- .../core/http/pipeline/steps/RetryStepTest.kt | 21 +++--- .../core/pagination/PaginatorOptionsTest.kt | 3 +- sdk-io-okio3/api/sdk-io-okio3.api | 1 + 11 files changed, 65 insertions(+), 42 deletions(-) diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 789e996a..b69a9d87 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -739,6 +739,7 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline : org/de 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; } @@ -751,6 +752,7 @@ public final class org/dexpace/sdk/core/http/pipeline/AsyncHttpPipeline$Companio 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; @@ -811,6 +813,7 @@ 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; @@ -1051,12 +1054,12 @@ 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; @@ -1070,7 +1073,7 @@ public final class org/dexpace/sdk/core/http/pipeline/steps/HttpRedirectOptions 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/EnumSet;)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; @@ -1523,6 +1526,11 @@ 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; @@ -1574,7 +1582,7 @@ 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/ResponseExtensionsKt { +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; @@ -2280,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 { @@ -2373,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 @@ -2420,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; @@ -2463,14 +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/LinkExtractor { +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 (Lorg/dexpace/sdk/core/pagination/LinkExtractor;)V - public fun (Lorg/dexpace/sdk/core/pagination/LinkExtractor;Ljava/lang/String;)V - public synthetic fun (Lorg/dexpace/sdk/core/pagination/LinkExtractor;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; } @@ -2505,15 +2522,11 @@ public final class org/dexpace/sdk/core/pagination/PageInfo { public final fun getNextRequest ()Lorg/dexpace/sdk/core/http/request/Request; } -public abstract interface class org/dexpace/sdk/core/pagination/PageNumberExtractor { - public abstract fun extract (Lorg/dexpace/sdk/core/http/response/Response;)Ljava/util/List; -} - public final class org/dexpace/sdk/core/pagination/PageNumberPaginationStrategy : org/dexpace/sdk/core/pagination/PaginationStrategy { - public fun (Lorg/dexpace/sdk/core/pagination/PageNumberExtractor;)V - public fun (Lorg/dexpace/sdk/core/pagination/PageNumberExtractor;Ljava/lang/String;)V - public fun (Lorg/dexpace/sdk/core/pagination/PageNumberExtractor;Ljava/lang/String;I)V - public synthetic fun (Lorg/dexpace/sdk/core/pagination/PageNumberExtractor;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; } @@ -2536,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; @@ -2801,11 +2815,13 @@ public abstract interface class org/dexpace/sdk/core/serde/Deserializer { 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 { 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 bc797a08..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 @@ -144,8 +144,7 @@ public class ETag private constructor(private val raw: String) { // suffix + at least 1 opaque char between). private const val WEAK_FORM_MIN_LEN = 4 - // RFC 7232 §2.3 etagc boundaries. - /** First printable ASCII byte allowed in `etagc` (`!`). */ + /** 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. */ 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 ef47a2d3..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,8 +25,6 @@ package org.dexpace.sdk.core.http.pipeline */ @Suppress("unused") public enum class Stage(public val order: Int, public val isPillar: Boolean) { - // -- Outermost: sees only the final response -- - /** * 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 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 ffe10207..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 @@ -67,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, 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 097c55be..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 @@ -273,6 +273,7 @@ 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. 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 index b6a0ff98..669006e4 100644 --- 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 @@ -16,8 +16,8 @@ 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.SerdeException 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") 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 3976a9a0..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 @@ -71,8 +71,14 @@ public class CloseablePages * `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 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 9fb7bf1b..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 @@ -136,7 +136,8 @@ private inline fun deserializeViaRawClass( 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, object : TypeRef() {}) +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 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 08579d9c..945288c7 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 @@ -542,12 +542,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()) } @@ -1723,10 +1723,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/pagination/PaginatorOptionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorOptionsTest.kt index d089108d..00fbfeba 100644 --- 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 @@ -42,8 +42,7 @@ class PaginatorOptionsTest { if (body.isEmpty()) emptyList() else body.split(",") } - private fun options(): RequestOptions = - RequestOptions.builder().maxRetries(0).tag("origin", "test").build() + private fun options(): RequestOptions = RequestOptions.builder().maxRetries(0).tag("origin", "test").build() @Test fun `sync paginator threads its RequestOptions into every page fetch`() { 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; From ada301b9fc4e3892c4aaad46892f97571033ef12 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 6 Jul 2026 15:50:00 +0300 Subject: [PATCH 46/46] test: lock in the review-fix behaviors with regression tests Cover the trickiest corrected behaviors so a future change cannot silently reintroduce them: RetryRecovery re-classifying re-sent responses (503,503,200 reaches 200; exhaustion yields the mapped exception; a configured retryable status widens the set); TypeRef rejecting a captured type variable; the Response status-range predicates; charset-aware ResponseBody.string(); the replayable throwOnError error body; SocketTimeoutException being retried rather than treated as cancellation; no spurious mixed-provider warning; atomic StagedSteps.reload and the flattening HttpPipelineBuilder(pipeline) constructor; the auth-challenge non-replayable-body path; Jackson rejecting a literal-null body and not closing a caller stream; and UrlRedactor.redactUrlValue. --- .../core/http/pipeline/HttpPipelineTest.kt | 45 ++++++++++++ .../sdk/core/http/pipeline/StagedStepsTest.kt | 17 +++++ .../core/http/pipeline/steps/AuthStepTest.kt | 42 +++++++++++ .../steps/DefaultAsyncRetryStepTest.kt | 17 +++++ .../core/http/pipeline/steps/RetryStepTest.kt | 27 +++++++ .../http/response/ResponseExtensionsTest.kt | 38 ++++++++++ .../sdk/core/http/response/ResponseTest.kt | 72 +++++++++++++++++++ .../core/instrumentation/UrlRedactorTest.kt | 33 +++++++++ .../kotlin/org/dexpace/sdk/core/io/IoTest.kt | 39 ++++++++++ .../pipeline/step/retry/RetryRecoveryTest.kt | 63 ++++++++++++++++ .../org/dexpace/sdk/core/serde/TypeRefTest.kt | 39 ++++++++++ .../sdk/serde/jackson/JacksonSerdeTest.kt | 58 +++++++++++++++ 12 files changed, 490 insertions(+) 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 fea48f00..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 @@ -750,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() 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 fce6452b..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 @@ -87,6 +87,23 @@ 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 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 6e5bdedc..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 @@ -28,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 @@ -190,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/RetryStepTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryStepTest.kt index 945288c7..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 @@ -35,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 @@ -504,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) 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 index 0e3e8212..dbbf4bb2 100644 --- 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 @@ -133,6 +133,24 @@ class ResponseExtensionsTest { 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") @@ -324,6 +342,26 @@ class ResponseExtensionsTest { ) } + /** 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() 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 5bb362ca..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 @@ -403,6 +403,52 @@ class ResponseTest { .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 { @@ -522,4 +568,30 @@ class ResponseBodyTest { 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/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 9d483695..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 @@ -305,6 +305,45 @@ class IoTest { } } + @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") diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecoveryTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecoveryTest.kt index f90f99df..594e7b27 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecoveryTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryRecoveryTest.kt @@ -336,6 +336,68 @@ class RetryRecoveryTest { assertTrue(client.calls.isEmpty()) } + @Test + 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 @@ -747,6 +809,7 @@ class RetryRecoveryTest { 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/TypeRefTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/serde/TypeRefTest.kt index fada55f6..27d8b801 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -22,6 +23,15 @@ class TypeRefTest { @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() {} @@ -58,4 +68,33 @@ class TypeRefTest { 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-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 7c085c1d..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 @@ -167,6 +167,17 @@ class JacksonSerdeTest { 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() @@ -215,6 +226,19 @@ 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 @@ -388,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()