Current design
RequestBody defaults to is_replayable() == False; from_stream / from_iter are single-use, and the documented contract is that a caller must call to_replayable() before a send if the body needs to be re-emitted.
Two shipped policies handle this hazard in opposite ways:
-
RetryPolicy.send auto-buffers. At the top of send it swaps in a replayable copy when the effective retry total is positive (packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/retry.py:227-228):
if settings["total"] > 0 and request.body is not None and not request.body.is_replayable():
request = request.with_body(request.body.to_replayable())
AsyncRetryPolicy does the same.
-
RedirectPolicy refuses. On a body-preserving hop (307/308, and 301/302 here) _reissue_preserving_body raises (packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/redirect.py:271-276):
if body is not None and not body.is_replayable():
raise RuntimeError(
"Cannot follow redirect with a non-replayable request body. "
"Call body.to_replayable() before sending if redirects are expected."
)
In the default stack the two sit in the same pipeline, with redirect outer and retry inner (packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/defaults.py:86-88, order operation-tracing → redirect → idempotency → retry → ...). Redirect drives its own hop loop over current_request (redirect.py:139-169) and calls self.next.send(...) into the retry wrapper. Retry's buffered copy is local to retry's downstream chain — it never propagates back up to the current_request redirect reissues from. So a single-use body sent with total_retries > 0 is buffered and safe for retries, yet a 307/308 hop still hits is_replayable() == False and raises RuntimeError.
Trade-off / concern
The contract for one hazard is internally inconsistent, and which behavior a caller gets depends on which policy fires for a given response — invisible at call-assembly time.
A consumer who reads docs/bodies.md ("The retry policy does automatically buffer single-use bodies when retries are enabled... so a retry can re-emit the same payload") or docs/pipelines.md ("To keep retries safe without forcing every caller to remember to_replayable()...") reasonably concludes the default pipeline buffers single-use bodies for them. The same default pipeline then throws a hard RuntimeError on a 307/308 from the redirect policy. The divergence with redirect is not called out anywhere in the docs or CLAUDE.md as an intentional asymmetry.
There is also a second-order tension with the headline design intent. The stated model is "single-use by default; caller must remember to_replayable()." But retry's unconditional buffer-when-total>0 makes that manual contract effectively moot in the default stack for the retry path — while redirect still enforces it. The hazard is half-erased and half-mandatory, and the boundary is the policy, not anything the caller can see.
Modeling replayability as a runtime boolean (is_replayable()) rather than as a property of the body's type is what lets these two policies disagree silently: nothing in the request-assembly signatures records that a body is or is not safe to re-emit, so each policy is free to invent its own response to the flag.
Proposed direction
Pick one model and apply it across the whole pipeline:
-
(a) Uniform buffer-on-demand. RedirectPolicy calls to_replayable() on a body-preserving hop, exactly as retry does. Ergonomic and consistent; cost is the same silent memory buffering retry already incurs, now also on the redirect path. To make it propagate correctly in the default ordering, the buffering would need to happen where both policies see it (e.g. once, outermost) rather than locally inside retry.
-
(b) Uniform typed error. Neither policy buffers silently; both surface the same typed error (not a bare RuntimeError) when a single-use body meets a retry/redirect that needs replay. Keeps single-use genuinely single-use and keeps the manual to_replayable() contract honest, at the cost of the convenience retry currently provides.
-
(c) Typed split at construction. Replace the runtime is_replayable() flag with a ReplayableRequestBody subtype that pipeline/transport signatures can demand when retries or redirects are enabled. "This body cannot be retried/redirected" becomes a type error at call assembly, the buffering conversion happens once in one well-defined place, and the two policies can no longer diverge because there is nothing to diverge on. Trade-off: callers must opt into replayability up front (less flexible than a body that silently upgrades itself), in exchange for a static guarantee the boolean-plus-divergent-policies design cannot give. This is the larger change and most directly addresses the root cause.
Whichever route, the single buffering point also resolves a related interaction: LoggableRequestBody.to_replayable() and where in the chain the conversion lands matter less once there is one canonical place it happens.
Open question for direction
Is the retry auto-buffer meant to be the de-facto behavior of the default stack (in which case redirect should match it, option a), or is the manual to_replayable() contract the real intent (in which case retry's silent buffer is the outlier, option b)? The answer decides whether this is a redirect bug-fix-shaped change or a deliberate convenience to standardize on. The from_stream/from_iter single-use default and the to_replayable() mechanism are documented; only the cross-policy asymmetry is not, and clarifying which side is canonical is the first decision.
Current design
RequestBodydefaults tois_replayable() == False;from_stream/from_iterare single-use, and the documented contract is that a caller must callto_replayable()before a send if the body needs to be re-emitted.Two shipped policies handle this hazard in opposite ways:
RetryPolicy.sendauto-buffers. At the top ofsendit swaps in a replayable copy when the effective retry total is positive (packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/retry.py:227-228):AsyncRetryPolicydoes the same.RedirectPolicyrefuses. On a body-preserving hop (307/308, and 301/302 here)_reissue_preserving_bodyraises (packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/policies/redirect.py:271-276):In the default stack the two sit in the same pipeline, with redirect outer and retry inner (
packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/defaults.py:86-88, orderoperation-tracing → redirect → idempotency → retry → ...). Redirect drives its own hop loop overcurrent_request(redirect.py:139-169) and callsself.next.send(...)into the retry wrapper. Retry's buffered copy is local to retry's downstream chain — it never propagates back up to thecurrent_requestredirect reissues from. So a single-use body sent withtotal_retries > 0is buffered and safe for retries, yet a 307/308 hop still hitsis_replayable() == Falseand raisesRuntimeError.Trade-off / concern
The contract for one hazard is internally inconsistent, and which behavior a caller gets depends on which policy fires for a given response — invisible at call-assembly time.
A consumer who reads
docs/bodies.md("The retry policy does automatically buffer single-use bodies when retries are enabled... so a retry can re-emit the same payload") ordocs/pipelines.md("To keep retries safe without forcing every caller to rememberto_replayable()...") reasonably concludes the default pipeline buffers single-use bodies for them. The same default pipeline then throws a hardRuntimeErroron a 307/308 from the redirect policy. The divergence with redirect is not called out anywhere in the docs orCLAUDE.mdas an intentional asymmetry.There is also a second-order tension with the headline design intent. The stated model is "single-use by default; caller must remember
to_replayable()." But retry's unconditional buffer-when-total>0makes that manual contract effectively moot in the default stack for the retry path — while redirect still enforces it. The hazard is half-erased and half-mandatory, and the boundary is the policy, not anything the caller can see.Modeling replayability as a runtime boolean (
is_replayable()) rather than as a property of the body's type is what lets these two policies disagree silently: nothing in the request-assembly signatures records that a body is or is not safe to re-emit, so each policy is free to invent its own response to the flag.Proposed direction
Pick one model and apply it across the whole pipeline:
(a) Uniform buffer-on-demand.
RedirectPolicycallsto_replayable()on a body-preserving hop, exactly as retry does. Ergonomic and consistent; cost is the same silent memory buffering retry already incurs, now also on the redirect path. To make it propagate correctly in the default ordering, the buffering would need to happen where both policies see it (e.g. once, outermost) rather than locally inside retry.(b) Uniform typed error. Neither policy buffers silently; both surface the same typed error (not a bare
RuntimeError) when a single-use body meets a retry/redirect that needs replay. Keeps single-use genuinely single-use and keeps the manualto_replayable()contract honest, at the cost of the convenience retry currently provides.(c) Typed split at construction. Replace the runtime
is_replayable()flag with aReplayableRequestBodysubtype that pipeline/transport signatures can demand when retries or redirects are enabled. "This body cannot be retried/redirected" becomes a type error at call assembly, the buffering conversion happens once in one well-defined place, and the two policies can no longer diverge because there is nothing to diverge on. Trade-off: callers must opt into replayability up front (less flexible than a body that silently upgrades itself), in exchange for a static guarantee the boolean-plus-divergent-policies design cannot give. This is the larger change and most directly addresses the root cause.Whichever route, the single buffering point also resolves a related interaction:
LoggableRequestBody.to_replayable()and where in the chain the conversion lands matter less once there is one canonical place it happens.Open question for direction
Is the retry auto-buffer meant to be the de-facto behavior of the default stack (in which case redirect should match it, option a), or is the manual
to_replayable()contract the real intent (in which case retry's silent buffer is the outlier, option b)? The answer decides whether this is a redirect bug-fix-shaped change or a deliberate convenience to standardize on. Thefrom_stream/from_itersingle-use default and theto_replayable()mechanism are documented; only the cross-policy asymmetry is not, and clarifying which side is canonical is the first decision.