Skip to content

Commit 4c6bcee

Browse files
committed
feat: add retry controls for fractional Retry-After, attempt headers, and server-driven retries
Three additive, opt-in enhancements to the retry layer: - Parse fractional Retry-After values. RetryAfterParser now reads the Retry-After delta-seconds form with toDoubleOrNull, so values like `Retry-After: 1.5` are honoured to nanosecond resolution instead of being silently ignored. Integer seconds and the HTTP-date form are unchanged, and the negative / NaN / non-finite guards plus the 365-day ceiling still apply. The numeric form now shares that ceiling with the date and Unix-epoch forms, so a far-future delta clamps rather than flowing through unbounded. Both retry layers benefit, since they delegate parsing here. - Optional per-attempt retry-count request header. RetrySettings gains an opt-in attemptHeaderName (default null). When set, the recovery-aware RetryStep stamps that header with the 1-based attempt ordinal on a per-attempt copy of the request, so servers and proxies can observe the retry count. The header is applied via Request.newBuilder, leaving the immutable template and any caller-supplied idempotency key untouched. - Server-driven retry predicate. New ServerOverrideRetryPredicate implements the composable HttpRetryConditionPredicate seam and honours an X-Should-Retry-style response header (configurable name): a truthy value forces a retry, a falsy value suppresses one, and an absent or unrecognised value defers to a delegate predicate. It is not installed by default; the default retryable-status set is unchanged, so 409 stays out of it unless a server explicitly opts a response in.
1 parent ea0cc81 commit 4c6bcee

8 files changed

Lines changed: 493 additions & 15 deletions

File tree

sdk-core/api/sdk-core.api

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,21 @@ public abstract class org/dexpace/sdk/core/http/pipeline/steps/RetryStep : org/d
945945
public final fun getStage ()Lorg/dexpace/sdk/core/http/pipeline/Stage;
946946
}
947947

