Skip to content

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

Open
jeremy wants to merge 1 commit into
mainfrom
fix/go-withbody-replay-body
Open

Go: replay *WithBody request bodies across retries#481
jeremy wants to merge 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 snapshots the first finalized request's body once (after buildRequest + applyEditors) and replays it, under a documented, net/http-consistent contract:

  • Retry-ineligible (single attempt) → stream the finalized body untouched (no normalization). Checked first, so a non-retrying call never rewrites an editor's req.Body.
  • GetBody present (retry-eligible)GetBody is authoritative. This attempt is normalized to send GetBody()'s bytes too, so attempt 1 and every retry are byte-identical even if an editor mutated req.Body out of sync with GetBody (including an in-place strings.Reader.Reset). A large body with a valid GetBody stays fully replayable.
  • No GetBody → an unrewindable, single-use stream: not buffered or retried. Buffering would risk unbounded memory, and a blocking reader cannot be interrupted (there is no general way to abort an io.Reader), so the request is sent exactly once.

Because retry keys off GetBody, the SDK's own serialized bodies must be snapshotable. marshalBody (and the multipart account-logo path) now return *bytes.Reader instead of the old custom rewindableReader (which had no GetBody), so the eight hand-written idempotent update methods (Todos/Cards/CardSteps/Checkins/Projects/People/Schedules.Update, etc.) and UpdateAccountLogo keep retrying on transient failures. rewindableReader is removed. Only an external caller passing a genuinely streaming, non-snapshotable reader to a raw *WithBody method forgoes retries — the safe behavior for an unrewindable stream.

On a retry the captured body is installed before the editors run (and re-installed after), so a body-aware editor (Digest/HMAC) signs the bytes actually sent, and an editor-installed retry body is discarded and closed. A GetBody snapshot failure disables retries (single attempt with the valid body) rather than failing an otherwise-sendable request, and an installed replay body is closed if a retry editor errors. ContentLength is restored per replay path. The fix lives in go/templates/client.tmpl (regenerated).

Tests

Outside pkg/generated per the repo rule — 18 tests, measured against the shipped d5604962 client:

  • 11 proofs that fail against the shipped client: empty/truncated retry body, no-GetBody stream retried with a broken body, partial-read byte-0 restart, wrong retry ContentLength, editor-body inconsistency (replace + in-place Reset), retry-editor body discarded+closed, leaked replay body on editor error, orphaned pre-editor replay body when a retry editor replaces req.Body (successful and replace-then-error paths), the Digest/HMAC regression; and an empty-body transfer-framing proof through a real net/http Transport (a wrapped http.NoBody would switch the retry to chunked encoding — a fake doer can't observe framing).
  • 4 regression proofs (pass on the shipped client): a large body with a valid GetBody must not be demoted; a GetBody snapshot failure must not fail the request; an explicitly empty body (http.NoBody) must be replayed as a non-nil empty body; and a body-aware editor observes the replay body on a retry.
  • 3 controls: happy path; retry-ineligible streams nothing; retry-ineligible honors the editor body.

Plus a public-service retry proof (ProjectsService.Update retries a 503 and replays the full serialized body — red-proofed against a non-snapshotable body) and a marshalBody snapshotability test. Each proof is individually red-proofed. Go conformance's naturally-idempotent PUT/DELETE cases pass; -race, drift, go vet, golangci-lint all green.

Behavior note

For retry-eligible *WithBody ops, attempt 1 now sends GetBody()'s bytes (identical for correct callers). All public typed methods retry (their bodies are *bytes.Reader); only an external caller passing a non-snapshotable streaming reader to a raw *WithBody method is sent once. A request editor changes a retrying request's body only via req.GetBody; an explicitly empty body (http.NoBody) stays a non-nil empty reader across retries. 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

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 (1)

go/templates/client.tmpl:267

  • In installReplay, when there is no captured body to replay (bodyReplay == nil), the code sets req.Body = nil. For client requests, net/http typically uses http.NoBody to represent an empty body; leaving Body nil can break request editors or transports that assume req.Body is non-nil (and is inconsistent with the net/http contract this change set is aiming to follow). Set req.Body to http.NoBody instead of nil for the no-body replay case.
		} else {
			req.Body = nil
			req.GetBody = nil
		}

Copilot AI review requested due to automatic review settings July 28, 2026 16:24
@jeremy
jeremy force-pushed the fix/go-withbody-replay-body branch from a721f23 to a161fe3 Compare July 28, 2026 16:24
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

