Skip to content
Merged
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
19 changes: 18 additions & 1 deletion sdk-core/api/sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,21 @@ public abstract class org/dexpace/sdk/core/http/pipeline/steps/RetryStep : org/d
public final fun getStage ()Lorg/dexpace/sdk/core/http/pipeline/Stage;
}

public final class org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate : org/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate {
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate$Companion;
public static final field DEFAULT_HEADER_NAME Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
public fun <init> ()V
public fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)V
public fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;)V
public synthetic fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getDelegate ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;
public final fun getHeaderName ()Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
public fun shouldRetry (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryCondition;)Z
}

public final class org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate$Companion {
}

public final class org/dexpace/sdk/core/http/pipeline/steps/SetDateStep : org/dexpace/sdk/core/http/pipeline/HttpStep {
public fun <init> ()V
public fun <init> (Lorg/dexpace/sdk/core/util/Clock;)V
Expand Down Expand Up @@ -2112,9 +2127,10 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings {
public static final field DEFAULT_RETRYABLE_METHODS Ljava/util/Set;
public static final field DEFAULT_RETRYABLE_STATUSES Ljava/util/Set;
public static final field DEFAULT_TOTAL_TIMEOUT Ljava/time/Duration;
public synthetic fun <init> (Ljava/time/Duration;Ljava/time/Duration;DLjava/time/Duration;IDLjava/util/Set;Ljava/util/Set;Ljava/util/concurrent/ScheduledExecutorService;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/time/Duration;Ljava/time/Duration;DLjava/time/Duration;IDLjava/util/Set;Ljava/util/Set;Ljava/util/concurrent/ScheduledExecutorService;Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public static final fun builder ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
public static final fun defaults ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
public final fun getAttemptHeaderName ()Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
public final fun getDelayMultiplier ()D
public final fun getInitialDelay ()Ljava/time/Duration;
public final fun getJitter ()D
Expand All @@ -2135,6 +2151,7 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings$Compan
public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder : org/dexpace/sdk/core/generics/Builder {
public fun <init> ()V
public fun <init> (Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;)V
public final fun attemptHeaderName (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
public synthetic fun build ()Ljava/lang/Object;
public fun build ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
public final fun delayMultiplier (D)Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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.HttpHeaderName
import java.util.Locale

/**
* Composable [HttpRetryConditionPredicate] that lets the server steer the retry decision via an
* explicit response header (`X-Should-Retry` by default).
*
* Some APIs annotate responses with a definitive "retry / do not retry" signal that the client
* cannot infer from the status code alone — for example a `409 Conflict` that is genuinely
* transient, or a `503` the server knows will not recover. This predicate honours that signal
* when, and only when, the header is present:
*
* - A **truthy** value (`true`, `1`, `yes`, `retry`, case-insensitive) forces a retry, even for
* a status the default classifier would not retry.
* - A **falsy** value (`false`, `0`, `no`, `stop`, case-insensitive) suppresses a retry, even
* for a status the default classifier would retry.
* - When the header is **absent**, unrecognised, or there is no response (the exception path),
* the decision is delegated to [delegate] — so wiring this predicate in changes behaviour
* only when the server actually speaks.
*
* ## What the override does and does not bypass
*
* The override flips only the *classification* decision — "is this response retryable?". It does
* not bypass the other gates the retry step enforces: a forced retry is still capped by
* [HttpRetryOptions.maxRetries], and still requires a retry-safe request (an idempotent method or
* a replayable body). A truthy header on a `POST` carrying a non-replayable body therefore does
* **not** trigger a retry — the body cannot be re-sent, so the response is returned as-is.
*
* ## Opt-in
*
* This predicate is **not** installed by default. A caller enables it by passing an instance as
* [HttpRetryOptions.shouldRetryCondition] (and/or [HttpRetryOptions.shouldRetryException]). The
* SDK's default retryable-status set is unchanged — in particular `409 Conflict` stays out of
* it; this predicate is the mechanism by which a server can opt a `409` (or any other status)
* into a retry, rather than widening the default set for everyone.
*
* ## Composition
*
* [delegate] defaults to the SDK's standard classifier, dispatched per path: the response
* classifier (`408 / 429 / 5xx`, except `501` / `505`) on the response path, and the exception
* classifier (`IOException` / `TimeoutException` anywhere in the cause chain) on the exception
* path. Because the override header only appears on responses, wiring this predicate into
* [HttpRetryOptions.shouldRetryException] leaves the exception-path decision entirely to the
* delegate. Supply a different delegate to layer the server override on top of bespoke retry
* logic, or [HttpRetryConditionPredicate] `{ false }` to make the server header the sole
* authority.
*
* ## Thread-safety
*
* Immutable and stateless — safe to share across concurrent requests.
*
* @property headerName The response header consulted for the override signal. Defaults to
* `X-Should-Retry`.
* @property delegate Fallback predicate consulted when the header is absent or unrecognised, or
* on the exception path. Defaults to the standard per-path classifier.
*/
public class ServerOverrideRetryPredicate
@JvmOverloads
constructor(
public val headerName: HttpHeaderName = DEFAULT_HEADER_NAME,
public val delegate: HttpRetryConditionPredicate =
HttpRetryConditionPredicate(::defaultClassifier),
) : HttpRetryConditionPredicate {
override fun shouldRetry(condition: HttpRetryCondition): Boolean {
val response = condition.response ?: return delegate.shouldRetry(condition)
val raw = response.headers.get(headerName) ?: return delegate.shouldRetry(condition)
return when (raw.trim().lowercase(Locale.US)) {
in TRUTHY -> true
in FALSY -> false
// An unrecognised value is not a directive — defer rather than guess.
else -> delegate.shouldRetry(condition)
}
}

public companion object {
/** Default override header (`X-Should-Retry`). */
@JvmField
public val DEFAULT_HEADER_NAME: HttpHeaderName = HttpHeaderName.fromString("X-Should-Retry")

/** Header values that force a retry. */
private val TRUTHY: Set<String> = setOf("true", "1", "yes", "retry")

/** Header values that suppress a retry. */
private val FALSY: Set<String> = setOf("false", "0", "no", "stop")
}
}

/**
* Default [ServerOverrideRetryPredicate.delegate]: applies the SDK's standard classification for
* whichever path the [condition] represents — the response classifier when a response is present,
* the exception classifier otherwise. This keeps the override predicate's fall-through behaviour
* identical to the stock [HttpRetryOptions] defaults on both the response and exception paths.
*/
private fun defaultClassifier(condition: HttpRetryCondition): Boolean =
if (condition.response != null) {
defaultShouldRetryResponse(condition)
} else {
defaultShouldRetryException(condition)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import java.util.concurrent.ThreadLocalRandom
*
* ## Recognized header forms
*
* 1. `Retry-After: <seconds>` — RFC 7231 §7.1.3 numeric (delta-seconds) form.
* 1. `Retry-After: <seconds>` — RFC 7231 §7.1.3 numeric (delta-seconds) form. Both integer
* and fractional values (e.g. `1.5`) are accepted; the fractional part is honoured to
* nanosecond resolution.
* 2. `Retry-After: <HTTP-date>` — RFC 7231 §7.1.3 absolute-date form (parsed via
* [DateTimeRfc1123], which tolerates an informational weekday per RFC 7231 §7.1.1.1).
* 3. `retry-after-ms: <millis>` — millisecond delta variant.
Expand Down Expand Up @@ -106,6 +108,25 @@ public object RetryAfterParser {
*/
private val MAX_DELAY: Duration = Duration.ofDays(365)

/** [MAX_DELAY] expressed in whole seconds — the clamp threshold for fractional parsing. */
private val MAX_DELAY_SECONDS: Double = MAX_DELAY.seconds.toDouble()

/** Nanoseconds per second — the scale factor for the fractional `Retry-After` conversion. */
private const val NANOS_PER_SECOND: Double = 1_000_000_000.0

/**
* Strict grammar for the numeric `Retry-After` delta: one or more decimal digits, optionally
* followed by a single decimal point and one or more decimal digits.
*
* Screening with this regex before [String.toDoubleOrNull] is deliberate: `toDoubleOrNull`
* accepts the Java floating-point literal grammar, which includes the type suffixes (`d`,
* `f`) and hexadecimal-float forms (`0x1p4`). Without the screen a header such as
* `Retry-After: 30d` or `Retry-After: 0x1p4` would parse to a finite delta instead of falling
* through to the backoff schedule. Only the RFC 7231 §7.1.3 delta-seconds form and the
* fractional extension real servers emit are honoured here.
*/
private val NUMERIC_SECONDS = Regex("""^\d+(\.\d+)?$""")

/**
* Parses the next-attempt delay from [headers] relative to [now]. Returns `null` when no
* recognized header is present or parseable.
Expand Down Expand Up @@ -185,14 +206,29 @@ public object RetryAfterParser {
}

/**
* Parses [value] as a non-negative integer count of seconds. Returns `null` on any parse
* failure, including negative values — the retry layer falls back to its backoff
* schedule rather than retrying immediately against a misbehaving server.
* Parses [value] as a non-negative count of seconds. Accepts both the RFC 7231 §7.1.3
* integer (delta-seconds) form and the fractional form (`1.5`) that real servers and
* proxies emit; the fractional part is honoured down to nanosecond resolution.
*
* The value is first screened against [NUMERIC_SECONDS] so only the plain decimal grammar
* is honoured: [String.toDoubleOrNull] otherwise accepts Java float literals such as `30d`
* and `0x1p4`, which must instead fall through to the HTTP-date branch (or, ultimately, the
* backoff schedule). Returns `null` on any parse failure, on a negative value, or on a
* non-finite value (`NaN`, `Infinity`). A finite but absurdly large value is clamped to
* [MAX_DELAY] before the nanosecond conversion so the resulting [Duration] can never
* overflow [Duration.toNanos] downstream.
*/
private fun parseNumericSeconds(value: String): Duration? {
val seconds = value.toLongOrNull() ?: return null
if (seconds < 0L) return null
return Duration.ofSeconds(seconds)
// The strict screen guarantees a non-negative decimal, so toDoubleOrNull never returns
// null/NaN here; an extremely long digit run can still overflow to +Infinity, which the
// ceiling check below absorbs by clamping rather than letting it reach the nanos multiply.
if (!NUMERIC_SECONDS.matches(value)) return null
val seconds = value.toDoubleOrNull() ?: return null
// Clamp in the seconds domain before converting to nanos: a value beyond the ceiling
// (or an overflow to +Infinity) would otherwise overflow the `* NANOS_PER_SECOND`
// multiply and the Long cast below.
if (seconds >= MAX_DELAY_SECONDS) return MAX_DELAY
return Duration.ofNanos((seconds * NANOS_PER_SECOND).toLong())
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package org.dexpace.sdk.core.pipeline.step.retry

import org.dexpace.sdk.core.generics.Builder
import org.dexpace.sdk.core.http.common.HttpHeaderName
import org.dexpace.sdk.core.http.request.Method
import java.time.Duration
import java.util.Collections
Expand Down Expand Up @@ -37,6 +38,7 @@ private val MAX_NANO_REPRESENTABLE_DELAY: Duration = Duration.ofNanos(Long.MAX_V
* - [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].
* - [attemptHeaderName] = `null` — no per-attempt header is stamped (opt-in; see the property).
*
* ## Thread-safety
*
Expand All @@ -61,6 +63,12 @@ private val MAX_NANO_REPRESENTABLE_DELAY: Duration = Duration.ofNanos(Long.MAX_V
* RFC). Non-idempotent methods (`POST`, `PATCH`) only retry when the body is replayable.
* @property scheduler Optional caller-provided scheduler. When `null` [RetryStep] uses a
* process-wide lazy daemon scheduler.
* @property attemptHeaderName Optional request header stamped on each attempt [RetryStep]
* 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
* request, never on the immutable template. Any idempotency key the caller stamps stays
* stable across retries — only this attempt header changes per send.
*/
public class RetrySettings
// The 9-arg constructor lives behind a `private` modifier — public construction goes
Expand All @@ -78,6 +86,7 @@ public class RetrySettings
public val retryableStatuses: Set<Int>,
public val retryableMethods: Set<Method>,
public val scheduler: ScheduledExecutorService?,
public val attemptHeaderName: HttpHeaderName?,
) {
/** Returns a fresh [RetrySettingsBuilder] preloaded with this instance's values. */
public fun newBuilder(): RetrySettingsBuilder = RetrySettingsBuilder(this)
Expand All @@ -96,6 +105,7 @@ public class RetrySettings
private var retryableStatuses: Set<Int> = DEFAULT_RETRYABLE_STATUSES
private var retryableMethods: Set<Method> = DEFAULT_RETRYABLE_METHODS
private var scheduler: ScheduledExecutorService? = null
private var attemptHeaderName: HttpHeaderName? = null

/** Creates an empty builder populated with the SDK defaults. */
public constructor()
Expand All @@ -111,6 +121,7 @@ public class RetrySettings
this.retryableStatuses = settings.retryableStatuses
this.retryableMethods = settings.retryableMethods
this.scheduler = settings.scheduler
this.attemptHeaderName = settings.attemptHeaderName
}

/** Sets [RetrySettings.totalTimeout]. Must be non-negative. */
Expand Down Expand Up @@ -186,6 +197,16 @@ public class RetrySettings
this.scheduler = scheduler
}

/**
* Sets [RetrySettings.attemptHeaderName]. When non-null, [RetryStep] stamps this
* header (carrying the 1-based attempt ordinal) on each attempt's request copy.
* `null` (the default) leaves attempts unstamped.
*/
public fun attemptHeaderName(attemptHeaderName: HttpHeaderName?): RetrySettingsBuilder =
apply {
this.attemptHeaderName = attemptHeaderName
}

/** Builds the immutable [RetrySettings] instance. */
override fun build(): RetrySettings =
RetrySettings(
Expand All @@ -198,6 +219,7 @@ public class RetrySettings
retryableStatuses = Collections.unmodifiableSet(LinkedHashSet(retryableStatuses)),
retryableMethods = Collections.unmodifiableSet(LinkedHashSet(retryableMethods)),
scheduler = scheduler,
attemptHeaderName = attemptHeaderName,
)
}

Expand Down
Loading
Loading