948+
public final class org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate : org/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate {
949+
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate$Companion;
950+
public static final field DEFAULT_HEADER_NAME Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
951+
public fun <init> ()V
952+
public fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)V
953+
public fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;)V
954+
public synthetic fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
955+
public final fun getDelegate ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;
956+
public final fun getHeaderName ()Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
957+
public fun shouldRetry (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryCondition;)Z
958+
}
959+
960+
public final class org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate$Companion {
961+
}
962+
948963
public final class org/dexpace/sdk/core/http/pipeline/steps/SetDateStep : org/dexpace/sdk/core/http/pipeline/HttpStep {
949964
public fun <init> ()V
950965
public fun <init> (Lorg/dexpace/sdk/core/util/Clock;)V
@@ -2112,9 +2127,10 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings {
21122127
public static final field DEFAULT_RETRYABLE_METHODS Ljava/util/Set;
21132128
public static final field DEFAULT_RETRYABLE_STATUSES Ljava/util/Set;
21142129
public static final field DEFAULT_TOTAL_TIMEOUT Ljava/time/Duration;
2115-
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
2130+
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
21162131
public static final fun builder ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
21172132
public static final fun defaults ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
2133+
public final fun getAttemptHeaderName ()Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
21182134
public final fun getDelayMultiplier ()D
21192135
public final fun getInitialDelay ()Ljava/time/Duration;
21202136
public final fun getJitter ()D
@@ -2135,6 +2151,7 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings$Compan
21352151
public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder : org/dexpace/sdk/core/generics/Builder {
21362152
public fun <init> ()V
21372153
public fun <init> (Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;)V
2154+
public final fun attemptHeaderName (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
21382155
public synthetic fun build ()Ljava/lang/Object;
21392156
public fun build ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
21402157
public final fun delayMultiplier (D)Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.http.pipeline.steps
9+
10+
import org.dexpace.sdk.core.http.common.HttpHeaderName
11+
import java.util.Locale
12+
13+
/**
14+
* Composable [HttpRetryConditionPredicate] that lets the server steer the retry decision via an
15+
* explicit response header (`X-Should-Retry` by default).
16+
*
17+
* Some APIs annotate responses with a definitive "retry / do not retry" signal that the client
18+
* cannot infer from the status code alone — for example a `409 Conflict` that is genuinely
19+
* transient, or a `503` the server knows will not recover. This predicate honours that signal
20+
* when, and only when, the header is present:
21+
*
22+
* - A **truthy** value (`true`, `1`, `yes`, `retry`, case-insensitive) forces a retry, even for
23+
* a status the default classifier would not retry.
24+
* - A **falsy** value (`false`, `0`, `no`, `stop`, case-insensitive) suppresses a retry, even
25+
* for a status the default classifier would retry.
26+
* - When the header is **absent**, unrecognised, or there is no response (the exception path),
27+
* the decision is delegated to [delegate] — so wiring this predicate in changes behaviour
28+
* only when the server actually speaks.
29+
*
30+
* ## Opt-in
31+
*
32+
* This predicate is **not** installed by default. A caller enables it by passing an instance as
33+
* [HttpRetryOptions.shouldRetryCondition] (and/or [HttpRetryOptions.shouldRetryException]). The
34+
* SDK's default retryable-status set is unchanged — in particular `409 Conflict` stays out of
35+
* it; this predicate is the mechanism by which a server can opt a `409` (or any other status)
36+
* into a retry, rather than widening the default set for everyone.
37+
*
38+
* ## Composition
39+
*
40+
* [delegate] defaults to the SDK's standard classifier, dispatched per path: the response
41+
* classifier (`408 / 429 / 5xx`, except `501` / `505`) on the response path, and the exception
42+
* classifier (`IOException` / `TimeoutException` anywhere in the cause chain) on the exception
43+
* path. Because the override header only appears on responses, wiring this predicate into
44+
* [HttpRetryOptions.shouldRetryException] leaves the exception-path decision entirely to the
45+
* delegate. Supply a different delegate to layer the server override on top of bespoke retry
46+
* logic, or [HttpRetryConditionPredicate] `{ false }` to make the server header the sole
47+
* authority.
48+
*
49+
* ## Thread-safety
50+
*
51+
* Immutable and stateless — safe to share across concurrent requests.
52+
*
53+
* @property headerName The response header consulted for the override signal. Defaults to
54+
* `X-Should-Retry`.
55+
* @property delegate Fallback predicate consulted when the header is absent or unrecognised, or
56+
* on the exception path. Defaults to the standard per-path classifier.
57+
*/
58+
public class ServerOverrideRetryPredicate
59+
@JvmOverloads
60+
constructor(
61+
public val headerName: HttpHeaderName = DEFAULT_HEADER_NAME,
62+
public val delegate: HttpRetryConditionPredicate =
63+
HttpRetryConditionPredicate(::defaultClassifier),
64+
) : HttpRetryConditionPredicate {
65+
override fun shouldRetry(condition: HttpRetryCondition): Boolean {
66+
val response = condition.response ?: return delegate.shouldRetry(condition)
67+
val raw = response.headers.get(headerName) ?: return delegate.shouldRetry(condition)
68+
return when (raw.trim().lowercase(Locale.US)) {
69+
in TRUTHY -> true
70+
in FALSY -> false
71+
// An unrecognised value is not a directive — defer rather than guess.
72+
else -> delegate.shouldRetry(condition)
73+
}
74+
}
75+
76+
public companion object {
77+
/** Default override header (`X-Should-Retry`). */
78+
@JvmField
79+
public val DEFAULT_HEADER_NAME: HttpHeaderName = HttpHeaderName.fromString("X-Should-Retry")
80+
81+
/** Header values that force a retry. */
82+
private val TRUTHY: Set<String> = setOf("true", "1", "yes", "retry")
83+
84+
/** Header values that suppress a retry. */
85+
private val FALSY: Set<String> = setOf("false", "0", "no", "stop")
86+
}
87+
}
88+
89+
/**
90+
* Default [ServerOverrideRetryPredicate.delegate]: applies the SDK's standard classification for
91+
* whichever path the [condition] represents — the response classifier when a response is present,
92+
* the exception classifier otherwise. This keeps the override predicate's fall-through behaviour
93+
* identical to the stock [HttpRetryOptions] defaults on both the response and exception paths.
94+
*/
95+
private fun defaultClassifier(condition: HttpRetryCondition): Boolean =
96+
if (condition.response != null) {
97+
defaultShouldRetryResponse(condition)
98+
} else {
99+
defaultShouldRetryException(condition)
100+
}

sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryAfterParser.kt

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ import java.util.concurrent.ThreadLocalRandom
2828
*
2929
* ## Recognized header forms
3030
*
31-
* 1. `Retry-After: <seconds>` — RFC 7231 §7.1.3 numeric (delta-seconds) form.
31+
* 1. `Retry-After: <seconds>` — RFC 7231 §7.1.3 numeric (delta-seconds) form. Both integer
32+
* and fractional values (e.g. `1.5`) are accepted; the fractional part is honoured to
33+
* nanosecond resolution.
3234
* 2. `Retry-After: <HTTP-date>` — RFC 7231 §7.1.3 absolute-date form (parsed via
3335
* [DateTimeRfc1123], which tolerates an informational weekday per RFC 7231 §7.1.1.1).
3436
* 3. `retry-after-ms: <millis>` — millisecond delta variant.
@@ -106,6 +108,12 @@ public object RetryAfterParser {
106108
*/
107109
private val MAX_DELAY: Duration = Duration.ofDays(365)
108110

111+
/** [MAX_DELAY] expressed in whole seconds — the clamp threshold for fractional parsing. */
112+
private val MAX_DELAY_SECONDS: Double = MAX_DELAY.seconds.toDouble()
113+
114+
/** Nanoseconds per second — the scale factor for the fractional `Retry-After` conversion. */
115+
private const val NANOS_PER_SECOND: Double = 1_000_000_000.0
116+
109117
/**
110118
* Parses the next-attempt delay from [headers] relative to [now]. Returns `null` when no
111119
* recognized header is present or parseable.
@@ -185,14 +193,23 @@ public object RetryAfterParser {
185193
}
186194

187195
/**
188-
* Parses [value] as a non-negative integer count of seconds. Returns `null` on any parse
189-
* failure, including negative values — the retry layer falls back to its backoff
190-
* schedule rather than retrying immediately against a misbehaving server.
196+
* Parses [value] as a non-negative count of seconds. Accepts both the RFC 7231 §7.1.3
197+
* integer (delta-seconds) form and the fractional form (`1.5`) that real servers and
198+
* proxies emit; the fractional part is honoured down to nanosecond resolution.
199+
*
200+
* Returns `null` on any parse failure, on a negative value, or on a non-finite value
201+
* (`NaN`, `Infinity`) — the retry layer then falls back to its backoff schedule rather
202+
* than retrying immediately against a misbehaving server. A finite but absurdly large
203+
* value is clamped to [MAX_DELAY] before the nanosecond conversion so the resulting
204+
* [Duration] can never overflow [Duration.toNanos] downstream.
191205
*/
192206
private fun parseNumericSeconds(value: String): Duration? {
193-
val seconds = value.toLongOrNull() ?: return null
194-
if (seconds < 0L) return null
195-
return Duration.ofSeconds(seconds)
207+
val seconds = value.toDoubleOrNull() ?: return null
208+
if (seconds.isNaN() || seconds.isInfinite() || seconds < 0.0) return null
209+
// Clamp in the seconds domain before converting to nanos: a value beyond the ceiling
210+
// would overflow the `* NANOS_PER_SECOND` multiply and the Long cast below.
211+
if (seconds >= MAX_DELAY_SECONDS) return MAX_DELAY
212+
return Duration.ofNanos((seconds * NANOS_PER_SECOND).toLong())
196213
}
197214

198215
/**

sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetrySettings.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package org.dexpace.sdk.core.pipeline.step.retry
99

1010
import org.dexpace.sdk.core.generics.Builder
11+
import org.dexpace.sdk.core.http.common.HttpHeaderName
1112
import org.dexpace.sdk.core.http.request.Method
1213
import java.time.Duration
1314
import java.util.Collections
@@ -37,6 +38,7 @@ private val MAX_NANO_REPRESENTABLE_DELAY: Duration = Duration.ofNanos(Long.MAX_V
3738
* - [retryableMethods] = `{GET, HEAD, OPTIONS, PUT, DELETE}` — safe-by-method per RFC 9110.
3839
* `POST`/`PATCH`/etc. retry only when the request body is replayable.
3940
* - [scheduler] = `null` — fall back to the lazy daemon scheduler created by [RetryStep].
41+
* - [attemptHeaderName] = `null` — no per-attempt header is stamped (opt-in; see the property).
4042
*
4143
* ## Thread-safety
4244
*
@@ -61,6 +63,12 @@ private val MAX_NANO_REPRESENTABLE_DELAY: Duration = Duration.ofNanos(Long.MAX_V
6163
* RFC). Non-idempotent methods (`POST`, `PATCH`) only retry when the body is replayable.
6264
* @property scheduler Optional caller-provided scheduler. When `null` [RetryStep] uses a
6365
* process-wide lazy daemon scheduler.
66+
* @property attemptHeaderName Optional request header stamped on each attempt [RetryStep]
67+
* dispatches, carrying the 1-based attempt ordinal (`1` for the original send, `2` for the
68+
* first retry, and so on) so servers and proxies can observe the retry count. `null` (the
69+
* default) disables the header entirely. The header is set on a per-attempt copy of the
70+
* request, never on the immutable template. Any idempotency key the caller stamps stays
71+
* stable across retries — only this attempt header changes per send.
6472
*/
6573
public class RetrySettings
6674
// The 9-arg constructor lives behind a `private` modifier — public construction goes
@@ -78,6 +86,7 @@ public class RetrySettings
7886
public val retryableStatuses: Set<Int>,
7987
public val retryableMethods: Set<Method>,
8088
public val scheduler: ScheduledExecutorService?,
89+
public val attemptHeaderName: HttpHeaderName?,
8190
) {
8291
/** Returns a fresh [RetrySettingsBuilder] preloaded with this instance's values. */
8392
public fun newBuilder(): RetrySettingsBuilder = RetrySettingsBuilder(this)
@@ -96,6 +105,7 @@ public class RetrySettings
96105
private var retryableStatuses: Set<Int> = DEFAULT_RETRYABLE_STATUSES
97106
private var retryableMethods: Set<Method> = DEFAULT_RETRYABLE_METHODS
98107
private var scheduler: ScheduledExecutorService? = null
108+
private var attemptHeaderName: HttpHeaderName? = null
99109

100110
/** Creates an empty builder populated with the SDK defaults. */
101111
public constructor()
@@ -111,6 +121,7 @@ public class RetrySettings
111121
this.retryableStatuses = settings.retryableStatuses
112122
this.retryableMethods = settings.retryableMethods
113123
this.scheduler = settings.scheduler
124+
this.attemptHeaderName = settings.attemptHeaderName
114125
}
115126

116127
/** Sets [RetrySettings.totalTimeout]. Must be non-negative. */
@@ -186,6 +197,16 @@ public class RetrySettings
186197
this.scheduler = scheduler
187198
}
188199

200+
/**
201+
* Sets [RetrySettings.attemptHeaderName]. When non-null, [RetryStep] stamps this
202+
* header (carrying the 1-based attempt ordinal) on each attempt's request copy.
203+
* `null` (the default) leaves attempts unstamped.
204+
*/
205+
public fun attemptHeaderName(attemptHeaderName: HttpHeaderName?): RetrySettingsBuilder =
206+
apply {
207+
this.attemptHeaderName = attemptHeaderName
208+
}
209+
189210
/** Builds the immutable [RetrySettings] instance. */
190211
override fun build(): RetrySettings =
191212
RetrySettings(
@@ -198,6 +219,7 @@ public class RetrySettings
198219
retryableStatuses = Collections.unmodifiableSet(LinkedHashSet(retryableStatuses)),
199220
retryableMethods = Collections.unmodifiableSet(LinkedHashSet(retryableMethods)),
200221
scheduler = scheduler,
222+
attemptHeaderName = attemptHeaderName,
201223
)
202224
}
203225

sdk-core/src/main/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryStep.kt

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ import java.util.concurrent.TimeUnit
5656
* is a deliberate departure from Square's `RetryInterceptor`, which retries on status alone and
5757
* silently double-sends a body it cannot replay on transport timeouts.
5858
*
59+
* ## Per-attempt header
60+
*
61+
* When [RetrySettings.attemptHeaderName] is configured (it is `null` by default), every send is
62+
* stamped with that header carrying the 1-based attempt ordinal (`1` for the original send, `2`
63+
* for the first retry, …) on a fresh per-attempt copy of the request, so servers and proxies can
64+
* observe the retry count. The header is set on a copy via [Request.newBuilder]; the captured
65+
* template is never mutated, and any idempotency key already on the request is left untouched.
66+
*
5967
* ## Cancellation
6068
*
6169
* Waits between attempts use [CompletableFuture.get] backed by [scheduler] — never
@@ -142,9 +150,11 @@ public class RetryStep
142150
*/
143151
@Throws(Throwable::class)
144152
public fun attempt(): Response {
153+
// The original send is attempt ordinal 1 — stamp it when the feature is enabled so
154+
// the very first request carries the same observable counter the retries will.
145155
val initial: ResponseOutcome =
146156
try {
147-
ResponseOutcome.Success(httpClient.execute(request))
157+
ResponseOutcome.Success(httpClient.execute(stampAttempt(request, attemptOrdinal = 1)))
148158
} catch (t: Throwable) {
149159
ResponseOutcome.Failure(t)
150160
}
@@ -172,7 +182,9 @@ public class RetryStep
172182
is AttemptStep.Abort -> return ResponseOutcome.Failure(readyState.error)
173183
is AttemptStep.Proceed -> {
174184
state.attempt += 1
175-
val outcome = executeOnce()
185+
// state.attempt is now the 1-based ordinal of the send about to happen:
186+
// the original was 1, so the first retry dispatched here is 2.
187+
val outcome = executeOnce(state.attempt)
176188
if (outcome is ResponseOutcome.Success) return outcome
177189
val nextError = (outcome as ResponseOutcome.Failure).error
178190
if (!isClassifiedRetryable(nextError)) return outcome
@@ -268,17 +280,39 @@ public class RetryStep
268280
* Executes the request once via the captured transport, converting any throwable
269281
* raised by the transport into a [ResponseOutcome.Failure]. Preserves the interrupt
270282
* flag if the transport raises an [InterruptedException] mid-send.
283+
*
284+
* @param attemptOrdinal The 1-based ordinal of this send, stamped onto the per-attempt
285+
* request copy when [RetrySettings.attemptHeaderName] is configured.
271286
*/
272-
private fun executeOnce(): ResponseOutcome =
287+
private fun executeOnce(attemptOrdinal: Int): ResponseOutcome =
273288
try {
274-
ResponseOutcome.Success(httpClient.execute(request))
289+
ResponseOutcome.Success(httpClient.execute(stampAttempt(request, attemptOrdinal)))
275290
} catch (e: InterruptedException) {
276291
Thread.currentThread().interrupt()
277292
ResponseOutcome.Failure(e)
278293
} catch (t: Throwable) {
279294
ResponseOutcome.Failure(t)
280295
}
281296

297+
/**
298+
* Returns a per-attempt copy of [request] carrying the configured
299+
* [RetrySettings.attemptHeaderName] set to [attemptOrdinal]. When no attempt header is
300+
* configured the original [request] is returned unchanged — the no-op path allocates
301+
* nothing, so the common (feature-off) case pays no cost. The returned copy is built via
302+
* [Request.newBuilder] so the immutable template the step captured is never mutated; any
303+
* idempotency key the caller stamped is preserved verbatim because only this single
304+
* header is replaced.
305+
*/
306+
private fun stampAttempt(
307+
request: Request,
308+
attemptOrdinal: Int,
309+
): Request {
310+
val header = settings.attemptHeaderName ?: return request
311+
return request.newBuilder()
312+
.setHeader(header.caseSensitiveName, attemptOrdinal.toString())
313+
.build()
314+
}
315+
282316
/**
283317
* Blocks the calling thread for [delay] without pinning a carrier thread under Loom.
284318
* Uses [ScheduledExecutorService.schedule] + [CompletableFuture.get] so the wait can

0 commit comments

Comments
 (0)