Skip to content

Commit daff797

Browse files
authored
test: document and pin that a body-less non-idempotent request is not retried (#87)
* test: pin and document that a body-less non-idempotent request is not retried The retry-safety gate keys body-less requests off method idempotency rather than off the absence of a body: with no body the request is retried only when its method is idempotent, and with a body only when the body is replayable. A consequence that was easy to miss is that a bare POST (a body-less, non-idempotent request, e.g. a trigger/activate-style endpoint) is NOT retried even though it carries no payload to re-send — replaying it could duplicate a side effect the server may already have applied. This behavior was intended but undocumented and untested. Make it explicit: - Spell out the body-less branch in the KDoc of DefaultRetryStep (isRetrySafe and the "Body replayability" section) and the recovery-aware RetryStep (canRetry), stating that body-less retry safety keys off method idempotency. - Tighten the retry idempotency note in docs/pipelines.md to match the actual per-axis gate and call out the bare-POST case. - Add regression tests: a body-less POST against a retryable 503 results in exactly one attempt (no retry), with a body-less PUT control proving the same branch retries an idempotent method. KDoc, docs, and tests only; no public API or behavior change. * test: mirror body-less retry-eligibility cases in the recovery-pipeline RetryStep suite The http.pipeline DefaultRetryStep suite pins that a body-less request's retry eligibility keys off method idempotency (bare POST not retried, body-less PUT retried). The recovery-oriented pipeline.step.retry.RetryStep gate has the same rule but only covered body-bearing requests (non-replayable POST/PUT). Add the two body-less cases there so both gates carry the regression, and cross-reference the mirrored cases in both suites.
1 parent c4a9599 commit daff797

7 files changed

Lines changed: 1798 additions & 20 deletions

File tree

AUDIT.md

Lines changed: 149 additions & 0 deletions
Large diffs are not rendered by default.

docs/openai-java-deep-dive.md

Lines changed: 1514 additions & 0 deletions
Large diffs are not rendered by default.

docs/pipelines.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,14 @@ It retries only when the outcome is a `Failure` whose throwable is classified re
362362
`RetrySettings.retryableStatuses`.
363363
- A `NetworkException` (a transport failure with no response on the wire — always retryable).
364364

365-
Idempotency is enforced independently of classification: a request is eligible only when its
366-
method is in `RetrySettings.retryableMethods` **or** its body is replayable. Non-idempotent
367-
methods (`POST`/`PATCH`) with a non-replayable body are never re-sent.
365+
Idempotency is enforced independently of classification, keyed off whether the request carries
366+
a body. A request **with a body** is eligible only when its body is replayable (a non-replayable
367+
body cannot be re-sent — the second `writeTo` would trip its consume-once guard). A request
368+
**with no body** is eligible only when its method is in `RetrySettings.retryableMethods`.
369+
Body-less retry safety keys off method idempotency, not off the absence of a body: a body-less
370+
non-idempotent request — a bare `POST`/`PATCH` to a trigger / activate-style endpoint — is
371+
therefore never re-sent, even though there is no payload to replay, because the server may
372+
already have applied the side effect.
368373

369374
Waits between attempts use a `ScheduledExecutorService` plus `CompletableFuture.get`, never
370375
`Thread.sleep`, so virtual-thread carriers can unmount during the delay. An interrupt restores

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

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,20 @@ import java.util.concurrent.ThreadLocalRandom
4646
*
4747
* ## Body replayability
4848
*
49-
* Eligibility is gated on re-sendability. A body-less request is retried only when its method
50-
* is idempotent; a body-bearing request is retried only when its body is replayable — a
51-
* non-replayable body physically cannot be re-sent (the second `writeTo` trips the body's
52-
* consume-once guard and surfaces as a confusing wrapped [IllegalStateException] that masks the
53-
* real failure). A replayable body is re-sendable regardless of method; making the re-sent
54-
* request idempotent (for a non-idempotent method, e.g. via an idempotency key) is the caller's
55-
* responsibility. When the request is not re-sendable the loop runs exactly one attempt and
56-
* returns the response (or rethrows the exception) as-is. This mirrors
57-
* `pipeline.step.retry.RetryStep.canRetry`.
49+
* Eligibility is gated on re-sendability, keyed off whether the request carries a body:
50+
* - **No body** — retried only when the method is idempotent ([IDEMPOTENT_METHODS]). Body-less
51+
* retry safety keys off method idempotency, NOT off the absence of a body, so a body-less
52+
* non-idempotent request — e.g. a bare `POST` to a trigger / activate-style endpoint — is NOT
53+
* retried even though there is no payload to re-send: replaying it could duplicate the side
54+
* effect the server may already have applied.
55+
* - **Has a body** — retried only when the body is replayable. A non-replayable body physically
56+
* cannot be re-sent (the second `writeTo` trips the body's consume-once guard and surfaces as
57+
* a confusing wrapped [IllegalStateException] that masks the real failure). A replayable body
58+
* is re-sendable regardless of method; making the re-sent request idempotent (for a
59+
* non-idempotent method, e.g. via an idempotency key) is the caller's responsibility.
60+
*
61+
* When the request is not re-sendable the loop runs exactly one attempt and returns the response
62+
* (or rethrows the exception) as-is. This mirrors `pipeline.step.retry.RetryStep.canRetry`.
5863
*
5964
* ## Delay precedence (highest to lowest)
6065
*
@@ -246,11 +251,14 @@ public open class DefaultRetryStep
246251

247252
/**
248253
* Returns `true` when [request] may be re-sent. A body-less request is retry-safe only
249-
* when its method is idempotent; a body-bearing request is retry-safe only when its body
250-
* is replayable — a non-replayable body cannot be re-sent (the second
251-
* `RequestBody.writeTo` trips the body's consume-once guard and surfaces as a confusing
252-
* wrapped [IllegalStateException]). Making a re-sent body-bearing request idempotent is
253-
* the caller's responsibility. Mirrors `pipeline.step.retry.RetryStep.canRetry`.
254+
* when its method is idempotent ([IDEMPOTENT_METHODS]) — the gate keys off method
255+
* idempotency, not off the absence of a body, so a body-less non-idempotent request (a
256+
* bare `POST`) is NOT retry-safe even though there is no payload to re-send. A body-bearing
257+
* request is retry-safe only when its body is replayable — a non-replayable body cannot be
258+
* re-sent (the second `RequestBody.writeTo` trips the body's consume-once guard and
259+
* surfaces as a confusing wrapped [IllegalStateException]). Making a re-sent body-bearing
260+
* request idempotent is the caller's responsibility. Mirrors
261+
* `pipeline.step.retry.RetryStep.canRetry`.
254262
*/
255263
private fun isRetrySafe(request: Request): Boolean {
256264
val body = request.body ?: return request.method in IDEMPOTENT_METHODS

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,9 +344,11 @@ public class RetryStep
344344

345345
/**
346346
* Returns true when [request] is safe to retry. A body-less request is retry-safe only
347-
* when its method is in [RetrySettings.retryableMethods] (idempotent); a body-bearing
348-
* request is retry-safe only when its body is replayable — a non-replayable body cannot
349-
* be re-sent (the second `writeTo` trips the consume-once guard). Ensuring a re-sent
347+
* when its method is in [RetrySettings.retryableMethods] (idempotent) — the gate keys off
348+
* method idempotency, not off the absence of a body, so a body-less non-idempotent request
349+
* (a bare `POST`) is NOT retried even though there is no payload to re-send. A body-bearing
350+
* request is retry-safe only when its body is replayable — a non-replayable body cannot be
351+
* re-sent (the second `writeTo` trips the consume-once guard). Ensuring a re-sent
350352
* body-bearing request is idempotent is the caller's responsibility.
351353
*/
352354
private fun canRetry(request: Request): Boolean {

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,61 @@ class RetryStepTest {
685685
assertEquals(2, fake.callCount, "replayable POST must retry")
686686
}
687687

688+
@Test
689+
fun `body-less POST is NOT retried on a retryable response`() {
690+
// Body-less retry safety keys off METHOD idempotency, not off the absence of a body. A
691+
// bare POST (no payload to re-send) is non-idempotent, so it must NOT be retried even on a
692+
// retryable status — a second POST could duplicate a side effect the server already
693+
// applied. The 503 is returned as-is after exactly one attempt. This exercises the
694+
// body == null branch of isRetrySafe on a non-idempotent method. Mirrored by the
695+
// `body-less POST is not retried` case in the pipeline.step.retry RetryStep suite.
696+
val fake =
697+
FakeHttpClient()
698+
.enqueue { status(503) }
699+
.enqueue { status(200) } // must never be reached
700+
701+
val pipeline =
702+
HttpPipelineBuilder(fake)
703+
.append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3), zeroDelayClock()))
704+
.build()
705+
706+
val request =
707+
Request.builder()
708+
.method(Method.POST)
709+
.url("https://api.example.com/x")
710+
.build()
711+
712+
val response = pipeline.send(request)
713+
assertEquals(503, response.status.code)
714+
assertEquals(1, fake.callCount, "body-less POST must not be retried — POST is non-idempotent")
715+
}
716+
717+
@Test
718+
fun `body-less PUT IS retried because PUT is idempotent`() {
719+
// Control for the body == null branch: with no body the gate falls through to method
720+
// idempotency. PUT is idempotent, so a body-less PUT is retry-safe and retries normally.
721+
// Mirrored by the `body-less PUT is retried` case in the pipeline.step.retry RetryStep suite.
722+
val fake =
723+
FakeHttpClient()
724+
.enqueue { status(503) }
725+
.enqueue { status(200) }
726+
727+
val pipeline =
728+
HttpPipelineBuilder(fake)
729+
.append(DefaultRetryStep(HttpRetryOptions(maxRetries = 3), zeroDelayClock()))
730+
.build()
731+
732+
val request =
733+
Request.builder()
734+
.method(Method.PUT)
735+
.url("https://api.example.com/x")
736+
.build()
737+
738+
val response = pipeline.send(request)
739+
assertEquals(200, response.status.code)
740+
assertEquals(2, fake.callCount, "body-less PUT must retry — PUT is idempotent")
741+
}
742+
688743
@Test
689744
fun `non-replayable PUT body is NOT retried even though PUT is idempotent`() {
690745
// Both gates are required: PUT is idempotent, but a non-replayable body physically

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ class RetryStepTest {
167167
.build()
168168
}
169169

170+
private fun requestPostBodyless(): Request =
171+
Request.builder()
172+
.url("https://api.example.com/resource")
173+
.method(Method.POST)
174+
.build()
175+
176+
private fun requestPutBodyless(): Request =
177+
Request.builder()
178+
.url("https://api.example.com/resource")
179+
.method(Method.PUT)
180+
.build()
181+
170182
private fun response(
171183
status: Int,
172184
headers: Headers = Headers.builder().build(),
@@ -248,6 +260,39 @@ class RetryStepTest {
248260
assertTrue(client.calls.isEmpty())
249261
}
250262

263+
@Test
264+
fun `503 with body-less POST is not retried because POST is non-idempotent`() {
265+
// Body-less retry safety keys off METHOD idempotency, not off the absence of a body. A
266+
// bare POST has no payload to re-send, but it is non-idempotent, so it must NOT be retried
267+
// — a second POST could duplicate a side effect the server already applied. Exercises the
268+
// body == null branch of canRetry on a non-idempotent method. Mirrors the
269+
// `body-less POST is NOT retried` case in the http.pipeline DefaultRetryStep suite.
270+
val client = FakeClient()
271+
val request = requestPostBodyless()
272+
val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request)
273+
val outcome = ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE))
274+
val out = step.invoke(outcome)
275+
assertSame(outcome, out, "Outcome must be unchanged — a body-less POST is non-idempotent")
276+
assertTrue(client.calls.isEmpty(), "body-less POST must not be re-dispatched")
277+
}
278+
279+
@Test
280+
fun `503 with body-less PUT is retried because PUT is idempotent`() {
281+
// Control for the body == null branch: with no body the gate falls through to method
282+
// idempotency. PUT is idempotent, so a body-less PUT is retry-safe and retries normally —
283+
// here the single retry succeeds. Mirrors the `body-less PUT IS retried` control in the
284+
// http.pipeline DefaultRetryStep suite.
285+
val ok = response(SC_OK)
286+
val client = FakeClient(listOf(Canned.Ok(ok)))
287+
val request = requestPutBodyless()
288+
val step = RetryStep(client, zeroDelaySettings(InstantScheduler()), request)
289+
val outcome = ResponseOutcome.Failure(httpException(SC_SERVICE_UNAVAILABLE))
290+
val out = step.invoke(outcome)
291+
assertTrue(out is ResponseOutcome.Success, "body-less PUT must retry — PUT is idempotent")
292+
assertSame(ok, (out as ResponseOutcome.Success).response)
293+
assertEquals(1, client.calls.size)
294+
}
295+
251296
@Test
252297
fun `404 is not retried`() {
253298
val client = FakeClient()

0 commit comments

Comments
 (0)