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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions sdk-core/api/sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
}

Expand All @@ -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 <init> (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 <init> ()V
public fun <init> (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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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())

Expand Down
231 changes: 231 additions & 0 deletions sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Timeout.kt
Original file line number Diff line number Diff line change
@@ -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<Timeout> {
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()
}
}
Loading
Loading