⚠️ Potential breaking changes detected:

  • The rewindableReader type was removed, which was previously an exported type.
  • The use of rewindableReader in the UpdateLogo method of AccountService was replaced with bytes.NewReader, changing how request bodies are handled for retries.
  • The function marshalBody no longer returns a rewindableReader. Instead, it now returns a *bytes.Reader. This change modifies the type of the returned object, which may impact existing usage.

Review carefully before merging. Consider a major version bump.

@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Fixed the naturally-idempotent PUT/DELETE regression (was failing Go Tests + Conformance).

Root cause: the new no-buffer policy keys retry off GetBody, but the SDK's own serialized bodies came from marshalBody, which returned a custom rewindableReader with no GetBody — so the eight hand-written idempotent update methods (and UpdateAccountLogo) were demoted to a single attempt.

Fix: marshalBody and the account-logo path now return *bytes.Reader (net/http snapshots it into GetBody), so those bodies replay and keep retrying; rewindableReader is removed. Added a public-service retry proof (ProjectsService.Update retries a 503 and replays the full body, red-proofed against a non-snapshotable body) and updated the marshalBody test to assert snapshotability. Also corrected the PR body's scope statement.

make conformance-go → 92 passed / 0 failed (PUT/DELETE naturally-idempotent green); full basecamp suite, -race, drift, wrapper-drift, vet, golangci-lint all green.

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: 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
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 snapshots the first finalized request's body once (after
buildRequest + applyEditors) and replays THAT body on every later attempt,
under a documented, net/http-consistent contract:

  - Retry-ineligible (single attempt) -> stream the finalized body untouched.
  - GetBody present (retry-eligible) -> GetBody is AUTHORITATIVE. This attempt
    is normalized to send GetBody()'s bytes too, so attempt 1 and every retry
    ship byte-identical content even if an editor mutated req.Body out of sync
    with GetBody (including an in-place strings.Reader.Reset). A large body with
    a valid GetBody stays fully replayable.
  - No GetBody -> an unrewindable, single-use stream: NOT buffered or retried.
    Buffering would risk unbounded memory, and a blocking reader cannot be
    interrupted (no general way to abort an io.Reader), so it is sent once.

Because that policy keys retry off GetBody, the SDK's own serialized bodies
must be snapshotable. marshalBody (and the multipart account-logo path) now
return *bytes.Reader instead of the old custom rewindableReader (which had no
GetBody), so the eight hand-written idempotent update methods
(Todos/Cards/CardSteps/Checkins/Projects/People/Schedules.Update, etc.) — and
UpdateAccountLogo — replay their bodies and keep retrying on transient
failures. rewindableReader is removed. Only an external caller passing a
genuinely streaming, non-snapshotable reader to a raw *WithBody method forgoes
retries, which is the safe behavior for an unrewindable stream.

On a retry the captured body is installed BEFORE the editors run (and
re-installed after), so a body-aware editor (Digest/HMAC) signs the bytes that
are actually sent, and an editor-installed retry body is discarded and closed.
A GetBody snapshot failure disables retries (single attempt with the valid
body) rather than failing an otherwise-sendable request; an installed replay
body is closed if a retry editor errors; and an explicitly empty body
(http.NoBody) is replayed as a non-nil empty body, not collapsed to nil. ContentLength is restored per
replay path.

Fix lives in go/templates/client.tmpl (regenerated) plus helpers.go's
marshalBody. Tests: 18 raw-*WithBody replay tests outside pkg/generated (11 fail against
the shipped d560496 client, 4 regression proofs, 3 controls, each red-proofed),
including an empty-body framing proof through a real net/http Transport; a PUBLIC-service retry proof (ProjectsService.Update retries a
503 and replays the full body — red-proofed against a non-snapshotable body);
and a marshalBody snapshotability test. Go conformance's naturally-idempotent
PUT/DELETE cases pass.

No API signature changes. A request editor changes a retrying request's body
only via req.GetBody.

Follows the retry-contract program (#456/#460/#461/#476).
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
Comment on lines +218 to +221
if req.Body != nil {
_ = req.Body.Close()
}
req.Body = body

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor bodies installed by request editors

For an idempotent request using the default retry configuration, a request editor that replaces req.Body without also replacing the builder-created req.GetBody now has its body closed and silently overwritten before even the first attempt. This breaks the documented ability of WithRequestEditorFn to mutate a request: editors that compress, encrypt, or otherwise transform the payload can send the original bytes with headers describing the transformed bytes, even when no retry occurs. Preserve the editor-finalized body for the initial attempt and disable replay when it cannot be reproduced instead of treating a potentially stale GetBody as authoritative.

Useful? React with 👍 / 👎.

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