Skip to content

fix(relay): set Request.GetBody so the HTTP/2 transport can transparently retry after an upstream stream reset#6249

Open
BlockchainHe wants to merge 2 commits into
QuantumNous:mainfrom
BlockchainHe:fix/upstream-request-getbody
Open

fix(relay): set Request.GetBody so the HTTP/2 transport can transparently retry after an upstream stream reset#6249
BlockchainHe wants to merge 2 commits into
QuantumNous:mainfrom
BlockchainHe:fix/upstream-request-getbody

Conversation

@BlockchainHe

@BlockchainHe BlockchainHe commented Jul 16, 2026

Copy link
Copy Markdown

📝 Description

While relaying to an HTTP/2 upstream, requests started failing with:

http2: Transport: cannot retry err [...] after Request.Body was written; define Request.GetBody to avoid this error

Go's transport can transparently retry these resets (REFUSED_STREAM / graceful GOAWAY), but only when Request.GetBody is set. The relay never sets it — the outbound body is a type-erased reader over BodyStorage — so every such reset surfaces to the caller even though a retry would have succeeded. While fixing this I also noticed DoTaskApiRequest installs a GetBody that re-reads an already-consumed reader: a retry there would silently send an empty body, which is worse than failing.

The fix hands out independent replay readers from BodyStorage (a new NewReader() on both the memory and disk implementations — zero-copy in both modes), carries the replay hook on RelayInfo next to UpstreamRequestBodySize, and injects it only when net/http didn't already derive a correct GetBody. The broken override is removed. Unit tests plus a raw-frame HTTP/2 end-to-end test cover both the transparent-retry path and the empty-body regression.

Full analysis: problem, fix design, and test coverage

Problem

1. Requests on the main relay paths can never be transparently retried by Go's HTTP/2 transport

Per net/http semantics, the HTTP/2 transport can transparently retry a request that fails with a retry-safe error — a stream-level RST_STREAM(REFUSED_STREAM) (RFC 9113 §8.7: the upstream guarantees the stream was not processed; common with proxy/CDN-fronted upstreams under load or during graceful restarts) or a graceful connection-level GOAWAY. However, once the request body has been written, retrying requires Request.GetBody to be non-nil; otherwise the request fails outright:

http2: Transport: cannot retry err [stream error: stream ID 1; REFUSED_STREAM; received from peer] after Request.Body was written; define Request.GetBody to avoid this error

The outbound body built by DoApiRequest is a type-erased io.Reader (common.ReaderOnly(BodyStorage)), and net/http only auto-derives GetBody for *bytes.Reader / *bytes.Buffer / *strings.Reader. So GetBody stays nil on every path going through DoApiRequest — chat / claude / gemini / responses / embedding / image / rerank. This is the other half of the same type-erasure issue that applyUpstreamContentLength already handles for ContentLength.

2. The hand-rolled GetBody in DoTaskApiRequest silently replays an empty body (worse than failing)

req.GetBody = func() (io.ReadCloser, error) {
    return io.NopCloser(requestBody), nil
}

This returns the same, already-consumed reader: if the transport ever retries (or a redirect needs to replay the body), the second attempt sends an empty body. Moreover, most task adaptors pass *bytes.Reader bodies, for which net/http had already derived a correct snapshot-based GetBody — this code overwrote a correct implementation with a broken one.

Fix

