Skip to content

http: surface the server's error body on failed calls and discovery (lands #26) - #44

Merged
h3xxit merged 7 commits into
devfrom
fix/http-surface-error-body-rebased
Sep 4, 2026
Merged

h3xxit merged 7 commits into
devfrom
fix/http-surface-error-body-rebased

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 4, 2026 •

Copy link
Copy Markdown
Member

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.callTool normalizes a failed call into an Error whose message carries the status and the server's body (a string error / message / detail field when the body is JSON, otherwise the JSON itself), with enumerable status and data fields so the reason survives JSON.stringify out of a sandboxed tool runner. Network and timeout errors pass through unchanged.
  • SSE and Streamable HTTP discovery read the response body before throwing on a non-2xx, so a refused discovery no longer surfaces as just HTTP 403: Forbidden in errors[].

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

Supersedes #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.

  • callTool errors include the status and body in the message and carry enumerable status and data fields, so the reason survives JSON.stringify out of sandboxed tool runners.
  • SSE and Streamable HTTP discovery errors include the response body, truncated to 200 characters, instead of HTTP 403: Forbidden.
  • Object-valued error fields are JSON-stringified rather than shown as [object Object], and a structured error field takes precedence over a generic message field.
  • A blank body falls back to the status text; tool-call messages cap detail at 2000 characters while data keeps the full body.
  • Truncation is an O(cap) code-point walk, so huge bodies are not fully materialized and multi-byte characters on the boundary are never split.
  • Network and timeout errors pass through unchanged.

Written for commit 1bd01e5. Summary will update on new commits.

Review in cubic

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.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 4 files

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

Re-trigger cubic

Comment thread packages/http/src/http_communication_protocol.ts
Comment thread packages/http/src/sse_communication_protocol.ts Outdated
h3xxit and others added 2 commits September 4, 2026 16:03
…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>

@cubic-dev-ai cubic-dev-ai 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.

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

Comment thread packages/http/src/http_communication_protocol.ts Outdated
… 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>

@cubic-dev-ai cubic-dev-ai 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.

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

Comment thread packages/http/src/http_communication_protocol.ts Outdated
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>
@h3xxit
h3xxit merged commit fb3309a into dev Sep 4, 2026
1 check passed
@h3xxit
h3xxit deleted the fix/http-surface-error-body-rebased branch September 4, 2026 14:52
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.

2 participants