hermes-agent/tests/run_agent/test_summarize_api_error.py
gauravsaxena1997 0569a637d0 fix(agent): guard response.text access in _summarize_api_error against httpx.ResponseNotRead
When an API error carries an httpx.Response whose body was consumed via
iter_bytes() during streaming error handling (e.g. GeminiAPIError from
agent/gemini_native_adapter.py), accessing .text raises
httpx.ResponseNotRead. The secondary exception replaced the real,
already-computed provider error (429 free-tier quota guidance) with the
generic 'Attempted to access streaming response content' message on
every turn.

Guard the .text access so it degrades to an empty snippet and falls
through to the str(error) fallback, which carries the full original
message. Mirrors the existing guards in
agent/error_classifier.py::_extract_error_body() and
agent/gemini_native_adapter.py::gemini_http_error().

Fixes #59769

Salvaged from PR #59868 (guard + regression test); the unrelated
desktop Ctrl-C fix bundled in that PR was intentionally dropped and is
triaged separately.
2026-07-09 02:01:56 +05:30

82 lines
3.3 KiB
Python

"""Regression: empty-body HTTP 4xx errors must still surface a real provider message.
Reported on Windows (#36109): an LLM API call returned HTTP 400 with an *empty*
parsed SDK ``body`` ({}), so ``_summarize_api_error`` fell through to the bare
``str(error)`` path and the user saw only "HTTP 400" with no provider detail.
The SDK leaves ``body`` empty in this case, but the underlying httpx
``response`` still carries the real payload in ``.text``. These tests lock the
contract: when ``body`` is empty, fall back to ``response.text`` (parsing a JSON
``error.message`` / ``message`` when present) so logs and CLI show the real
provider error. This is a diagnostic improvement and is platform-agnostic.
"""
from types import SimpleNamespace
from typing import Any
import httpx
from run_agent import AIAgent
def _make_empty_body_error(response_text: str, status_code: int = 400) -> Exception:
"""Mimic an OpenAI-SDK error whose parsed body is empty but whose httpx
response still holds the payload text."""
err = Exception("") # str(error) is empty/uninformative on this path
err.status_code = status_code
err.body = {} # empty dict — the #36109 trigger
err.response = SimpleNamespace(text=response_text)
return err
def test_empty_body_falls_back_to_response_json_error_message():
"""A JSON payload with error.message is surfaced (not a bare HTTP 400)."""
err = _make_empty_body_error(
'{"error": {"message": "model `foo` does not exist", "type": "invalid_request_error"}}'
)
summary = AIAgent._summarize_api_error(err)
assert "HTTP 400" in summary
assert "model `foo` does not exist" in summary
def test_empty_body_falls_back_to_raw_response_text_when_not_json():
"""A non-JSON response body is surfaced verbatim (truncated), not dropped."""
err = _make_empty_body_error("upstream connect error or disconnect/reset before headers")
summary = AIAgent._summarize_api_error(err)
assert "HTTP 400" in summary
assert "upstream connect error" in summary
def test_empty_body_fallback_redacts_secrets(monkeypatch):
"""The surfaced provider/proxy error body must pass through the secret
redactor — a proxy echoing an API key in the error must not leak it into
final_response/logs (the empty-body path previously hid it as bare HTTP 400)."""
monkeypatch.setenv("HERMES_REDACT_SECRETS", "true")
err = _make_empty_body_error(
'{"error": {"message": "bad key: sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef"}}'
)
summary = AIAgent._summarize_api_error(err)
assert "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" not in summary
def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_message():
"""Unread streaming responses must not replace the real provider error."""
class _StreamingError(Exception):
def __init__(self):
super().__init__("Gemini HTTP 429: quota exceeded")
self.status_code = 429
self.response: Any = None
err = _StreamingError()
class _UnreadStreamingResponse:
@property
def text(self):
raise httpx.ResponseNotRead()
err.response = _UnreadStreamingResponse()
summary = AIAgent._summarize_api_error(err)
assert "HTTP 429" in summary
assert "Gemini HTTP 429: quota exceeded" in summary