BodyStorage retains the full payload for the lifetime of the request (in memory, or in a disk-backed temp file above the configured threshold), so replay support only needed wiring:

  • BodyStorage gains a NewReader() (io.ReadCloser, error) method returning an independent reader per call, as required by the Request.GetBody contract ("a new copy of Body"): the memory implementation returns a fresh bytes.Reader over the same immutable backing array (zero-copy), and the disk implementation opens a fresh file descriptor (zero-copy, kernel page-cache backed). Independent cursors mean replays can never race an in-flight read.
  • NewOutboundJSONBody now also returns getBody func() (io.ReadCloser, error) backed by NewReader(); closing a replayed body never affects the underlying storage (its lifecycle stays owned by the handler's defer closer.Close()), and once the storage is released, GetBody fails with ErrStorageClosed instead of replaying stale data.
  • RelayInfo gains an UpstreamRequestGetBody field, set at the same call sites (8 handlers, 9 call sites) and with the same lifecycle as the existing UpstreamRequestBodySize.
  • A new applyUpstreamGetBody helper (symmetric with applyUpstreamContentLength) wires it into DoApiRequest / DoFormRequest / DoTaskApiRequest, and only injects when req.GetBody == nil — it never overrides a correct auto-derived implementation.
  • The broken override in DoTaskApiRequest is removed: *bytes.Reader / *bytes.Buffer-style bodies fall back to net/http's snapshot-based GetBody, and non-replayable type-erased bodies keep GetBody == nil, so a retry fails loudly instead of silently sending a corrupted (empty) request.

Tests

  • End-to-end HTTP/2 retry test (relay/channel/api_request_getbody_test.go), against a raw-frame HTTP/2 test server that reads the full request body, answers the first attempt with RST_STREAM(REFUSED_STREAM), and serves the retried stream normally with 200:
    • TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset: builds the request the same way DoApiRequest does (type-erased body + both applyUpstream* helpers) and asserts the transport retries transparently and the server receives a byte-identical body on both attempts;
    • TestUpstreamGetBody_HTTP2CannotRetryWithoutGetBody: negative case reproducing the pre-fix cannot retry err ... after Request.Body was written failure.
  • TestNewOutboundJSONBody_GetBodyReadersAreIndependent (+ _DiskStorage): two readers obtained from GetBody are read interleaved and each still yields the complete, correct payload — independent cursors verified for both the memory- and disk-backed storage modes.
  • TestApplyUpstreamGetBody_*: injected GetBody is non-nil and two consecutive calls both return the full body; an existing GetBody is never overridden; without a replay source it stays nil.
  • TestNewOutboundJSONBody_* (relay/common/outbound_body_test.go): after the main body is consumed (or partially read), getBody replays the full body repeatedly; closing a replayed body does not affect the storage lifecycle.
  • TestDoTaskApiRequest_KeepsReplayableGetBody: regression test for problem 2 — after the request completes, req.GetBody still returns the full body repeatedly (it returned an empty body before the fix).
  • go build ./... passes; go vet reports no new findings on the touched packages (identical to the main baseline); go test ./relay/... ./common/... all pass; the two HTTP/2 end-to-end tests run flake-free with -count=20.

🚀 Type of change

  • 🐛 Bug fix - please link the corresponding issue; do not classify design trade-offs, expectation mismatches, or misunderstandings as bugs
  • ✨ New feature - major features should be discussed in an issue first
  • ⚡ Performance / refactor
  • 📝 Documentation

🔗 Related Issue

  • No corresponding issue (I searched existing issues and PRs and found no duplicate report). This is a deterministic defect rather than a design trade-off: the Go transport's own error message says "define Request.GetBody to avoid this error", and this PR ships a reproducible negative test capturing the pre-fix behavior. Happy to open a tracking issue and link it here if the maintainers prefer.

✅ 提交前检查项 / Checklist

  • Human confirmation: I have personally reviewed, organized, and written this description; it is not unprocessed AI output.
  • Not a duplicate: I have searched existing issues and PRs and confirmed this is not a duplicate submission.
  • Bug fix note: if this PR is labeled Bug fix, I have filed or linked a corresponding issue, and I am not classifying design trade-offs, expectation mismatches, or misunderstandings as bugs.
  • Understanding: I understand how these changes work and their potential impact.
  • Focused scope: this PR contains no code changes unrelated to the task at hand.
  • Local verification: I have run and passed the tests (or verified manually) locally, so maintainers can use the results for review.
  • Security & compliance: the code contains no sensitive credentials and follows the project's coding conventions.

📸 Proof of Work

$ go build ./...
(ok, no output)

$ gofmt -l <all files touched by this PR>
(no output — clean)

$ go test ./relay/channel/ -run 'ApplyUpstreamGetBody|DoTaskApiRequest|UpstreamGetBody_HTTP2' -v -count=1
--- PASS: TestDoTaskApiRequest_KeepsReplayableGetBody (0.00s)
--- PASS: TestUpstreamGetBody_HTTP2RetryAfterUpstreamStreamReset (0.00s)
--- PASS: TestUpstreamGetBody_HTTP2CannotRetryWithoutGetBody (0.00s)
--- PASS: TestApplyUpstreamGetBody_SetsReplayableGetBody (0.00s)
--- PASS: TestApplyUpstreamGetBody_NoopWithoutReplaySource (0.00s)
--- PASS: TestApplyUpstreamGetBody_KeepsExistingGetBody (0.00s)
PASS
ok      github.com/QuantumNous/new-api/relay/channel    0.813s

$ go test ./relay/common/ -run 'OutboundJSONBody' -v -count=1
--- PASS: TestNewOutboundJSONBody_GetBodyAfterPartialRead (0.00s)
--- PASS: TestNewOutboundJSONBody_GetBodyReplaysFullBody (0.00s)
PASS
ok      github.com/QuantumNous/new-api/relay/common     1.215s

$ go test ./relay/channel/ -run 'UpstreamGetBody_HTTP2' -count=20   # flake check
ok      github.com/QuantumNous/new-api/relay/channel    0.412s

$ go test ./relay/... ./common/... -count=1
(all 14 packages with tests: ok, no FAIL)

Genuine pre-fix reproduction, captured by running the same e2e scenario without the applyUpstreamGetBody wiring:

Error: Post "http://upstream.test/v1/chat/completions": http2: Transport: cannot retry err
[stream error: stream ID 1; REFUSED_STREAM; received from peer] after Request.Body was written;
define Request.GetBody to avoid this error

Summary by CodeRabbit

  • Bug Fixes

    • Improved upstream request retries by safely replaying request bodies when supported.
    • Prevented retries from sending empty or corrupted payloads after a request body has been consumed.
    • Ensured HTTP/2 stream-reset retries resend the complete request body.
  • Tests

    • Added coverage for request-body replay, existing replay handlers, partial reads, and HTTP/2 retry behavior.

…ntly retry after an upstream stream reset

The outbound request body is a type-erased io.Reader over BodyStorage, so
net/http cannot derive Request.GetBody (it only does so for *bytes.Reader,
*bytes.Buffer and *strings.Reader). With GetBody nil, the HTTP/2 transport
cannot transparently retry a request once the body has been written and the
upstream resets the stream with a retryable error (REFUSED_STREAM, or a
connection-level GOAWAY); the relay request then fails with:

    http2: Transport: cannot retry err [...] after Request.Body was written;
    define Request.GetBody to avoid this error

This affects every relay path that goes through DoApiRequest (chat, claude,
gemini, responses, embedding, image, rerank).

BodyStorage (memory and disk) already implements io.Seeker, so replay support
only needed wiring:

- NewOutboundJSONBody additionally returns a getBody that rewinds the storage
  and hands out a fresh non-closing reader. The transport only calls GetBody
  after the previous attempt's body has been abandoned, so the rewind cannot
  race an in-flight read.
- RelayInfo carries it in the new UpstreamRequestGetBody field, set alongside
  UpstreamRequestBodySize by the handlers that build storage-backed bodies.
- applyUpstreamGetBody (symmetric with applyUpstreamContentLength) wires it
  into DoApiRequest/DoFormRequest/DoTaskApiRequest, only when req.GetBody is
  still nil.

Also remove the hand-rolled GetBody override in DoTaskApiRequest: it returned
the same already-consumed reader, so any transport-level replay would have
silently sent an empty body, and it clobbered the correct snapshot-based
GetBody that net/http derives from the *bytes.Reader bodies the task adaptors
pass in. For non-replayable bodies GetBody now stays nil, so a retry fails
loudly instead of corrupting the request.

Covered by unit tests plus an end-to-end raw-frame HTTP/2 test that resets
the first stream with REFUSED_STREAM after the body is written and asserts
the transport transparently retries with the complete body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Upstream body replay

Layer / File(s) Summary
Replayable body contract
relay/common/outbound_body.go, common/body_storage.go, relay/common/relay_info.go, relay/common/outbound_body_test.go
NewOutboundJSONBody returns a callback that creates independent readers from stored request data, and RelayInfo carries that callback with memory- and disk-backed replay behavior covered by tests.
Handler replay metadata propagation
relay/*_handler.go, relay/chat_completions_via_responses.go
JSON request handlers capture and store UpstreamRequestGetBody alongside the upstream body size.
Channel request retry integration
relay/channel/api_request.go, relay/channel/api_request_getbody_test.go
API, form, and task requests apply available replay callbacks without overwriting existing GetBody values; HTTP/2 tests cover successful retries and missing replay metadata.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: calcium-ion

Poem

A rabbit packed JSON in a satchel so neat,
With a fresh little copy for each retrying feat.
The stream reset—oh dear!—but the body came through,
Replayed from storage, complete and true.
“Hop twice,” said the bunny, “the transport knows what to do!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: wiring Request.GetBody to enable transparent HTTP/2 retries after upstream stream resets.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@relay/common/outbound_body.go`:
- Around line 26-43: Update NewOutboundJSONBody’s getBody closure to create and
return a fresh reader backed by an independent copy of the original body data,
rather than rewinding and reusing storage. Preserve the existing error
propagation and io.ReadCloser return contract so retries and redirects each
receive an independently positioned reader.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6be0eeda-9a45-45a6-8380-8b75e651d239

📥 Commits

Reviewing files that changed from the base of the PR and between a63364d and 04488b0.

📒 Files selected for processing (13)
  • relay/channel/api_request.go
  • relay/channel/api_request_getbody_test.go
  • relay/chat_completions_via_responses.go
  • relay/claude_handler.go
  • relay/common/outbound_body.go
  • relay/common/outbound_body_test.go
  • relay/common/relay_info.go
  • relay/compatible_handler.go
  • relay/embedding_handler.go
  • relay/gemini_handler.go
  • relay/image_handler.go
  • relay/rerank_handler.go
  • relay/responses_handler.go

Comment thread relay/common/outbound_body.go Outdated
Per the http.Request.GetBody contract ("returns a new copy of Body"),
each call must yield a reader with its own cursor. The previous
implementation rewound and reused the shared BodyStorage, so two
consecutive GetBody readers would interfere with each other, and a
replay could disturb the primary body's offset under extreme transport
timing (e.g. attempt N's body write not yet fully abandoned when the
transport builds attempt N+1).

Instead of snapshotting the payload (an extra copy), add
BodyStorage.NewReader, which returns an independent zero-copy reader:

- memory mode: a fresh bytes.Reader over the same immutable backing
  array;
- disk mode: a separate file descriptor over the cache file, so the
  transport closing a replayed body only closes that descriptor.

NewOutboundJSONBody's getBody now simply hands out storage.NewReader,
and once the handler releases the storage, GetBody fails with
ErrStorageClosed instead of replaying stale data.

Tests: interleaved reads across two replay readers and the primary
body each observe exactly their own byte stream, for both the memory
and the disk-backed storage; the existing GetBody and HTTP/2 retry
suites still pass (h2 e2e tests flake-free with -count=20).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
relay/common/outbound_body_test.go (1)

20-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use testify/assert for non-fatal value checks.

As per coding guidelines, Go backend tests must use testify/assert for non-fatal value checks, reserving testify/require strictly for setup and fatal assertions. The require.Equal and require.EqualValues calls throughout this test file act as non-fatal checks for the replay payloads and should be updated. Ensure "github.com/stretchr/testify/assert" is imported if not already present.

  • relay/common/outbound_body_test.go#L20-L26: Change require.EqualValues(t, len(payload), size) and require.Equal(t, payload, first) to use assert.
  • relay/common/outbound_body_test.go#L36-L36: Change require.Equal to assert.Equal.
  • relay/common/outbound_body_test.go#L58-L68: Change require.Equal to assert.Equal on lines 58 and 67.
  • relay/common/outbound_body_test.go#L84-L84: Change require.Equal to assert.Equal.
  • relay/common/outbound_body_test.go#L96-L111: Change require.Equal to assert.Equal on lines 96, 101, 106, and 111.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/common/outbound_body_test.go` around lines 20 - 26, In
relay/common/outbound_body_test.go, replace the non-fatal require.EqualValues
and require.Equal checks throughout the outbound body tests with
assert.EqualValues and assert.Equal, including the checks in lines 20-26, 36,
58, 67, 84, 96, 101, 106, and 111; add the testify/assert import if needed,
while retaining require for setup and fatal assertions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@relay/common/outbound_body_test.go`:
- Around line 20-26: In relay/common/outbound_body_test.go, replace the
non-fatal require.EqualValues and require.Equal checks throughout the outbound
body tests with assert.EqualValues and assert.Equal, including the checks in
lines 20-26, 36, 58, 67, 84, 96, 101, 106, and 111; add the testify/assert
import if needed, while retaining require for setup and fatal assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e0a4352-193b-41c0-b2c7-e1b8eacf3796

📥 Commits

Reviewing files that changed from the base of the PR and between 04488b0 and c5952ee.

📒 Files selected for processing (3)
  • common/body_storage.go
  • relay/common/outbound_body.go
  • relay/common/outbound_body_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • relay/common/outbound_body.go

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