Skip to content

Commit 79494a7

Browse files
committed
fix: screen numeric Retry-After against a strict decimal grammar
The fractional Retry-After support switched the numeric branch to toDoubleOrNull(), which accepts the Java floating-point literal grammar: "30d" parses to 30.0 and "0x1p4" to 16.0. A header like `Retry-After: 30d` would therefore silently honour a 30-second delta instead of falling through to the backoff schedule. Screen the value against ^\d+(\.\d+)?$ before parsing so only the RFC 7231 delta-seconds form and the fractional extension real servers emit are honoured; anything else (type suffixes, hex-float, signs, exponents) falls through to the HTTP-date branch and ultimately the backoff schedule. Add parser tests for the rejected forms. Expand the ServerOverrideRetryPredicate tests to cover the full truthy/falsy token matrix and the unrecognised-token defer branch, and add a case proving a forced retry still does not re-send a non-replayable body. Clarify the predicate KDoc that the override flips classification only and does not bypass the attempt cap or the replayability gate.
1 parent 4c6bcee commit 79494a7

4 files changed

Lines changed: 174 additions & 7 deletions

File tree

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/ServerOverrideRetryPredicate.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ import java.util.Locale
2727
* the decision is delegated to [delegate] — so wiring this predicate in changes behaviour
2828
* only when the server actually speaks.
2929
*
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+
*
3038
* ## Opt-in
3139
*
3240
* This predicate is **not** installed by default. A caller enables it by passing an instance as

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

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,19 @@ public object RetryAfterParser {
114114
/** Nanoseconds per second — the scale factor for the fractional `Retry-After` conversion. */
115115
private const val NANOS_PER_SECOND: Double = 1_000_000_000.0
116116

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+
117130
/**
118131
* Parses the next-attempt delay from [headers] relative to [now]. Returns `null` when no
119132
* recognized header is present or parseable.
@@ -197,17 +210,23 @@ public object RetryAfterParser {
197210
* integer (delta-seconds) form and the fractional form (`1.5`) that real servers and
198211
* proxies emit; the fractional part is honoured down to nanosecond resolution.
199212
*
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.
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.
205220
*/
206221
private fun parseNumericSeconds(value: String): Duration? {
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
207226
val seconds = value.toDoubleOrNull() ?: return null
208-
if (seconds.isNaN() || seconds.isInfinite() || seconds < 0.0) return null
209227
// 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.
228+
// (or an overflow to +Infinity) would otherwise overflow the `* NANOS_PER_SECOND`
229+
// multiply and the Long cast below.
211230
if (seconds >= MAX_DELAY_SECONDS) return MAX_DELAY
212231
return Duration.ofNanos((seconds * NANOS_PER_SECOND).toLong())
213232
}

sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/RetryStepTest.kt

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,115 @@ class RetryStepTest {
11741174
assertEquals(2, attempts.get())
11751175
}
11761176

1177+
@Test
1178+
fun `server override recognises every truthy token and forces a retry`() {
1179+
// Each documented truthy spelling (case-insensitive) must force a retry on an otherwise
1180+
// non-retryable 404. Verbatim casing variants prove the lowercase() normalisation.
1181+
for (token in listOf("true", "TRUE", "1", "yes", "YES", "retry", "Retry")) {
1182+
val opts =
1183+
HttpRetryOptions(
1184+
maxRetries = 3,
1185+
shouldRetryCondition = ServerOverrideRetryPredicate(),
1186+
)
1187+
val fake =
1188+
FakeHttpClient()
1189+
.enqueue { status(404).header("X-Should-Retry", token) }
1190+
.enqueue { status(200) }
1191+
val pipeline =
1192+
HttpPipelineBuilder(fake)
1193+
.append(DefaultRetryStep(opts, zeroDelayClock()))
1194+
.build()
1195+
1196+
val response = pipeline.send(getRequest())
1197+
assertEquals(200, response.status.code, "truthy token '$token' should force a retry")
1198+
assertEquals(2, fake.callCount, "truthy token '$token' should produce exactly one retry")
1199+
}
1200+
}
1201+
1202+
@Test
1203+
fun `server override recognises every falsy token and suppresses a retry`() {
1204+
// Each documented falsy spelling must suppress the retry the default classifier would
1205+
// otherwise allow on a 503.
1206+
for (token in listOf("false", "FALSE", "0", "no", "NO", "stop", "Stop")) {
1207+
val opts =
1208+
HttpRetryOptions(
1209+
maxRetries = 3,
1210+
shouldRetryCondition = ServerOverrideRetryPredicate(),
1211+
)
1212+
val fake =
1213+
FakeHttpClient()
1214+
.enqueue { status(503).header("X-Should-Retry", token) }
1215+
val pipeline =
1216+
HttpPipelineBuilder(fake)
1217+
.append(DefaultRetryStep(opts, zeroDelayClock()))
1218+
.build()
1219+
1220+
val response = pipeline.send(getRequest())
1221+
assertEquals(503, response.status.code, "falsy token '$token' should suppress the retry")
1222+
assertEquals(1, fake.callCount, "falsy token '$token' should not retry")
1223+
}
1224+
}
1225+
1226+
@Test
1227+
fun `server override defers an unrecognised token to the delegate`() {
1228+
// An unrecognised value such as `maybe` is not a directive: the predicate falls through
1229+
// to the delegate. With the default classifier, a 503 still retries and a 404 still does
1230+
// not — proving the else branch defers rather than guessing.
1231+
val retryableOpts =
1232+
HttpRetryOptions(
1233+
maxRetries = 3,
1234+
shouldRetryCondition = ServerOverrideRetryPredicate(),
1235+
)
1236+
val retryableFake =
1237+
FakeHttpClient()
1238+
.enqueue { status(503).header("X-Should-Retry", "maybe") }
1239+
.enqueue { status(200) }
1240+
val retryablePipeline =
1241+
HttpPipelineBuilder(retryableFake)
1242+
.append(DefaultRetryStep(retryableOpts, zeroDelayClock()))
1243+
.build()
1244+
assertEquals(200, retryablePipeline.send(getRequest()).status.code)
1245+
assertEquals(2, retryableFake.callCount, "unrecognised token must defer: 503 still retries")
1246+
1247+
val nonRetryableOpts =
1248+
HttpRetryOptions(
1249+
maxRetries = 3,
1250+
shouldRetryCondition = ServerOverrideRetryPredicate(),
1251+
)
1252+
val nonRetryableFake =
1253+
FakeHttpClient()
1254+
.enqueue { status(404).header("X-Should-Retry", "maybe") }
1255+
val nonRetryablePipeline =
1256+
HttpPipelineBuilder(nonRetryableFake)
1257+
.append(DefaultRetryStep(nonRetryableOpts, zeroDelayClock()))
1258+
.build()
1259+
assertEquals(404, nonRetryablePipeline.send(getRequest()).status.code)
1260+
assertEquals(1, nonRetryableFake.callCount, "unrecognised token must defer: 404 still does not retry")
1261+
}
1262+
1263+
@Test
1264+
fun `server override does not bypass the non-replayable body gate`() {
1265+
// A truthy override flips the classification decision, but it does not bypass the
1266+
// replayability gate: a POST with a non-replayable body cannot be re-sent, so the
1267+
// response is returned without a retry even though the server asked for one.
1268+
val opts =
1269+
HttpRetryOptions(
1270+
maxRetries = 3,
1271+
shouldRetryCondition = ServerOverrideRetryPredicate(),
1272+
)
1273+
val fake =
1274+
FakeHttpClient()
1275+
.enqueue { status(404).header("X-Should-Retry", "true") }
1276+
val pipeline =
1277+
HttpPipelineBuilder(fake)
1278+
.append(DefaultRetryStep(opts, zeroDelayClock()))
1279+
.build()
1280+
1281+
val response = pipeline.send(nonReplayablePost())
1282+
assertEquals(404, response.status.code)
1283+
assertEquals(1, fake.callCount, "forced retry must not re-send a non-replayable body")
1284+
}
1285+
11771286
// ----------------- Error propagation -----------------
11781287

11791288
@Test

sdk-core/src/test/kotlin/org/dexpace/sdk/core/pipeline/step/retry/RetryAfterParserTest.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,37 @@ class RetryAfterParserTest {
102102
)
103103
}
104104

