Skip to content

fix(server-tools): replay spent upstream bodies as real error statuses - #149

Closed
orangeboyChen wants to merge 4 commits into
mainfrom
fix/upstream-error-body-already-used
Closed

orangeboyChen wants to merge 4 commits into
mainfrom
fix/upstream-error-body-already-used

Conversation

@orangeboyChen

Copy link
Copy Markdown
Owner

Problem

An upstream rate limit surfaced to clients as status_code=500, Body already used instead of the real 429:

[CodeBuddy2API] Upstream request failed {
  route: "/v1/chat/completions",
  status: 429,
  detail: "{"code":6004,"msg":"您的使用量已超出频率限制..."}"
}

Cause

The route: "/v1/chat/completions" in that log is fetchChatCompletion's internal label, so the 429 came from inside the server-tool loop, not a plain chat request.

executeWebSearchLoop read the upstream body to check whether the model asked for a search, then returned that same Response object. A Response body can only be consumed once, so the route layer's second read — the one that builds the answer the client sees — threw Body is unusable: Body has already been read. The 500 replaced the real status.

Verified against the unpatched tree: HTTP 500 {"type":"error","error":{"type":"api_error","message":"Body is unusable: Body has already been read"}}.

Fix

  • web-search-loop.ts — clone before reading in both loop iterations, and rebuild the response from the drained body on failure, so both reads work. The body is replayed verbatim rather than re-serialized, so an error detail that isn't valid JSON still reaches the client intact. content-length/content-encoding are dropped since the bytes are re-emitted, not re-encoded.
  • anthropic.ts — the streaming bridge emitted a generic api_error at HTTP 200, hiding the upstream status from clients that key retry decisions on rate_limit_error. It now maps the upstream status through the same status→type table the non-streaming path already used (extracted as anthropicErrorType).

Result

path before after
Anthropic non-stream 500 api_error 429 rate_limit_error + upstream detail
Anthropic stream 200 api_error 200 + rate_limit_error + upstream detail
Chat completions 500 Body already used 429 + upstream detail

The streaming HTTP status stays 200 because the Anthropic SSE envelope is already committed by the time the failure arrives — the error type now carries the information instead.

Verification

3 new regression tests in tests/server/upstream-error-response.test.ts; all 3 fail on the unpatched tree and pass with the fix.

  • bun run lint / format:check / typecheck / build — pass
  • bun run test:ci — 667 tests pass, 94.46% statements
  • bun run test:patch-branches — 92.80% (≥ 90% required)

🤖 Generated with Claude Code

orangeboyChen and others added 4 commits September 17, 2026 12:05
A turn that ran a server tool lost everything the model wrote between
searches, and delivered its final answer only after it was complete.

The execution loop fed each iteration's message back into the transcript
but never emitted it, so a multi-hop turn surfaced only the tool calls
and the last answer. Anthropic returns the text between searches as
content blocks of the same turn, and there is no other point at which
that text can reach the client.

The final iteration was also requested buffered, which held the whole
answer back before replaying it in fixed-size slices. It now streams,
the same way the first iteration already did: text is forwarded as it
arrives and only tool-call frames are held, since a server tool still
has to be answered locally.

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

Three defects in the streamed search loop:

Streamed iterations had their text emitted twice — once by the probe as
it forwarded deltas, and again from the accumulated content when the
turn continued. The synthesized emission now applies only to buffered
iterations, whose payload never reached the client.

The probe's reader was never registered with the stream's cancellation
path, so a client disconnect could not interrupt a read already parked
on a stalled upstream; the loop only noticed once upstream produced
another chunk. The reader is now handed to the caller, which cancels it.

A non-streaming turn mixing a local web tool with a client-owned tool
folded the current iteration's text in twice: the mixed payload already
carries it, and the accumulated turns included it too. Only completed
iterations are accumulated now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An upstream error response was read twice on the server-tool path. The
loop consumed the body to check whether the model asked for a search,
then handed the same Response back; the route layer read it again to
build the client's answer and threw "Body already used", so a rate limit
surfaced as a 500 instead of its real status.

Clone before reading in both loop iterations and rebuild the response
from the drained body on failure, preserving non-JSON error details.

The Anthropic streaming bridge also emitted a generic api_error at HTTP
200, hiding the upstream status from clients that key their retry
decisions on rate_limit_error. It now maps the upstream status through
the same status-to-type table the non-streaming path uses.

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

Copy link
Copy Markdown
Owner Author

Superseded by #150, which is based directly on main instead of carrying the 3 commits from fix/server-tool-loop-streaming.

@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: 4f15c2c494

ℹ️ 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 on lines 1292 to +1295
if (nextRemainingCalls.length) {
finalPayload = buildMixedTurnPayload({
message,
payload,
payload: {

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 Avoid replaying already-emitted mixed-turn text

When a follow-up iteration contains both a locally executable tool and a client-owned tool, its text and reasoning have already been sent by probeServerToolStream for SSE responses, or explicitly emitted at lines 1237–1257 for buffered responses. Building finalPayload from the same message and then passing it to synthesizeChatCompletionStream emits that content a second time, so clients receive duplicated prose/reasoning. Defer the earlier emission or synthesize only the findings and outstanding tool calls in this branch.

Useful? React with 👍 / 👎.

Comment on lines +1214 to +1216
if (cancelled) return;
activeReader = null;
usage = sumUsage(usage, context.usage);

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 Reset per-response usage before summing streamed iterations

If one streamed iteration reports usage and a later iteration omits it while requesting another local tool, context.usage still contains the prior iteration's value because probeServerToolStream only overwrites it when a chunk supplies usage. Summing that retained value here counts the earlier request again, and the inflated total is later emitted when the loop produces a synthesized mixed or fallback response. Reset the context usage before each probe or sum the current probe's independently initialized usage.

Useful? React with 👍 / 👎.

Comment on lines +1646 to 1651
message: withIntermediateTurns({
payload,
reasonings: intermediateReasonings,
texts: intermediateTexts,
}).choices?.[0]?.message,
payload,

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 Preserve accumulated reasoning in mixed non-streaming turns

For a multi-hop non-streaming turn that ends with both a server-tool call and a client-owned call, withIntermediateTurns adds earlier reasoning to the temporary message, but buildMixedTurnPayload only reads that argument's content; its output message is copied from the separate, original payload. Consequently earlier intermediateReasonings are discarded even though earlier text is retained. Pass the folded payload itself or copy the folded reasoning into the returned message.

Useful? React with 👍 / 👎.

Comment on lines 1551 to +1555
if (!response.ok || payload.error) {
return { body: loopBody, executions, response };
return {
body: loopBody,
executions,
response: await buildServerToolFailureResponse(response),

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 Give payload-level failures a non-success status

When upstream returns HTTP 200 with a JSON error payload, this branch recognizes a failure but rebuilds it with the unchanged 200 status. In proxyChatCompletions, a streaming caller therefore treats the response as successful and feeds it to synthesizeChatCompletionStream; because an error payload has no choices, the client receives an empty successful stream instead of the upstream error. Convert payload-level failures to a non-2xx status or ensure they bypass success-stream synthesis.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.43379% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.33%. Comparing base (50860b8) to head (4f15c2c).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #149      +/-   ##
==========================================
- Coverage   95.38%   95.33%   -0.05%     
==========================================
  Files          36       36              
  Lines        6239     6349     +110     
  Branches     1778     1812      +34     
==========================================
+ Hits         5951     6053     +102     
- Misses        288      296       +8     
Flag Coverage Δ
unittests 95.33% <95.43%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@orangeboyChen
orangeboyChen deleted the fix/upstream-error-body-already-used branch September 17, 2026 12:42
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