Skip to content

Go: replay *WithBody request bodies across retries - #481

Merged
jeremy merged 1 commit into
mainfrom
fix/go-withbody-replay-body
Jul 28, 2026
Merged

jeremy merged 1 commit into
mainfrom
fix/go-withbody-replay-body

Conversation

@jeremy

@jeremy jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member

Problem

The generated client's doWithRetry rebuilds each attempt's request via buildRequest(), which recreates it from the caller's single-use io.Reader. The raw *WithBody(..., body io.Reader) methods therefore shipped an empty (or, after a partial-read network failure, mid-stream) body on every retry attempt. All 42 idempotent raw *WithBody ops (Update*/Reposition*/Set*/Replace*/Toggle*/MarkAsRead, reaching BC3 over PUT/DELETE) retry, so a transient 429/503/network failure could resend a truncated body.

Fix

doWithRetry now decides once how a retry reproduces the first finalized body, under a documented, net/http-consistent contract that never rewrites req.Body:

  • Attempt 1 always sends req.Body exactly as buildRequest + the request editors left it — so an editor that compresses/encrypts/transforms the payload (and sets matching headers) is honored, even with no retry.
  • A retry replays via req.GetBody, exactly as net/http replays a body on a 307/308 redirect — but only when the builder's body survived the editors unchanged (checked against the pre-editor reference) and GetBody probes OK.
  • If an editor replaced req.Body, the builder's GetBody is stale and the finalized bytes can't be reproduced, so the request is sent once (not retried with the wrong body). An in-place mutation without a GetBody update is undetectable and, like a redirect, would replay GetBody's snapshot — editors should replace the body or keep req.GetBody in sync.
  • No GetBody / non-idempotent → single attempt. nil and http.NoBody are reproduced faithfully (the sentinel is installed unwrapped so retry framing stays identical — Content-Length: 0, never chunked).

The SDK's own hand-written idempotent updates (Todos/Cards/CardSteps/Checkins/Projects/People/Schedules.Update + UpdateAccountLogo) marshal bodies via marshalBody, which now returns *bytes.Reader (net/http snapshots it into GetBody) instead of the old rewindableReader, so they keep retrying. On a retry the replay body is installed before the editors run (so body-aware editors sign the sent bytes) and owned via a close-once handle released after the editor phase even if an editor replaces it. ContentLength is restored per path. The fix lives in go/templates/client.tmpl (regenerated) plus helpers.go.

Tests

Outside pkg/generated per the repo rule — 18 tests (9 fail against the shipped client): empty/truncated retry body, wrong retry ContentLength, no-GetBody stream sent once, partial-read byte-0 restart, replaced body sent verbatim then not retried (with and without a matching GetBody), in-place mutation sent on attempt 1, empty-body transfer framing through a real net/http Transport, orphaned/leaked replay handles (replacement + replace-then-error), a signing editor (reads GetBody) whose digest matches the sent body on both attempts, and empty-body-not-nil; the rest are regression proofs and controls. Each proof is individually red-proofed. Plus a public-service retry proof (ProjectsService.Update) and a marshalBody snapshotability test. Go conformance's naturally-idempotent PUT/DELETE cases pass; -race, drift, go vet, golangci-lint green.

Behavior note

Attempt 1 sends req.Body as the editors finalized it (honoring body-transforming editors); retries replay via req.GetBody when the body was not replaced, else the request is sent once. Editors that transform the body must keep req.GetBody in sync for retries (per the net/http contract). No API signature changes.


