From 5c4811ab25b9f8acdac3ef110c85443616253e55 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 05:37:01 +0300 Subject: [PATCH] feat: add per-phase Timeout value type to sdk-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an immutable Timeout value type splitting a call's I/O budget into connect, read, write, and request phases. The read and write phases inherit the request timeout when left unset, so a caller can express "finish within N seconds" with a single value while still being able to pin individual phases. Connect establishment is kept separate and does not inherit. Every phase uses Duration.ZERO to mean "no limit", matching the underlying HTTP clients this maps onto. The builder validates durations are non-negative and nanosecond- representable. This is a core value type only; transports translate the resolved phases onto their native settings. It is deliberately independent of the retry total-timeout budget — a Timeout bounds a single attempt's phases, while the retry budget bounds the sum of all attempts plus backoff, and the two compose. Wire it into the configuration surface with well-known keys (CONNECT_TIMEOUT, READ_TIMEOUT, WRITE_TIMEOUT, REQUEST_TIMEOUT) and a Configuration.getTimeout() accessor that resolves a Timeout from those keys, preserving the value type's inheritance and never throwing on a malformed value. Closes #41 --- sdk-core/api/sdk-core.api | 43 ++++ .../dexpace/sdk/core/config/Configuration.kt | 46 ++++ .../org/dexpace/sdk/core/config/Timeout.kt | 231 +++++++++++++++++ .../core/config/ConfigurationTimeoutTest.kt | 98 ++++++++ .../dexpace/sdk/core/config/TimeoutTest.kt | 235 ++++++++++++++++++ 5 files changed, 653 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Timeout.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTimeoutTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/TimeoutTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..cc8a98ae 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -22,12 +22,16 @@ public final class org/dexpace/sdk/core/client/HttpClient$DefaultImpls { } public final class org/dexpace/sdk/core/config/Configuration { + public static final field CONNECT_TIMEOUT Ljava/lang/String; public static final field Companion Lorg/dexpace/sdk/core/config/Configuration$Companion; public static final field HTTPS_PROXY Ljava/lang/String; public static final field HTTP_PROXY Ljava/lang/String; public static final field LOG_LEVEL Ljava/lang/String; public static final field MAX_RETRY_ATTEMPTS Ljava/lang/String; public static final field NO_PROXY Ljava/lang/String; + public static final field READ_TIMEOUT Ljava/lang/String; + public static final field REQUEST_TIMEOUT Ljava/lang/String; + public static final field WRITE_TIMEOUT Ljava/lang/String; public final fun get (Ljava/lang/String;)Ljava/lang/String; public final fun get (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; public static synthetic fun get$default (Lorg/dexpace/sdk/core/config/Configuration;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String; @@ -36,6 +40,9 @@ public final class org/dexpace/sdk/core/config/Configuration { public static final fun getGlobalConfiguration ()Lorg/dexpace/sdk/core/config/Configuration; public final fun getInt (Ljava/lang/String;I)I public final fun getProperty (Ljava/lang/String;)Ljava/lang/String; + public final fun getTimeout ()Lorg/dexpace/sdk/core/config/Timeout; + public final fun getTimeout (Lorg/dexpace/sdk/core/config/Timeout;)Lorg/dexpace/sdk/core/config/Timeout; + public static synthetic fun getTimeout$default (Lorg/dexpace/sdk/core/config/Configuration;Lorg/dexpace/sdk/core/config/Timeout;ILjava/lang/Object;)Lorg/dexpace/sdk/core/config/Timeout; public static final fun setGlobalConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)V } @@ -52,6 +59,42 @@ public final class org/dexpace/sdk/core/config/ConfigurationBuilder { public final fun put (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; } +public final class org/dexpace/sdk/core/config/Timeout { + public static final field Companion Lorg/dexpace/sdk/core/config/Timeout$Companion; + public static final field NONE Lorg/dexpace/sdk/core/config/Timeout; + public synthetic fun (Ljava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;ZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun builder ()Lorg/dexpace/sdk/core/config/Timeout$TimeoutBuilder; + public static final fun defaults ()Lorg/dexpace/sdk/core/config/Timeout; + public fun equals (Ljava/lang/Object;)Z + public final fun getConnectTimeout ()Ljava/time/Duration; + public final fun getEffectiveReadTimeout ()Ljava/time/Duration; + public final fun getEffectiveWriteTimeout ()Ljava/time/Duration; + public final fun getReadTimeout ()Ljava/time/Duration; + public final fun getRequestTimeout ()Ljava/time/Duration; + public final fun getWriteTimeout ()Ljava/time/Duration; + public fun hashCode ()I + public final fun newBuilder ()Lorg/dexpace/sdk/core/config/Timeout$TimeoutBuilder; + public static final fun ofRequest (Ljava/time/Duration;)Lorg/dexpace/sdk/core/config/Timeout; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/config/Timeout$Companion { + public final fun builder ()Lorg/dexpace/sdk/core/config/Timeout$TimeoutBuilder; + public final fun defaults ()Lorg/dexpace/sdk/core/config/Timeout; + public final fun ofRequest (Ljava/time/Duration;)Lorg/dexpace/sdk/core/config/Timeout; +} + +public final class org/dexpace/sdk/core/config/Timeout$TimeoutBuilder : org/dexpace/sdk/core/generics/Builder { + public fun ()V + public fun (Lorg/dexpace/sdk/core/config/Timeout;)V + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/config/Timeout; + public final fun connectTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/core/config/Timeout$TimeoutBuilder; + public final fun readTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/core/config/Timeout$TimeoutBuilder; + public final fun requestTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/core/config/Timeout$TimeoutBuilder; + public final fun writeTimeout (Ljava/time/Duration;)Lorg/dexpace/sdk/core/config/Timeout$TimeoutBuilder; +} + public abstract interface class org/dexpace/sdk/core/generics/Builder { public abstract fun build ()Ljava/lang/Object; } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt index 321de182..6cbd2523 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt @@ -94,6 +94,28 @@ public class Configuration internal constructor( default: Duration, ): Duration = get(name)?.let { parseDuration(it) } ?: default + /** + * Resolve a per-phase [Timeout] from the well-known timeout keys + * ([CONNECT_TIMEOUT], [READ_TIMEOUT], [WRITE_TIMEOUT], [REQUEST_TIMEOUT]). + * + * The connect and request phases fall back to the matching phase of [default] (which itself + * defaults to [Timeout.NONE]) when their key is missing or unparseable. The read and write + * phases are only pinned when their key is present and parses — otherwise they are left unset so + * the resulting [Timeout] applies its own request-timeout inheritance. Configuration issues never + * throw here: a bad value behaves as if the key were absent. + */ + @JvmOverloads + public fun getTimeout(default: Timeout = Timeout.NONE): Timeout { + val builder = + Timeout + .builder() + .connectTimeout(getDuration(CONNECT_TIMEOUT, default.connectTimeout)) + .requestTimeout(getDuration(REQUEST_TIMEOUT, default.requestTimeout)) + get(READ_TIMEOUT)?.let { parseDuration(it) }?.let { builder.readTimeout(it) } + get(WRITE_TIMEOUT)?.let { parseDuration(it) }?.let { builder.writeTimeout(it) } + return builder.build() + } + public companion object { // Well-known keys. `const val` so callers reference them as `Configuration.MAX_RETRY_ATTEMPTS` // from both Kotlin and Java without going through `Companion`. @@ -113,6 +135,30 @@ public class Configuration internal constructor( /** Standard environment variable for the comma-separated no-proxy host list. */ public const val NO_PROXY: String = "NO_PROXY" + /** + * Connection-establishment timeout for [getTimeout]. Parsed by [getDuration], so it accepts + * ISO-8601 (`PT5S`) or shorthand (`5s`, `500ms`). `0` (or an empty value) means no limit. + */ + public const val CONNECT_TIMEOUT: String = "CONNECT_TIMEOUT" + + /** + * Response-read timeout for [getTimeout]. When unset, the read phase inherits + * [REQUEST_TIMEOUT]. Same value grammar as [CONNECT_TIMEOUT]. + */ + public const val READ_TIMEOUT: String = "READ_TIMEOUT" + + /** + * Request-write timeout for [getTimeout]. When unset, the write phase inherits + * [REQUEST_TIMEOUT]. Same value grammar as [CONNECT_TIMEOUT]. + */ + public const val WRITE_TIMEOUT: String = "WRITE_TIMEOUT" + + /** + * End-to-end request timeout for [getTimeout]. The read and write phases inherit this value + * unless their own keys are set. Same value grammar as [CONNECT_TIMEOUT]. + */ + public const val REQUEST_TIMEOUT: String = "REQUEST_TIMEOUT" + @Volatile private var global: Configuration = Configuration(emptyMap()) diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Timeout.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Timeout.kt new file mode 100644 index 00000000..ef013037 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Timeout.kt @@ -0,0 +1,231 @@ +/* + * 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.config + +import org.dexpace.sdk.core.generics.Builder +import java.time.Duration + +// Largest delay representable as Long nanoseconds (~292 years). A larger value overflows +// Duration.toNanos() when transports translate to native millisecond/nanosecond settings, so +// the builder rejects it up front rather than letting an ArithmeticException surface later. +private val MAX_NANO_REPRESENTABLE: Duration = Duration.ofNanos(Long.MAX_VALUE) + +/** + * Immutable per-phase I/O timeout budget for a single HTTP exchange. + * + * Splits the time a call may spend into the four phases a transport can bound independently: + * + * - [connectTimeout] — establishing the TCP connection (and TLS handshake). + * - [readTimeout] — waiting for response bytes once the request is sent. + * - [writeTimeout] — flushing request bytes to the socket. + * - [requestTimeout] — the end-to-end ceiling for the whole call. + * + * [readTimeout] and [writeTimeout] **default to [requestTimeout]** when left unset, so a caller who + * only knows "this call must finish within N seconds" can set [requestTimeout] alone and have the + * read/write phases inherit it. [connectTimeout] does not inherit — connection establishment is a + * distinct concern that callers usually want to bound separately (often more tightly). + * + * Every phase uses [Duration.ZERO] to mean **no timeout** (wait indefinitely), matching the + * convention of the underlying clients this translates to (OkHttp, `java.net.http`). The + * [effectiveReadTimeout] / [effectiveWriteTimeout] accessors resolve the inheritance so transport + * adapters can read the value a phase should actually enforce without re-implementing the fallback. + * + * ## Relationship to retry total-timeout + * + * This type is **independent of** the retry budget + * ([RetrySettings.totalTimeout][org.dexpace.sdk.core.pipeline.step.retry.RetrySettings.totalTimeout]). + * A [Timeout] bounds a *single* attempt's I/O phases; the retry total-timeout bounds the *sum* of + * all attempts plus the backoff delays between them. They compose: each retry attempt is dispatched + * with this per-phase budget, and the retry layer abandons further attempts once its own total + * budget is exhausted. Neither value derives from the other. + * + * ## Translation to transports + * + * `sdk-core` defines the value type only; it does not enforce the durations. Each transport adapter + * maps the resolved phases onto its native client (e.g. OkHttp's + * `connectTimeout`/`readTimeout`/`writeTimeout`, or `java.net.http`'s `connectTimeout` plus a + * per-request `timeout`). A phase of [Duration.ZERO] disables that native setting. + * + * ## Thread-safety + * + * Instances are immutable and safe to share across threads. Builders are **not** thread-safe; + * confine to a single thread or guard externally. + * + * @property connectTimeout Ceiling for connection establishment. [Duration.ZERO] means no limit. + * @property readTimeout Ceiling for reading the response. [Duration.ZERO] means no limit; when it + * was never set explicitly it falls back to [requestTimeout] via [effectiveReadTimeout]. + * @property writeTimeout Ceiling for writing the request. [Duration.ZERO] means no limit; when it + * was never set explicitly it falls back to [requestTimeout] via [effectiveWriteTimeout]. + * @property requestTimeout End-to-end ceiling for the whole call. [Duration.ZERO] means no limit. + */ +public class Timeout + private constructor( + public val connectTimeout: Duration, + public val readTimeout: Duration, + public val writeTimeout: Duration, + public val requestTimeout: Duration, + // True when readTimeout was set explicitly; false means "inherit requestTimeout". + private val readExplicit: Boolean, + // True when writeTimeout was set explicitly; false means "inherit requestTimeout". + private val writeExplicit: Boolean, + ) { + /** + * The read timeout a transport should actually enforce: [readTimeout] when it was set + * explicitly, otherwise [requestTimeout]. [Duration.ZERO] means no limit. + */ + public val effectiveReadTimeout: Duration + get() = if (readExplicit) readTimeout else requestTimeout + + /** + * The write timeout a transport should actually enforce: [writeTimeout] when it was set + * explicitly, otherwise [requestTimeout]. [Duration.ZERO] means no limit. + */ + public val effectiveWriteTimeout: Duration + get() = if (writeExplicit) writeTimeout else requestTimeout + + /** Returns a fresh [TimeoutBuilder] preloaded with this instance's values. */ + public fun newBuilder(): TimeoutBuilder = TimeoutBuilder(this) + + /** Value equality across all four resolved phases (inheritance applied). */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Timeout) return false + return connectTimeout == other.connectTimeout && + requestTimeout == other.requestTimeout && + effectiveReadTimeout == other.effectiveReadTimeout && + effectiveWriteTimeout == other.effectiveWriteTimeout + } + + override fun hashCode(): Int { + var result = connectTimeout.hashCode() + result = HASH_PRIME * result + requestTimeout.hashCode() + result = HASH_PRIME * result + effectiveReadTimeout.hashCode() + result = HASH_PRIME * result + effectiveWriteTimeout.hashCode() + return result + } + + override fun toString(): String = + "Timeout(connect=$connectTimeout, read=$effectiveReadTimeout, " + + "write=$effectiveWriteTimeout, request=$requestTimeout)" + + /** + * Mutable builder for [Timeout]. Implements the generic [Builder] contract so it can be + * folded by builder-style configuration code. + * + * Each phase defaults to [Duration.ZERO] (no timeout). Setting [requestTimeout] alone makes + * the read and write phases inherit it; setting [readTimeout] / [writeTimeout] explicitly + * pins those phases regardless of [requestTimeout]. + */ + public class TimeoutBuilder : Builder { + private var connectTimeout: Duration = Duration.ZERO + private var readTimeout: Duration = Duration.ZERO + private var writeTimeout: Duration = Duration.ZERO + private var requestTimeout: Duration = Duration.ZERO + private var readExplicit: Boolean = false + private var writeExplicit: Boolean = false + + /** Creates an empty builder with every phase set to [Duration.ZERO] (no timeout). */ + public constructor() + + /** Creates a builder preloaded with the values from [timeout]. */ + public constructor(timeout: Timeout) { + this.connectTimeout = timeout.connectTimeout + this.readTimeout = timeout.readTimeout + this.writeTimeout = timeout.writeTimeout + this.requestTimeout = timeout.requestTimeout + this.readExplicit = timeout.readExplicit + this.writeExplicit = timeout.writeExplicit + } + + /** Sets [Timeout.connectTimeout]. Must be non-negative. [Duration.ZERO] disables it. */ + public fun connectTimeout(connectTimeout: Duration): TimeoutBuilder = + apply { + this.connectTimeout = validated(connectTimeout, "connectTimeout") + } + + /** + * Sets [Timeout.readTimeout] explicitly, pinning the read phase so it no longer inherits + * [requestTimeout]. Must be non-negative. [Duration.ZERO] disables it. + */ + public fun readTimeout(readTimeout: Duration): TimeoutBuilder = + apply { + this.readTimeout = validated(readTimeout, "readTimeout") + this.readExplicit = true + } + + /** + * Sets [Timeout.writeTimeout] explicitly, pinning the write phase so it no longer + * inherits [requestTimeout]. Must be non-negative. [Duration.ZERO] disables it. + */ + public fun writeTimeout(writeTimeout: Duration): TimeoutBuilder = + apply { + this.writeTimeout = validated(writeTimeout, "writeTimeout") + this.writeExplicit = true + } + + /** + * Sets [Timeout.requestTimeout], the end-to-end ceiling that the read and write phases + * inherit unless pinned explicitly. Must be non-negative. [Duration.ZERO] disables it. + */ + public fun requestTimeout(requestTimeout: Duration): TimeoutBuilder = + apply { + this.requestTimeout = validated(requestTimeout, "requestTimeout") + } + + /** Builds the immutable [Timeout] instance. */ + override fun build(): Timeout = + Timeout( + connectTimeout = connectTimeout, + readTimeout = readTimeout, + writeTimeout = writeTimeout, + requestTimeout = requestTimeout, + readExplicit = readExplicit, + writeExplicit = writeExplicit, + ) + + private fun validated( + value: Duration, + name: String, + ): Duration { + require(!value.isNegative) { "$name must be non-negative (got $value)" } + require(value <= MAX_NANO_REPRESENTABLE) { + "$name must be representable in nanoseconds (≤ ~292 years); got $value" + } + return value + } + } + + public companion object { + private const val HASH_PRIME = 31 + + /** + * The "no timeout" instance: every phase is [Duration.ZERO], so a transport waits + * indefinitely on every phase. This is the [Timeout] equivalent of leaving timeouts + * unconfigured, and the value [defaults] returns. + */ + @JvmField + public val NONE: Timeout = TimeoutBuilder().build() + + /** Java-friendly entry point: returns a fresh builder with every phase unset. */ + @JvmStatic + public fun builder(): TimeoutBuilder = TimeoutBuilder() + + /** Returns the [NONE] timeout — no limit on any phase. */ + @JvmStatic + public fun defaults(): Timeout = NONE + + /** + * Convenience factory: a [Timeout] whose [requestTimeout] is [duration] and whose read + * and write phases inherit it. [connectTimeout] is left unset ([Duration.ZERO]). + * + * @throws IllegalArgumentException if [duration] is negative or unrepresentable. + */ + @JvmStatic + public fun ofRequest(duration: Duration): Timeout = TimeoutBuilder().requestTimeout(duration).build() + } + } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTimeoutTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTimeoutTest.kt new file mode 100644 index 00000000..a2130f20 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTimeoutTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.config + +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConfigurationTimeoutTest { + @Test + fun `empty configuration yields NONE`() { + val cfg = ConfigurationBuilder().build() + assertEquals(Timeout.NONE, cfg.getTimeout()) + } + + @Test + fun `request timeout key drives read and write inheritance`() { + val cfg = ConfigurationBuilder().put(Configuration.REQUEST_TIMEOUT, "10s").build() + val t = cfg.getTimeout() + assertEquals(Duration.ofSeconds(10), t.requestTimeout) + assertEquals(Duration.ofSeconds(10), t.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(10), t.effectiveWriteTimeout) + assertEquals(Duration.ZERO, t.connectTimeout) + } + + @Test + fun `each phase key maps to its phase`() { + val cfg = + ConfigurationBuilder() + .put(Configuration.CONNECT_TIMEOUT, "PT1S") + .put(Configuration.READ_TIMEOUT, "2s") + .put(Configuration.WRITE_TIMEOUT, "3000ms") + .put(Configuration.REQUEST_TIMEOUT, "30s") + .build() + val t = cfg.getTimeout() + assertEquals(Duration.ofSeconds(1), t.connectTimeout) + assertEquals(Duration.ofSeconds(2), t.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(3), t.effectiveWriteTimeout) + assertEquals(Duration.ofSeconds(30), t.requestTimeout) + } + + @Test + fun `explicit read key pins read independently of request`() { + val cfg = + ConfigurationBuilder() + .put(Configuration.REQUEST_TIMEOUT, "10s") + .put(Configuration.READ_TIMEOUT, "4s") + .build() + val t = cfg.getTimeout() + assertEquals(Duration.ofSeconds(4), t.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(10), t.effectiveWriteTimeout) + } + + @Test + fun `unparseable read key is ignored and inherits request`() { + val cfg = + ConfigurationBuilder() + .put(Configuration.REQUEST_TIMEOUT, "10s") + .put(Configuration.READ_TIMEOUT, "not-a-duration") + .build() + val t = cfg.getTimeout() + // Bad value behaves as if absent: read inherits request. + assertEquals(Duration.ofSeconds(10), t.effectiveReadTimeout) + } + + @Test + fun `default fills missing connect and request phases`() { + val cfg = ConfigurationBuilder().put(Configuration.READ_TIMEOUT, "2s").build() + val default = + Timeout + .builder() + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(20)) + .build() + val t = cfg.getTimeout(default) + assertEquals(Duration.ofSeconds(5), t.connectTimeout) + assertEquals(Duration.ofSeconds(20), t.requestTimeout) + // The configured read key overrides the default's inherited read phase. + assertEquals(Duration.ofSeconds(2), t.effectiveReadTimeout) + } + + @Test + fun `env and sysprop layers feed the timeout keys`() { + val cfg = + ConfigurationBuilder() + .envSource { name -> if (name == Configuration.CONNECT_TIMEOUT) "1s" else null } + .propsSource { name -> if (name == "request.timeout") "15s" else null } + .build() + val t = cfg.getTimeout() + assertEquals(Duration.ofSeconds(1), t.connectTimeout) + assertEquals(Duration.ofSeconds(15), t.requestTimeout) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/TimeoutTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/TimeoutTest.kt new file mode 100644 index 00000000..890b17b9 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/TimeoutTest.kt @@ -0,0 +1,235 @@ +/* + * 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.config + +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TimeoutTest { + // ----- Defaults / NONE ----- + + @Test + fun `defaults returns NONE with every phase zero`() { + val t = Timeout.defaults() + assertSame(Timeout.NONE, t) + assertEquals(Duration.ZERO, t.connectTimeout) + assertEquals(Duration.ZERO, t.readTimeout) + assertEquals(Duration.ZERO, t.writeTimeout) + assertEquals(Duration.ZERO, t.requestTimeout) + assertEquals(Duration.ZERO, t.effectiveReadTimeout) + assertEquals(Duration.ZERO, t.effectiveWriteTimeout) + } + + @Test + fun `empty builder equals NONE`() { + assertEquals(Timeout.NONE, Timeout.builder().build()) + } + + // ----- Inheritance of read/write from request ----- + + @Test + fun `read and write inherit request when unset`() { + val t = Timeout.builder().requestTimeout(Duration.ofSeconds(10)).build() + assertEquals(Duration.ofSeconds(10), t.requestTimeout) + // The raw stored values stay ZERO... + assertEquals(Duration.ZERO, t.readTimeout) + assertEquals(Duration.ZERO, t.writeTimeout) + // ...but the effective values fall back to request. + assertEquals(Duration.ofSeconds(10), t.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(10), t.effectiveWriteTimeout) + } + + @Test + fun `ofRequest sets request and inherits read write`() { + val t = Timeout.ofRequest(Duration.ofSeconds(7)) + assertEquals(Duration.ofSeconds(7), t.requestTimeout) + assertEquals(Duration.ofSeconds(7), t.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(7), t.effectiveWriteTimeout) + assertEquals(Duration.ZERO, t.connectTimeout) + } + + @Test + fun `explicit read pins the read phase and does not inherit request`() { + val t = + Timeout + .builder() + .requestTimeout(Duration.ofSeconds(10)) + .readTimeout(Duration.ofSeconds(3)) + .build() + assertEquals(Duration.ofSeconds(3), t.effectiveReadTimeout) + // Write still inherits request. + assertEquals(Duration.ofSeconds(10), t.effectiveWriteTimeout) + } + + @Test + fun `explicit write pins the write phase independently`() { + val t = + Timeout + .builder() + .requestTimeout(Duration.ofSeconds(10)) + .writeTimeout(Duration.ofSeconds(4)) + .build() + assertEquals(Duration.ofSeconds(4), t.effectiveWriteTimeout) + assertEquals(Duration.ofSeconds(10), t.effectiveReadTimeout) + } + + @Test + fun `explicit read of zero pins read to no-limit even when request is set`() { + val t = + Timeout + .builder() + .requestTimeout(Duration.ofSeconds(10)) + .readTimeout(Duration.ZERO) + .build() + // Pinned to ZERO (no limit) — NOT inheriting the 10s request budget. + assertEquals(Duration.ZERO, t.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(10), t.effectiveWriteTimeout) + } + + @Test + fun `connect does not inherit request`() { + val t = Timeout.builder().requestTimeout(Duration.ofSeconds(10)).build() + assertEquals(Duration.ZERO, t.connectTimeout) + } + + // ----- All phases set ----- + + @Test + fun `all phases set independently`() { + val t = + Timeout + .builder() + .connectTimeout(Duration.ofSeconds(1)) + .readTimeout(Duration.ofSeconds(2)) + .writeTimeout(Duration.ofSeconds(3)) + .requestTimeout(Duration.ofSeconds(30)) + .build() + assertEquals(Duration.ofSeconds(1), t.connectTimeout) + assertEquals(Duration.ofSeconds(2), t.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(3), t.effectiveWriteTimeout) + assertEquals(Duration.ofSeconds(30), t.requestTimeout) + } + + // ----- Validation ----- + + @Test + fun `negative connect timeout is rejected`() { + assertFailsWith { + Timeout.builder().connectTimeout(Duration.ofSeconds(-1)) + } + } + + @Test + fun `negative read timeout is rejected`() { + assertFailsWith { + Timeout.builder().readTimeout(Duration.ofMillis(-1)) + } + } + + @Test + fun `negative write timeout is rejected`() { + assertFailsWith { + Timeout.builder().writeTimeout(Duration.ofNanos(-1)) + } + } + + @Test + fun `negative request timeout is rejected`() { + assertFailsWith { + Timeout.builder().requestTimeout(Duration.ofSeconds(-5)) + } + } + + @Test + fun `unrepresentable duration is rejected`() { + assertFailsWith { + Timeout.builder().requestTimeout(Duration.ofDays(365L * 1000)) + } + } + + @Test + fun `max nano-representable duration is accepted`() { + val maxNanos = Duration.ofNanos(Long.MAX_VALUE) + val t = Timeout.builder().requestTimeout(maxNanos).build() + assertEquals(maxNanos, t.requestTimeout) + } + + // ----- newBuilder round-trip ----- + + @Test + fun `newBuilder preserves all values including explicit flags`() { + val original = + Timeout + .builder() + .connectTimeout(Duration.ofSeconds(1)) + .readTimeout(Duration.ZERO) + .requestTimeout(Duration.ofSeconds(10)) + .build() + val copy = original.newBuilder().build() + assertEquals(original, copy) + // read was explicitly pinned to ZERO; the copy must keep that, not re-inherit request. + assertEquals(Duration.ZERO, copy.effectiveReadTimeout) + assertEquals(Duration.ofSeconds(10), copy.effectiveWriteTimeout) + } + + @Test + fun `newBuilder allows overriding a single phase`() { + val original = Timeout.ofRequest(Duration.ofSeconds(10)) + val modified = original.newBuilder().connectTimeout(Duration.ofSeconds(2)).build() + assertEquals(Duration.ofSeconds(2), modified.connectTimeout) + assertEquals(Duration.ofSeconds(10), modified.requestTimeout) + assertNotSame(original, modified) + } + + // ----- equals / hashCode / toString ----- + + @Test + fun `equality compares effective phases so inherited and explicit equal forms match`() { + val inherited = Timeout.builder().requestTimeout(Duration.ofSeconds(5)).build() + val explicit = + Timeout + .builder() + .requestTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .writeTimeout(Duration.ofSeconds(5)) + .build() + assertEquals(inherited, explicit) + assertEquals(inherited.hashCode(), explicit.hashCode()) + } + + @Test + fun `differing connect makes instances unequal`() { + val a = Timeout.builder().connectTimeout(Duration.ofSeconds(1)).build() + val b = Timeout.builder().connectTimeout(Duration.ofSeconds(2)).build() + assertNotEquals(a, b) + } + + @Test + fun `equals handles identity and other types`() { + val t = Timeout.ofRequest(Duration.ofSeconds(1)) + @Suppress("ReplaceCallWithBinaryOperator") + assertTrue(t.equals(t)) + assertNotEquals(t, "not a timeout") + assertNotEquals(t, null) + } + + @Test + fun `toString includes effective read and write`() { + val t = Timeout.builder().requestTimeout(Duration.ofSeconds(9)).build() + val s = t.toString() + assertTrue(s.contains("request=PT9S"), s) + assertTrue(s.contains("read=PT9S"), s) + assertTrue(s.contains("write=PT9S"), s) + } +}