http: surface the server's error body on failed calls and discovery - #102
Conversation
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>
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…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>
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 aClientResponseErrorwhose 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_bodyhelper reads the body on a 4xx/5xx and raises aClientResponseErrorof the same status and headers whose message is<reason>: <detail>.detailis a stringerror/message/detailfield 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 ClientResponseErrorhandlers keep working; they just see a more useful message.Test plan
errorfield showsINVALID_FIELD/value out of range, discoveryerrors[]carries the body for HTTP, SSE and Streamable HTTPpytest 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
raise_for_status_with_bodyreads the body on 4xx/5xx and includes it in the error message, preferringerror/message/detailJSON fields.errorfields 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.ClientResponseErrorhandlers still work.Written for commit badca39. Summary will update on new commits.