Part of the retry-contract follow-up (#456 / #460 / #461 / #476). #482 and #483 are merged; this branch is rebased on top of them (shared client.tmpl regions are disjoint) and regenerated.

Copilot AI review requested due to automatic review settings July 28, 2026 06:28
@jeremy jeremy added bug Something isn't working go labels Jul 28, 2026
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Series — retry-contract follow-up (branch fresh off main, each independent):

#481 and #483 both touch go/templates/client.tmpl (disjoint regions, no textual conflict). Whichever merges second should be rebased and regenerated (make -C go generate); the merged tree passes the Go generated-drift gate.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a correctness bug in the Go generated client’s retry loop: idempotent *WithBody(..., body io.Reader) operations could retry with an empty or truncated body because each attempt rebuilt the request from a single-use reader. The change snapshots the finalized first-attempt body (post request editors) and replays it across retries with net/http-consistent GetBody semantics, preserving ContentLength and bounding buffering.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Changes:

  • Add request-body replay support to doWithRetry (prefer GetBody; otherwise buffer up to 1 MiB; over-cap demotes to single attempt).
  • Regenerate the Go client so the fix is reflected in go/pkg/generated/client.gen.go.
  • Add Go tests (outside pkg/generated) that pin retry body replay behavior and edge cases.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

File Description
go/templates/client.tmpl Implements body snapshot/replay logic in doWithRetry and the captureReplayBody helper.
go/pkg/generated/client.gen.go Regenerated output reflecting the retry body replay changes.
go/pkg/basecamp/generated_withbody_replay_test.go Adds contract tests to ensure retries resend correct bodies across retry scenarios.
Comments suppressed due to low confidence (2)

go/pkg/basecamp/generated_withbody_replay_test.go:135

  • On the simulated mid-stream network error path, the request body isn't closed before returning the error. Closing it makes the fake doer match net/http Transport semantics and avoids leaking the partially-consumed body between attempts.
			// Read only a prefix, then fail like a mid-stream network error.
			_, _ = io.ReadFull(req.Body, make([]byte, 5))
			return nil, io.ErrUnexpectedEOF

go/pkg/basecamp/generated_withbody_replay_test.go:138

  • The success path reads req.Body but doesn't close it. Closing it makes the fake transport behavior consistent with net/http and prevents resource leaks in the test helper.
		attempt2Body, _ = io.ReadAll(req.Body)
		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/basecamp/generated_withbody_replay_test.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b70b85ba8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread go/pkg/basecamp/generated_withbody_replay_test.go
Copilot AI review requested due to automatic review settings July 28, 2026 06:50
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from 5b70b85 to bbea162 Compare July 28, 2026 06:50
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Addressed the bot review:

  • [Codex P2] On a retry the captured replay body is now installed before the request editors run (and re-installed after), so a body-aware editor (Digest/HMAC) signs the bytes that are actually sent — added a red-proofed test.
  • [Copilot/cubic] The fake transports now Close() req.Body after reading, modeling net/http.

9 red proofs + 2 controls green; drift, vet, golangci-lint clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated no new comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bbea1629f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated
Copilot AI review requested due to automatic review settings July 28, 2026 07:20
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from bbea162 to 9892b58 Compare July 28, 2026 07:20
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Addressed the fresh re-review + the outstanding items:

  • [Codex P2 — context cancellation] The no-GetBody bounded buffer is now context-aware (readAllContext); a blocking io.Pipe body returns context.Canceled promptly instead of hanging — red-proofed.
  • [retry-ineligible editor semantics] The retryEligible check now runs before the GetBody normalization, so a single-attempt call streams the editor's req.Body untouched (no rewrite). Added a proof.
  • [Digest/HMAC regression] Rewrote the editor test as the real regression: the editor drains req.Body and writes a SHA-256 header; the transport asserts the header matches the body actually sent on both attempts — pins the before- and after-editor installs (each individually red-proofed).

Honest test taxonomy (13 total): 9 fail against the shipped d5604962 client, 1 regression proof (no-demotion), 3 controls. No API signature changes; git show --check clean, drift/vet/golangci-lint green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9892b58aa4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

go/templates/client.tmpl:347

  • Capture the builder-provided GetBody/ContentLength on attempt 1 (before applyEditors) so we can later restore ContentLength when GetBody remains authoritative and wasn’t replaced by editors.
		req, err := buildRequest()
		if err != nil {
			return nil, err
		}
		req = req.WithContext(ctx)

go/templates/client.tmpl:370

  • When GetBody is authoritative and unchanged by editors, restore ContentLength to the builder value. Otherwise an editor can accidentally set ContentLength for a req.Body replacement that will be ignored (because GetBody still points at the original), causing a ContentLength/body mismatch.
			bodyReplay = replay
			replayContentLength = cl
			if !retriable {

go/templates/client.tmpl:315

  • doWithRetry normalizes attempt 1’s body to req.GetBody() bytes when GetBody is present, but it keeps whatever ContentLength editors set on the request. If an editor updates ContentLength for a req.Body replacement while forgetting to update GetBody, the SDK will ignore the editor body but retain the editor ContentLength, which can produce an invalid request (ContentLength mismatch) even on attempt 1.

Capture the pre-editor GetBody/ContentLength from the builder so ContentLength can be restored when GetBody wasn’t replaced by editors (i.e., we’re still using the builder’s GetBody snapshot).

This issue also appears in the following locations of the same file:

  • line 343
  • line 368
	var bodyReplay func() (io.ReadCloser, error)
	var replayContentLength int64
	retryEligible := isIdempotent && maxAttempts > 1

@jeremy jeremy added the breaking Breaking change to public API label Jul 28, 2026
Copilot AI review requested due to automatic review settings July 28, 2026 07:52
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from 9892b58 to 11401e5 Compare July 28, 2026 07:52
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Addressed the fresh re-review (goroutine leak) and the doc items:

  • [Codex P1 — goroutine leak] Removed pre-buffering of no-GetBody streams entirely (no goroutine, no readAllContext). A body with no GetBody is now an unrewindable single-use stream — sent once, not retried. In-memory readers already carry a GetBody and replay normally; only a genuinely streaming reader forgoes retries. This also removes the whole cancellation/leak surface.
  • PR body refreshed to the accurate taxonomy (12 tests: 8 fail against the shipped d5604962 client, 1 regression proof, 3 controls).

-race clean (3×), drift/vet/golangci-lint green. Labeled breaking to match the repo classifier's treatment of retry-behavior changes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11401e54c7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated
Comment thread go/templates/client.tmpl

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

go/templates/client.tmpl:299

  • The comment inside if !retriable says “large unbuffered body”, but this branch also runs when retries are simply ineligible (e.g., maxAttempts==1). Clarifying the comment helps keep the behavior explanation accurate.
			if !retriable {
				// The body can't be safely replayed (large unbuffered body):
				// send it once and do not retry.
				maxAttempts = 1
			}

go/templates/client.tmpl:262

  • On retries where the captured replay body is empty (initial request body was nil/http.NoBody), installReplay sets req.Body=nil. buildRequest via http.NewRequest uses http.NoBody, so this changes what request editors see on retries and can cause nil dereferences (e.g., editors that read req.Body). Prefer restoring http.NoBody to preserve net/http’s canonical non-nil empty body across attempts.

This issue also appears on line 295 of the same file.

		} else {
			req.Body = nil
			req.GetBody = nil
		}

go/pkg/basecamp/generated_withbody_replay_test.go:350

  • This test comment references maxReplayBodyBytes, but there is no such constant in the Go code (only this comment mentions it). Updating the wording avoids implying a buffering threshold that doesn’t exist in the replay implementation.
// large (> maxReplayBodyBytes) valid body must stay fully replayable — the
// GetBody path never buffers, so the op still retries instead of being demoted
// to a single attempt.
func TestWithBodyReplay_EditorSetsBodyAndGetBodyNoDemotion(t *testing.T) {
	payload := bytes.Repeat([]byte("z"), (1<<20)+512) // > maxReplayBodyBytes

Copilot AI review requested due to automatic review settings July 28, 2026 16:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a161fe36a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated
Copilot AI review requested due to automatic review settings July 28, 2026 17:52
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from a161fe3 to 05637a0 Compare July 28, 2026 17:52
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Addressed both remaining items:

  • [empty replay body → nil] captureReplayBody now distinguishes nil from http.NoBody: a nil body replays as nil, but an explicitly empty body retains a replay returning http.NoBody, so a retry gets a non-nil empty body and a body-aware editor never sees a nil Body. Red-proofed with a 503→200 empty-body proof.
  • [stale terminology] Removed the last maxReplayBodyBytes reference and the "large unbuffered body" wording — there is no size threshold; replaced with "not safely replayable / unrewindable stream" language.

Taxonomy now 15 raw-*WithBody tests (9 fail against the shipped client, 3 regression proofs, 3 controls) + the public-service retry proof + the marshalBody snapshotability test. -race (2×), drift, vet, golangci-lint green.

@github-actions github-actions Bot removed the breaking Breaking change to public API label Jul 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 05637a095f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated
Copilot AI review requested due to automatic review settings July 28, 2026 20:11
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from 05637a0 to fba7edc Compare July 28, 2026 20:11
@jeremy jeremy added the breaking Breaking change to public API label Jul 28, 2026
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Addressed the replay-handle leak:

  • On a retry installReplay now returns the installed handle, and the loop owns the pre-editor replay body (a close-once wrapper): it is installed before the editors run and closed after the editor phase regardless of whether an editor replaced req.Body. A replacement no longer orphans the pre-editor handle; if the editor leaves it in place, the close-once wrapper makes the routine close a no-op.
  • Two new red-proofed tests: successful replacement, and replacement-plus-error (both assert every replay body is closed).

Taxonomy now 17 raw-*WithBody tests (10 fail against the shipped client, 4 regression proofs, 3 controls). -race, drift, vet, golangci-lint green. Reapplied the breaking label (the classifier removed it on synchronize).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fba7edc5ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated
Copilot AI review requested due to automatic review settings July 28, 2026 20:24
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from fba7edc to 248535a Compare July 28, 2026 20:24
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Addressed the framing item, and rebased onto the merged #482/#483:

Taxonomy now 18 raw-*WithBody tests (11 fail against the shipped client, 4 regression proofs, 3 controls). -race, drift, vet, golangci-lint green. Reapplied breaking (labeler stripped it on the rebased head).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 248535a422

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/templates/client.tmpl Outdated
The generated client's doWithRetry rebuilds each attempt's request via
buildRequest(), which recreates it from the caller's single-use io.Reader.
The raw *WithBody(..., body io.Reader) methods therefore shipped an empty
(or, after a partial-read network failure, mid-stream) body on every retry
attempt — the 42 idempotent raw *WithBody ops (Update*/Reposition*/Set*/
Replace*/Toggle*/MarkAsRead) reaching BC3 over PUT/DELETE all retry, so a
transient 429/503/network failure could resend a truncated body.

doWithRetry now decides once how a retry reproduces the first finalized body,
under a documented, net/http-consistent contract that never rewrites req.Body:

  - Attempt 1 always sends req.Body exactly as buildRequest + the request
    editors left it, so an editor that compresses/encrypts/transforms the
    payload (and sets matching headers) is honored — even with no retry.
  - A retry replays via req.GetBody, exactly as net/http replays a body on a
    307/308 redirect — but only when the builder's body survived the editors
    unchanged (checked against the pre-editor reference) and GetBody probes OK.
  - If an editor REPLACED req.Body, the builder's GetBody is stale and the
    finalized bytes cannot be reproduced, so the request is sent once (not
    retried with the wrong body). An in-place mutation without a GetBody update
    is undetectable and, like a redirect, would replay GetBody's snapshot;
    editors should replace the body or keep req.GetBody in sync.
  - No GetBody / non-idempotent -> single attempt; nil and http.NoBody are
    reproduced faithfully (the sentinel is installed unwrapped so retry framing
    stays identical — Content-Length: 0, never chunked).

The SDK's own hand-written idempotent updates (Todos/Cards/CardSteps/Checkins/
Projects/People/Schedules.Update + UpdateAccountLogo) marshal bodies via
marshalBody, which now returns *bytes.Reader (net/http snapshots it into
GetBody) instead of the old rewindableReader, so they keep retrying. On a retry
the replay body is installed before the editors run (so body-aware editors sign
the sent bytes) and owned via a close-once handle that is released after the
editor phase even if an editor replaces it. ContentLength is restored per path.

Fix lives in go/templates/client.tmpl (regenerated) plus helpers.go. Tests: 18
raw-*WithBody replay tests outside pkg/generated (9 fail against the shipped
client — empty/truncated retry body, wrong ContentLength, replaced-body sent
verbatim then not retried, in-place mutation sent on attempt 1, empty-body
framing through a real net/http Transport, leaked/orphaned replay handles,
etc.; the rest regression proofs and controls; each red-proofed); a public-
service retry proof (ProjectsService.Update); and a marshalBody snapshotability
test. Go conformance's naturally-idempotent PUT/DELETE cases pass.

No API signature changes.

Follows the retry-contract program (#456/#460/#461/#476).
Copilot AI review requested due to automatic review settings July 28, 2026 20:52
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from 248535a to 47bbdb5 Compare July 28, 2026 20:52
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@jeremy — heads up on a contract decision (this is a reversal worth your eye):

The earlier review had me make attempt 1 normalize req.Body to GetBody() (for byte-identical consistency across attempts). Codex correctly flagged that this breaks WithRequestEditorFn body transforms — a compress/encrypt editor's body was overwritten by the builder's original before even the first attempt.

I switched to the net/http-consistent contract: attempt 1 sends req.Body as the editors finalized it; a retry replays via req.GetBody (like a 307/308 redirect) only when the body wasn't replaced — a replaced body can't be reproduced, so it's sent once (conservative: no wrong-body retry). Two consequences worth confirming:

  1. A well-behaved editor that replaces both req.Body and req.GetBody also gets a single attempt (I can't verify the new GetBody reproduces the new body). If you'd rather trust a matching editor GetBody and keep retries there, that's a one-line relaxation.
  2. In-place body mutation without a GetBody update is undetectable → the retry replays GetBody's snapshot (net/http-redirect behavior).

Fixed + red-proofed; 18 tests; conformance 93/0; -race/drift/vet/lint green; rebased on the merged #482/#483. Reapplied breaking.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

go/templates/client.tmpl:174

  • The Replay contract comment says the first attempt is “normalized to send GetBody()'s bytes”, but the implementation (and the later captureReplayBody comment) explicitly keeps attempt 1 as req.Body finalized by editors and only uses GetBody for retries. This mismatch is likely to confuse future maintainers about what bytes are actually sent on attempt 1 vs retries.
// GetBody authoritative: this attempt is ALSO normalized to send GetBody()'s
// bytes, so every attempt — first and retries — ships identical bytes. A
// retrying request's body is therefore changed by an editor only through
// req.GetBody (set req.Body and req.GetBody together, as net/http requires).

@jeremy
jeremy merged commit 2bc6b64 into main Jul 28, 2026
48 checks passed
@jeremy
jeremy deleted the fix/go-withbody-replay-body branch July 28, 2026 20:55
@jeremy jeremy mentioned this pull request Jul 29, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API bug Something isn't working go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants