Skip to content

Commit b6ccdc2

Browse files
authored
feat: server-driven and observable retry controls (#92)
PR: #92
1 parent b9e4402 commit b6ccdc2

8 files changed

Lines changed: 660 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
@@ -951,6 +951,21 @@ public abstract class org/dexpace/sdk/core/http/pipeline/steps/RetryStep : org/d
951951
public final fun getStage ()Lorg/dexpace/sdk/core/http/pipeline/Stage;
952952
}
953953

954+
public final class org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate : org/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate {
955+
public static final field Companion Lorg/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate$Companion;
956+
public static final field DEFAULT_HEADER_NAME Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
957+
public fun <init> ()V
958+
public fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)V
959+
public fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;)V
960+
public synthetic fun <init> (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
961+
public final fun getDelegate ()Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryConditionPredicate;
962+
public final fun getHeaderName ()Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
963+
public fun shouldRetry (Lorg/dexpace/sdk/core/http/pipeline/steps/HttpRetryCondition;)Z
964+
}
965+
966+
public final class org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate$Companion {
967+
}
968+
954969
public final class org/dexpace/sdk/core/http/pipeline/steps/SetDateStep : org/dexpace/sdk/core/http/pipeline/HttpStep {
955970
public fun <init> ()V
956971
public fun <init> (Lorg/dexpace/sdk/core/util/Clock;)V
@@ -2118,9 +2133,10 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings {
21182133
public static final field DEFAULT_RETRYABLE_METHODS Ljava/util/Set;
21192134
public static final field DEFAULT_RETRYABLE_STATUSES Ljava/util/Set;
21202135
public static final field DEFAULT_TOTAL_TIMEOUT Ljava/time/Duration;
2121-
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
2136+
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
21222137
public static final fun builder ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
21232138
public static final fun defaults ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
2139+
public final fun getAttemptHeaderName ()Lorg/dexpace/sdk/core/http/common/HttpHeaderName;
21242140
public final fun getDelayMultiplier ()D
21252141
public final fun getInitialDelay ()Ljava/time/Duration;
21262142
public final fun getJitter ()D
@@ -2141,6 +2157,7 @@ public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings$Compan
21412157
public final class org/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder : org/dexpace/sdk/core/generics/Builder {
21422158
public fun <init> ()V
21432159
public fun <init> (Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;)V
2160+
public final fun attemptHeaderName (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
21442161
public synthetic fun build ()Ljava/lang/Object;
21452162
public fun build ()Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings;
21462163
public final fun delayMultiplier (D)Lorg/dexpace/sdk/core/pipeline/step/retry/RetrySettings$RetrySettingsBuilder;
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
* ## What the override does and does not bypass
31+
*
32+
* The override flips only the *classification* decision — "is this response retryable?". It does
33+
* not bypass the other gates the retry step enforces: a forced retry is still capped by
34+
* [HttpRetryOptions.maxRetries], and still requires a retry-safe request (an idempotent method or
35+
* a replayable body). A truthy header on a `POST` carrying a non-replayable body therefore does
36+
* **not** trigger a retry — the body cannot be re-sent, so the response is returned as-is.
37+
*
38+
* ## Opt-in
39+
*
40+
* This predicate is **not** installed by default. A caller enables it by passing an instance as
41+
* [HttpRetryOptions.shouldRetryCondition] (and/or [HttpRetryOptions.shouldRetryException]). The
42+
* SDK's default retryable-status set is unchanged — in particular `409 Conflict` stays out of
43+
* it; this predicate is the mechanism by which a server can opt a `409` (or any other status)
44+
* into a retry, rather than widening the default set for everyone.
45+
*
46+
* ## Composition
47+
*
48+
* [delegate] defaults to the SDK's standard classifier, dispatched per path: the response
49+
* classifier (`408 / 429 / 5xx`, except `501` / `505`) on the response path, and the exception
50+
* classifier (`IOException` / `TimeoutException` anywhere in the cause chain) on the exception
51+
* path. Because the override header only appears on responses, wiring this predicate into
52+
* [HttpRetryOptions.shouldRetryException] leaves the exception-path decision entirely to the
53+
* delegate. Supply a different delegate to layer the server override on top of bespoke retry
54+
* logic, or [HttpRetryConditionPredicate] `{ false }` to make the server header the sole
55+
* authority.
56+
*
57+
* ## Thread-safety
58+
*
59+
* Immutable and stateless — safe to share across concurrent requests.
60+
*
61+
* @property headerName The response header consulted for the override signal. Defaults to
62+
* `X-Should-Retry`.
63+
* @property delegate Fallback predicate consulted when the header is absent or unrecognised, or
64+
* on the exception path. Defaults to the standard per-path classifier.
65+
*/
66+
public class ServerOverrideRetryPredicate
67+
@JvmOverloads
68+
constructor(
69+
public val headerName: HttpHeaderName = DEFAULT_HEADER_NAME,
70+
public val delegate: HttpRetryConditionPredicate =
71+
HttpRetryConditionPredicate(::defaultClassifier),
72+
) : HttpRetryConditionPredicate {
73+
override fun shouldRetry(condition: HttpRetryCondition): Boolean {
74+
val response = condition.response ?: return delegate.shouldRetry(condition)
75+
val raw = response.headers.get(headerName) ?: return delegate.shouldRetry(condition)
76+
return when (raw.trim().lowercase(Locale.US)) {
77+
in TRUTHY -> true
78+
in FALSY -> false
79+
// An unrecognised value is not a directive — defer rather than guess.
80+
else -> delegate.shouldRetry(condition)
81+
}
82+
}
83+
84+
public companion object {
85+
/** Default override header (`X-Should-Retry`). */
86+
@JvmField
87+
public val DEFAULT_HEADER_NAME: HttpHeaderName = HttpHeaderName.fromString("X-Should-Retry")
88+
89+
/** Header values that force a retry. */
90+
private val TRUTHY: Set<String> = setOf("true", "1", "yes", "retry")
91+
92+
/** Header values that suppress a retry. */
93+
private val FALSY: Set<String> = setOf("false", "0", "no", "stop")
94+
}
95+
}
96+
97+
/**
98+
* Default [ServerOverrideRetryPredicate.delegate]: applies the SDK's standard classification for
99+
* whichever path the [condition] represents — the response classifier when a response is present,
100+
* the exception classifier otherwise. This keeps the override predicate's fall-through behaviour
101+
* identical to the stock [HttpRetryOptions] defaults on both the response and exception paths.
102+
*/
103+
private fun defaultClassifier(condition: HttpRetryCondition): Boolean =
104+
if (condition.response != null) {
105+
defaultShouldRetryResponse(condition)
106+
} else {
107+
defaultShouldRetryException(condition)
108+
}

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

Lines changed: 43 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,25 @@ 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+
117+
/**
118+
* Strict grammar for the numeric `Retry-After` delta: one or more decimal digits, optionally
119+
* followed by a single decimal point and one or more decimal digits.
120+
*
121+
* Screening with this regex before [String.toDoubleOrNull] is deliberate: `toDoubleOrNull`
122+
* accepts the Java floating-point literal grammar, which includes the type suffixes (`d`,
123+
* `f`) and hexadecimal-float forms (`0x1p4`). Without the screen a header such as
124+
* `Retry-After: 30d` or `Retry-After: 0x1p4` would parse to a finite delta instead of falling
125+
* through to the backoff schedule. Only the RFC 7231 §7.1.3 delta-seconds form and the
126+
* fractional extension real servers emit are honoured here.
127+
*/
128+
private val NUMERIC_SECONDS = Regex("""^\d+(\.\d+)?$""")
129+
109130
/**
110131
* Parses the next-attempt delay from [headers] relative to [now]. Returns `null` when no
111132
* recognized header is present or parseable.
@@ -185,14 +206,29 @@ public object RetryAfterParser {
185206
}
186207

187208
/**
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.
209+
* Parses [value] as a non-negative count of seconds. Accepts both the RFC 7231 §7.1.3
210+
* integer (delta-seconds) form and the fractional form (`1.5`) that real servers and
211+
* proxies emit; the fractional part is honoured down to nanosecond resolution.
212+
*
213+
* The value is first screened against [NUMERIC_SECONDS] so only the plain decimal grammar
214+
* is honoured: [String.toDoubleOrNull] otherwise accepts Java float literals such as `30d`
215+
* and `0x1p4`, which must instead fall through to the HTTP-date branch (or, ultimately, the
216+
* backoff schedule). Returns `null` on any parse failure, on a negative value, or on a
217+
* non-finite value (`NaN`, `Infinity`). A finite but absurdly large value is clamped to
218+
* [MAX_DELAY] before the nanosecond conversion so the resulting [Duration] can never
219+
* overflow [Duration.toNanos] downstream.
191220
*/
192221
private fun parseNumericSeconds(value: String): Duration? {
193-
val seconds = value.toLongOrNull() ?: return null
194-
if (seconds < 0L) return null
195-
return Duration.ofSeconds(seconds)
222+
// The strict screen guarantees a non-negative decimal, so toDoubleOrNull never returns
223+
// null/NaN here; an extremely long digit run can still overflow to +Infinity, which the
224+
// ceiling check below absorbs by clamping rather than letting it reach the nanos multiply.
225+
if (!NUMERIC_SECONDS.matches(value)) return null
226+
val seconds = value.toDoubleOrNull() ?: return null
227+
// Clamp in the seconds domain before converting to nanos: a value beyond the ceiling
228+
// (or an overflow to +Infinity) would otherwise overflow the `* NANOS_PER_SECOND`
229+
// multiply and the Long cast below.
230+
if (seconds >= MAX_DELAY_SECONDS) return MAX_DELAY
231+
return Duration.ofNanos((seconds * NANOS_PER_SECOND).toLong())
196232
}
197233

198234
/**

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

0 commit comments

Comments
 (0)