Skip to content

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
mainfrom
feat/no-transparent-connection-retry
Draft

mp-orkes wants to merge 5 commits into
mainfrom
feat/no-transparent-connection-retry

Conversation

@mp-orkes

@mp-orkes mp-orkes commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

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 startWorkflow the operation can then run twice, and the error the caller sees describes the last attempt rather than the one that did the work.

  1. The request is written on a pooled connection. The server receives it and creates the workflow.
  2. The connection fails before the response comes back — the server was killed, restarted, or cut off by a network fault.
  3. OkHttp judges the failure recoverable and retransmits the body, on the same address or the next resolved one.
  4. That attempt fails too, or succeeds and runs the work a second time.
  5. Either way the caller is told about attempt two. Attempt one already created the workflow.

This needs no multi-homing. On a pooled connection ExchangeFinder.routeSelector is still null, so retryAfterFailure() returns true and the body is retransmitted on a single address.

Why

RequestBody.isOneShot() defaults to false, so an ordinary JSON body stays eligible for retransmission even once it has been sent. RetryAndFollowUpInterceptor only 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:

ConductorClient.builder()
        .basePath(...)
        .retransmitRequestBodies(false)
        .build();

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(...) returns null for 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:

  • 307 and 308 redirects are no longer followed. Those two codes keep the body, so the follow-up request carries a one-shot body and OkHttp returns the redirect instead. The caller sees a ConductorClientException carrying status 307 or 308. 301, 302 and 303 are unaffected — they convert to a bodyless GET.
  • 408 responses are no longer replayed.
  • 421 misdirected-request responses are no longer retried on a fresh connection.

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 through ConductorClient. MockWebServer is bound explicitly to 127.0.0.1 so there is provably one route; a warm-up call pools the connection first, which is what makes single-address retransmission happen.

  • Default: the body is delivered, the connection is severed, OkHttp retransmits, and the call succeeds — getRequestCount() == 3, and the caller never learns the work ran twice.
  • Opted in: same setup, getRequestCount() == 2, the call fails.
  • Contract pin: the built request body is one-shot only when opted in.
  • Route fallback: a one-shot POST still falls back to a second resolved address when the first cannot be connected to.

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 on localhost being dual-stack.

🤖 Generated with Claude Code

mp-orkes and others added 3 commits October 3, 2026 13:39
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 and others added 2 commits October 3, 2026 15:37
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
@mp-orkes mp-orkes changed the title fix(client): make request bodies one-shot feat(client): add retransmitRequestBodies(false) to stop re-sending a request that was already sent Oct 3, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant