Repository navigation
http: surface the server's error body on failed calls and discovery (lands #26) - #44
Merged
Merged
Conversation
On a non-2xx response, axios throws an AxiosError whose `.message` is the
generic "Request failed with status code 403" — the response body, where
servers put the actual reason (e.g. { "error": "..." }), lives on
`error.response.data` and is lost to every caller that only reads `.message`.
It is also dropped entirely when the error crosses a serialization boundary
(e.g. JSON.stringify inside an isolated-vm tool runner like @utcp/code-mode),
because `Error.message` is non-enumerable and AxiosError doesn't serialize its
`response`. The caller is left with just a status code and no explanation.
callTool now normalizes a failed HTTP call into an Error that folds the status
and server body into the message AND attaches enumerable `status` / `data`
fields, so the reason survives both `.message` readers and structured
serialization. Non-HTTP errors (network, timeout) pass through unchanged.
Adds a /forbidden test route (403 + JSON body) and a callTool test asserting
the thrown error carries the body in its message and round-trips through
JSON.stringify with status + data intact.
…iscovery
The same body-swallowing pattern existed in the fetch-based streamable_http and
sse protocols: on a non-2xx during manual discovery (registerManual) they threw
`HTTP ${status}: ${statusText}` without ever reading the response body, so a
server that refuses discovery with a descriptive 403/400 surfaced only as
"HTTP 403: Forbidden" in the returned errors[].
Both now read the body before throwing and fold it into the message (falling
back to statusText when the body is empty). Their callTool paths are stubs
(no HTTP call yet), so discovery is the only real failure surface today.
Adds a GET /forbidden-discovery route (403 + text body) and tests asserting
both protocols' registerManual surfaces the body, not just the status code.
`_normalizeToolError` preferred `data.error` / `data.message` / `data.detail`
unconditionally, but some APIs nest an OBJECT there (e.g.
{ error: { code, reason } }). Using it directly interpolated as
"[object Object]", hiding the real detail. Now only a STRING candidate is
used; a non-string falls through to JSON.stringify(data) so the structure
shows. The raw object is still preserved on the error's `data` field.
Adds a /forbidden-object route (422 + nested object body) and a callTool
test asserting the message contains the structured detail (not
"[object Object]") and `data` holds the original object.
Issue identified by cubic.
This was referenced Sep 4, 2026
Contributor
There was a problem hiding this comment.
All reported issues were addressed across 4 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…ail length Addresses cubic review on #44: - _normalizeToolError used an empty body string as the detail, producing a message ending in a bare colon that was less informative than the raw axios message. A blank body now falls back to the status text, then axios's message. The detail is capped at 2000 characters (the raw body stays intact on `data`). - SSE and Streamable HTTP discovery truncate the error body to 200 characters, matching their streaming paths, so a huge error page does not land in errors[] and logs in full. Tests for the blank body, the truncated call message, and the truncated discovery error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mirrors the fix on python-utcp#102: the first of error / message / detail that is present decides. A non-blank string is the reason; an object falls through to the full JSON so its structure stays visible instead of being skipped in favour of a later generic message field. Null and blank strings are still skipped. Test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
… pair Addresses cubic review on #44. Slicing the detail by UTF-16 unit could split a surrogate pair at the boundary and leave a lone surrogate in the message. All three truncation sites (tool-call error detail, SSE and Streamable HTTP discovery) now truncate by code point. Test with an emoji sitting exactly on the boundary. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Addresses cubic review on #44. Array.from(rawDetail) materialised every code point of a possibly multi-megabyte body before the cap applied. A small truncateByCodePoint helper walks the string with an iterator and stops at the cap, and short strings skip the walk entirely. Used at all three truncation sites. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lands #26 by @edujuan, cherry-picked with authorship preserved, on top of #41 so it applies to the rewritten SSE and Streamable HTTP protocols. Stacked on #41: until #41 merges this diff shows its commits too; after that only the three commits from #26 remain.
What #26 does
HttpCommunicationProtocol.callToolnormalizes a failed call into anErrorwhose message carries the status and the server's body (a stringerror/message/detailfield when the body is JSON, otherwise the JSON itself), with enumerablestatusanddatafields so the reason survivesJSON.stringifyout of a sandboxed tool runner. Network and timeout errors pass through unchanged.HTTP 403: Forbiddeninerrors[].Conflict resolution: only the Streamable HTTP discovery block conflicted with #41's rewrite; the body read was re-applied inside the new structure. The SSE hunk merged cleanly.
Test plan
bun test packages/http: 128 passbun run buildin packages/http succeeds including DTSSupersedes #26. Companion: universal-tool-calling-protocol/python-utcp#102.
🤖 Generated with Claude Code
Summary by cubic
Failed HTTP tool calls and discovery now surface the server's error body instead of a generic status code, so the real reason for a refusal shows up in messages and logs.
callToolerrors include the status and body in the message and carry enumerablestatusanddatafields, so the reason survivesJSON.stringifyout of sandboxed tool runners.HTTP 403: Forbidden.[object Object], and a structurederrorfield takes precedence over a genericmessagefield.datakeeps the full body.Written for commit 1bd01e5. Summary will update on new commits.