105+
@Test
106+
fun `Retry-After with a Java float type suffix is not honoured as numeric`() {
107+
// `"30d".toDoubleOrNull()` returns 30.0, but `30d` is not a delta-seconds value. The
108+
// strict numeric screen rejects it, and it is not a valid HTTP-date either, so it must
109+
// fall through to null rather than parse as 30 seconds.
110+
assertNull(RetryAfterParser.parse(headers("Retry-After" to "30d"), now))
111+
assertNull(RetryAfterParser.parse(headers("Retry-After" to "30f"), now))
112+
assertNull(RetryAfterParser.parse(headers("Retry-After" to "30D"), now))
113+
}
114+
115+
@Test
116+
fun `Retry-After hex-float form is not honoured as numeric`() {
117+
// `"0x1p4".toDoubleOrNull()` returns 16.0; the strict screen rejects the hex-float
118+
// grammar so it does not silently parse as a 16-second delay.
119+
assertNull(RetryAfterParser.parse(headers("Retry-After" to "0x1p4"), now))
120+
}
121+
122+
@Test
123+
fun `Retry-After with a leading plus or exponent is not honoured as numeric`() {
124+
// Signs and scientific notation are outside the delta-seconds grammar and must be
125+
// rejected by the strict screen.
126+
assertNull(RetryAfterParser.parse(headers("Retry-After" to "+30"), now))
127+
assertNull(RetryAfterParser.parse(headers("Retry-After" to "1e3"), now))
128+
assertNull(RetryAfterParser.parse(headers("Retry-After" to ".5"), now))
129+
}
130+
131+
@Test
132+
fun `parseHeaderValue rejects a Retry-After float type suffix`() {
133+
assertNull(RetryAfterParser.parseHeaderValue(HttpHeaderName.RETRY_AFTER, "30d", now))
134+
}
135+
105136
// endregion
106137

107138
// region -- HTTP-date Retry-After --

0 commit comments

Comments
 (0)