b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
Build Skills Index / trigger-deploy (push) Waiting to run
CI / Deny unrelated histories (push) Has been skipped
CI / CI timing report (push) Blocked by required conditions
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / All required checks pass (push) Waiting to run
149 lines
5.6 KiB
Python
149 lines
5.6 KiB
Python
"""Bounded reads of HTTP error response bodies.
|
|
|
|
When a provider returns a non-OK status on a *streaming* request, Hermes reads
|
|
the response body to build a useful diagnostic error. A bare ``response.read()``
|
|
on a streaming httpx response is unbounded in two dangerous ways:
|
|
|
|
1. A server can declare (or stream) an arbitrarily large body, so the read can
|
|
balloon memory.
|
|
2. A server can open the body and then stall forever (no ``Content-Length``,
|
|
no further bytes), so the read hangs the agent indefinitely.
|
|
|
|
Both are realistic against a misbehaving proxy, a hijacked endpoint, or a
|
|
provider having a bad day. The diagnostic body is only ever shown to the user
|
|
truncated to a few hundred characters, so reading megabytes — or blocking
|
|
forever — buys nothing.
|
|
|
|
``read_streaming_error_body`` bounds the read to a byte cap and enforces a
|
|
hard wall-clock deadline, returning the decoded text snippet. Callers pass the
|
|
returned text into their existing error builders instead of touching
|
|
``response.text`` (which would be unbounded / would raise after a partial
|
|
stream read).
|
|
|
|
A subtlety the implementation must respect: ``httpx``'s ``iter_bytes()`` blocks
|
|
*inside* the C/socket read while waiting for the next chunk. A wall-clock check
|
|
placed only between yielded chunks cannot interrupt a server that opens the
|
|
body and then stalls mid-chunk — control never returns to Python until httpx's
|
|
own (often 30s+) read timeout fires. To guarantee a bounded stop regardless of
|
|
socket behavior, the read runs on a daemon worker thread and the caller waits
|
|
on it with a hard deadline; on timeout we close the response (which unblocks /
|
|
cancels the read) and return whatever partial bytes were collected.
|
|
|
|
Ported and adapted from openclaw/openclaw#95108 ("bound Anthropic error
|
|
streams"), generalized to cover Hermes's three streaming error-body sites
|
|
(native Gemini, Gemini Cloud Code, Antigravity Cloud Code).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from typing import List, Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Defaults chosen to comfortably hold any real provider error envelope (Google
|
|
# RPC error JSON, Anthropic error JSON) while rejecting pathological bodies.
|
|
DEFAULT_ERROR_BODY_MAX_BYTES = 64 * 1024
|
|
# Hard wall-clock deadline for the whole bounded read. A streaming error body
|
|
# that does not finish within this window is abandoned and the connection is
|
|
# closed; we keep whatever partial bytes arrived.
|
|
DEFAULT_ERROR_BODY_TIMEOUT_S = 10.0
|
|
|
|
|
|
def read_streaming_error_body(
|
|
response: httpx.Response,
|
|
*,
|
|
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
|
|
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
|
|
) -> str:
|
|
"""Read a non-OK streaming response body with a byte cap and a hard deadline.
|
|
|
|
Returns the decoded body text (UTF-8, errors replaced), truncated to
|
|
``max_bytes``. Never raises: any transport error, stall, or oversize
|
|
condition is swallowed and the best-effort partial text (or an empty
|
|
string) is returned, because this runs on the error path and must not
|
|
mask the original HTTP failure with a read error.
|
|
|
|
The byte cap protects against huge bodies; the wall-clock deadline (enforced
|
|
via a worker thread so it can interrupt a socket read that stalls mid-chunk)
|
|
protects against bodies that open and then hang.
|
|
"""
|
|
chunks: List[bytes] = []
|
|
state = {"truncated": False}
|
|
done = threading.Event()
|
|
|
|
def _drain() -> None:
|
|
total = 0
|
|
try:
|
|
for chunk in response.iter_bytes():
|
|
if not chunk:
|
|
continue
|
|
remaining = max_bytes - total
|
|
if remaining <= 0:
|
|
state["truncated"] = True
|
|
break
|
|
if len(chunk) > remaining:
|
|
chunks.append(chunk[:remaining])
|
|
total += remaining
|
|
state["truncated"] = True
|
|
break
|
|
chunks.append(chunk)
|
|
total += len(chunk)
|
|
except Exception as exc: # noqa: BLE001 - error path must not raise
|
|
logger.debug("bounded error-body read failed: %s", exc)
|
|
finally:
|
|
done.set()
|
|
|
|
worker = threading.Thread(
|
|
target=_drain, name="bounded-error-body-read", daemon=True
|
|
)
|
|
worker.start()
|
|
finished = done.wait(timeout=timeout_s)
|
|
|
|
if not finished:
|
|
logger.debug(
|
|
"bounded error-body read: hard timeout after %.1fs (%d bytes so far)",
|
|
timeout_s,
|
|
sum(len(c) for c in chunks),
|
|
)
|
|
# Closing the response cancels the in-flight socket read, letting the
|
|
# worker thread unwind. We do not join (it is a daemon and may be
|
|
# blocked in C); the partial `chunks` collected so far are returned.
|
|
_safe_close(response)
|
|
else:
|
|
_safe_close(response)
|
|
|
|
if state["truncated"]:
|
|
logger.debug(
|
|
"bounded error-body read: capped at %d bytes (max=%d)",
|
|
sum(len(c) for c in chunks),
|
|
max_bytes,
|
|
)
|
|
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
|
|
|
|
def _safe_close(response: httpx.Response) -> None:
|
|
try:
|
|
response.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
def read_error_body_or_default(
|
|
response: httpx.Response,
|
|
*,
|
|
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
|
|
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
|
|
) -> Optional[str]:
|
|
"""Like ``read_streaming_error_body`` but returns ``None`` on empty body.
|
|
|
|
Convenience for callers that distinguish "no body" from "empty string".
|
|
"""
|
|
text = read_streaming_error_body(
|
|
response, max_bytes=max_bytes, timeout_s=timeout_s
|
|
)
|
|
return text or None
|