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) + } +}