Skip to content

http: surface the server's error body on failed calls and discovery - #102

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

http: surface the server's error body on failed calls and discovery#102
h3xxit merged 3 commits into
devfrom
fix/http-surface-error-body

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary

Python counterpart of universal-tool-calling-protocol/typescript-utcp#44 (which lands typescript-utcp#26), so both SDKs surface the server's error body in the same release.

raise_for_status() raises a ClientResponseError whose message is only the reason phrase (Forbidden). Servers put the real reason in the body, typically {"error": "..."}, and that was discarded, so a refused call or discovery surfaced as nothing more than a status code.

Change: a new raise_for_status_with_body helper reads the body on a 4xx/5xx and raises a ClientResponseError of the same status and headers whose message is <reason>: <detail>. detail is a string error / message / detail field when the body is a JSON object (an object-valued field falls through to the raw JSON so its structure shows, matching the TypeScript behavior), otherwise the raw body, truncated to 2000 characters. The raw text is attached as .body. Used by the HTTP protocol's tool calls and discovery, and by SSE and Streamable HTTP discovery.

The exception type is unchanged, so existing except ClientResponseError handlers keep working; they just see a more useful message.

Test plan

  • New tests: body in the tool-call error, object-valued error field shows INVALID_FIELD / value out of range, discovery errors[] carries the body for HTTP, SSE and Streamable HTTP
  • pytest plugins/communication_protocols/http/tests: 213 passed

🤖 Generated with Claude Code


Summary by cubic

Surfaces the server's error body on failed HTTP calls and discovery so errors include the real reason from the response instead of just the status code.

Bug Fixes

  • New raise_for_status_with_body reads the body on 4xx/5xx and includes it in the error message, preferring error/message/detail JSON fields.
  • Structured error fields are shown as raw JSON instead of falling back to a generic string; body reads are capped at 64 KiB and fall back to UTF-8 when the charset is missing or unknown.
  • Used for tool calls and discovery in HTTP, SSE, and Streamable HTTP protocols; existing ClientResponseError handlers still work.

Written for commit badca39. Summary will update on new commits.

Review in cubic

Python counterpart of typescript-utcp #26 / #44.

raise_for_status() raises a ClientResponseError whose message is only
the reason phrase ("Forbidden"); the body, where servers put the real
reason ({"error": "..."}), was discarded, so a refused call or discovery
surfaced as nothing more than a status code.

New utcp_http._errors.raise_for_status_with_body reads the body on a
4xx/5xx and raises a ClientResponseError of the same status and headers
whose message is "<reason>: <detail>", where detail is a string error /
message / detail field when the body is JSON, otherwise the raw body
(truncated). The raw text is attached as .body. Used by the HTTP
protocol's tool calls and discovery and by SSE and Streamable HTTP
discovery. The exception type is unchanged, so existing handlers keep
working.

Tests: body in the call error, object-valued error field shows its
structure, and discovery errors[] carries the body for all three
protocols.

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.

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py">

<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py:214">
P2: `raise_for_status_with_body` buffers the whole error body with `await response.text()` before `error_detail_from_body` truncates the message. The 2000-char cap (MAX_DETAIL_CHARS) only bounds the folded message, not the memory used to read it, so a provider (including an attacker-controlled discovery endpoint, the same trust surface handled elsewhere in this file) returning an arbitrarily large 4xx/5xx body causes an unbounded in-memory buffer on every failed call. Previously `response.raise_for_status()` never touched the body. Read the body incrementally against the cap (e.g. `response.content.iter_chunked`) and stop once MAX_DETAIL_CHARS bytes are collected.</violation>
</file>

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

Re-trigger cubic

Comment thread plugins/communication_protocols/http/src/utcp_http/_errors.py Outdated
auth_header_names=auth_header_names,
) as response:
response.raise_for_status() # Raise exception for 4XX/5XX responses
await raise_for_status_with_body(response) # 4XX/5XX, with the server's body in the message

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.

P2: raise_for_status_with_body buffers the whole error body with await response.text() before error_detail_from_body truncates the message. The 2000-char cap (MAX_DETAIL_CHARS) only bounds the folded message, not the memory used to read it, so a provider (including an attacker-controlled discovery endpoint, the same trust surface handled elsewhere in this file) returning an arbitrarily large 4xx/5xx body causes an unbounded in-memory buffer on every failed call. Previously response.raise_for_status() never touched the body. Read the body incrementally against the cap (e.g. response.content.iter_chunked) and stop once MAX_DETAIL_CHARS bytes are collected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py, line 214:

<comment>`raise_for_status_with_body` buffers the whole error body with `await response.text()` before `error_detail_from_body` truncates the message. The 2000-char cap (MAX_DETAIL_CHARS) only bounds the folded message, not the memory used to read it, so a provider (including an attacker-controlled discovery endpoint, the same trust surface handled elsewhere in this file) returning an arbitrarily large 4xx/5xx body causes an unbounded in-memory buffer on every failed call. Previously `response.raise_for_status()` never touched the body. Read the body incrementally against the cap (e.g. `response.content.iter_chunked`) and stop once MAX_DETAIL_CHARS bytes are collected.</comment>

<file context>
@@ -210,7 +211,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
                         auth_header_names=auth_header_names,
                     ) as response:
-                        response.raise_for_status()  # Raise exception for 4XX/5XX responses
+                        await raise_for_status_with_body(response)  # 4XX/5XX, with the server's body in the message
 
                         # Check content type to determine how to parse the response
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, fixed: the body is now read with iter_chunked and the read stops at MAX_BODY_READ_BYTES (64 KiB), decoded leniently with the response charset, so the cap bounds memory and not just the message. Test added with a 1 MiB error body asserting both the read and the message stay bounded.

…ounded

Addresses cubic review on #102:

- error_detail_from_body no longer skips an object-valued `error` to
  reach a lower-priority generic string: the first of error / message /
  detail that is present decides, and a structured value returns the
  raw JSON so its shape stays visible. Null and blank strings are still
  skipped.
- The error body is read incrementally and capped at MAX_BODY_READ_BYTES
  (64 KiB) instead of buffered in full and truncated afterwards, so an
  arbitrarily large 4xx/5xx body from an untrusted endpoint cannot grow
  memory unbounded. Decoding is lenient and honours the response charset.

Tests for precedence, null/blank skipping, and a 1 MiB error body.

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 2 files (changes from recent commits).

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

Re-trigger cubic

Comment thread plugins/communication_protocols/http/src/utcp_http/_errors.py Outdated
Comment thread plugins/communication_protocols/http/tests/test_http_communication_protocol.py Outdated
…d detail

- Decoding a bounded error body now falls back to UTF-8 when the response
  declares an unknown charset (LookupError) or the lookup fails for any
  other reason, so the detail is never lost. Tests for a body without a
  Content-Type and one with an unknown charset.
- The precedence test asserts on the parsed detail instead of slicing
  the message string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@h3xxit
h3xxit merged commit 8f0f679 into dev Sep 4, 2026
10 checks passed
@h3xxit
h3xxit deleted the fix/http-surface-error-body 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.

1 participant