feat(client): add retransmitRequestBodies(false) to stop re-sending a request that was already sent - #175
Draft
mp-orkes wants to merge 5 commits into
Draft
feat(client): add retransmitRequestBodies(false) to stop re-sending a request that was already sent#175mp-orkes wants to merge 5 commits into
mp-orkes wants to merge 5 commits into
Conversation
Stops OkHttp re-sending a request that was already delivered. Adds retransmitRequestBodies(true) to opt back in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvEmz7CHxsyLEmhWwPWm7F
…ngle address RESET_STREAM_AT_START resets the stream before MockWebServer reads it, so it can't prove delivery. Instead, build a tiny HTTP/2 peer directly from OkHttp's internal Http2Connection that fully reads each request before refusing the first stream it sees. OkHttp's same-route retry then reopens a second stream on the very same pooled connection (not even a new TCP connection), resending the body, and the caller ends up seeing the second stream's own local CANCEL reset instead of the first attempt's REFUSED_STREAM - confirmed suppressed when the body is one-shot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvEmz7CHxsyLEmhWwPWm7F
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvEmz7CHxsyLEmhWwPWm7F
mp-orkes
requested review from
c4lm,
chrishagglund-ship-it,
manan164 and
v1r3n
October 3, 2026 18:19
Flip the default so the one-shot protection is opt-in (false), keeping today's OkHttp retransmit behaviour for existing callers; update the setter javadoc to cover the 307/308 redirect side effect. Delegate isDuplex() in the one-shot RequestBody wrapper so CallServerInterceptor doesn't hang on a duplex delegate. Replace ConnectionRetryBehaviourTest with RequestBodyRetransmissionTest: drop the dual-stack-dependent test, the HTTP/2 tests that never exercised ConductorClient, and the resetStreamAtStart test that passed only via an uncaught NPE and a 5s timeout. Add a deterministic pooled-connection retransmission test pair and a direct one-shot contract pin. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvEmz7CHxsyLEmhWwPWm7F
Fix preSendConnectFailure_fallsBackToAnotherRoute: it was built with the default (retransmitRequestBodies true, nothing wrapped) and sent a bodyless GET, so it only pinned stock OkHttp route fallback. Rebuild it with retransmitRequestBodies(false) and a POST body so it actually pins that pre-send fallback survives the one-shot wrapper; drop connectTimeout to 250ms since the dead route blackholes rather than refusing. Expand the setter javadoc to cover 408 and 421 follow-ups alongside 307/308, note what the caller sees (a ConductorClientException carrying the blocked status), and soften "already delivered" to "once transmission has begun" to match requestSendStarted. Settle the opt-in/opt-out naming on "retransmitRequestBodies(false) opts in to the protection" throughout. Replace the opt-in test's debug println with an assertion that the failure cause is an IOException and not an InterruptedIOException, guarding against a call-timeout masquerading as the expected failure. Track clients created in each test and evict their connection pools in tearDown. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvEmz7CHxsyLEmhWwPWm7F
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
retransmitRequestBodies(false)so a request body that was already sent is never sent again. Opt-in; the default keeps today's behaviour, so nothing changes unless a caller asks for it.What happens today
OkHttp retransmits a request body on a retried connection. For a non-idempotent call such as
startWorkflowthe operation can then run twice, and the error the caller sees describes the last attempt rather than the one that did the work.This needs no multi-homing. On a pooled connection
ExchangeFinder.routeSelectoris still null, soretryAfterFailure()returns true and the body is retransmitted on a single address.Why
RequestBody.isOneShot()defaults tofalse, so an ordinary JSON body stays eligible for retransmission even once it has been sent.RetryAndFollowUpInterceptoronly declines to retry a sent request when the body declares itself one-shot. Nothing in this client set it either way.The change
ConductorClient.requestBody(...)can now wrap the body it builds so OkHttp will not re-send it:The default is
true— stock OkHttp behaviour, unchanged for every existing caller. This is deliberately opt-in: the protection has consequences worth choosing knowingly, below.GET, and DELETE without a body, are never affected either way:
requestBody(...)returnsnullfor them, so there is nothing to mark.A failure before the request is sent still falls back to the next resolved address, opted in or not.
recover(...)only consults the one-shot flag once transmission has begun, so route fallback is preserved. There is a test for this.What opting in costs
The same
isOneShot()check gates three of OkHttp's automatic replays. Opting in stops all of them for requests with a body:ConductorClientExceptioncarrying status 307 or 308. 301, 302 and 303 are unaffected — they convert to a bodyless GET.The 307/308 one is the one to think about: an ingress doing HTTPS or trailing-slash redirects will break POST, PUT and PATCH while GET keeps working.
Tests
RequestBodyRetransmissionTest, four tests, all throughConductorClient. MockWebServer is bound explicitly to127.0.0.1so there is provably one route; a warm-up call pools the connection first, which is what makes single-address retransmission happen.getRequestCount() == 3, and the caller never learns the work ran twice.getRequestCount() == 2, the call fails.Reverting the wrap fails the contract pin and the opt-in test. The suite passes with IPv6 disabled (
-Djava.net.preferIPv4Stack=true), so it does not depend onlocalhostbeing dual-stack.🤖 Generated with Claude Code