mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Port from openclaw/openclaw#95108: an unbounded response.read() on a non-OK *streaming* response can balloon memory (huge body) or hang the agent forever (body opens then stalls with no further bytes). The diagnostic body is only ever shown truncated, so reading megabytes or blocking indefinitely buys nothing. Add agent/bounded_response.read_streaming_error_body() which caps the read at a byte limit and enforces a hard wall-clock deadline (run on a worker thread so it can interrupt a socket read that stalls mid-chunk, which a between-chunk wall-clock check cannot). Wire it into all three streaming error-body sites that previously did a bare response.read(): native Gemini, Gemini Cloud Code, and Antigravity Cloud Code. The existing error builders now accept an optional pre-read body_text so classification (status code, RESOURCE_EXHAUSTED, free-tier guidance, Retry-After) is preserved unchanged. Tests use a real in-process socket server (no mocks): oversize body is capped, stalled body hits the deadline with partial text preserved, normal error envelope reads intact and parses.
148 lines
5.6 KiB
Python
148 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
|