{"repo":"NousResearch/hermes-agent","public_id":"59769","display_label":"Streaming error body read crash in API error handler","summary":"A bug where `_summarize_api_error` crashes with `httpx.ResponseNotRead` when handling streaming errors (such as Gemini free-tier 429s), hiding the real error message. Multiple fix attempts guard the `response.text` access against this unread-response condition.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":61112},"resolution":{"state":"resolved","by":"pr-61112"},"freshness":{"state":"verified","last_verified_at":"2026-07-08T20:31:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":59769,"pr_number":61112,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":59769,"title":"[Bug]: _summarize_api_error crashes with httpx.ResponseNotRead on Gemini streaming errors, hiding the real message (e.g. free-tier quota 429s)","url":null,"state":"closed"},{"kind":"pr","number":59774,"title":"fix(agent): guard response.text access in _summarize_api_error against httpx.ResponseNotRead","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61112","number":61112},{"kind":"pr","value":"#59868","number":59868},{"kind":"pr","value":"#59868","number":59868}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":59791,"title":"fix(agent): handle unread streaming response in _summarize_api_error (#59769)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59868","number":59868},{"kind":"pr","value":"#61112","number":61112},{"kind":"pr","value":"#59868","number":59868}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60192,"title":"fix(agent): guard response.text access in _summarize_api_error against ResponseNotRead (#59769)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59868","number":59868},{"kind":"pr","value":"#61112","number":61112},{"kind":"pr","value":"#59868","number":59868}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":61112,"title":"fix(agent): guard response.text access in _summarize_api_error against httpx.ResponseNotRead","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_summarize_api_error.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -2143,7 +2143,10 @@ def _summarize_api_error(error: Exception) -> str:\n         # widens exposure vs the old empty-body \"HTTP 400\" string).\n         response = getattr(error, \"response\", None)\n         if response is not None:\n-            snippet = (getattr(response, \"text\", None) or \"\").strip()\n+            try:\n+                snippet = (getattr(response, \"text\", None) or \"\").strip()\n+            except Exception:\n+                snippet = \"\"\n             if snippet:\n                 status_code = getattr(error, \"status_code\", None)\n                 prefix = f\"HTTP {status_code}: \" if status_code else \"\""]},{"file":"scripts/release.py","hunks":["@@ -59,6 +59,7 @@\n     \"[redacted:email]\": \"ManniBr\",  # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles)\n     \"[redacted:email]\": \"austinlaw076\",  # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set)\n     \"[redacted:email]\": \"liuhao1024\",  # PR #56993 salvage (gateway: process-level HERMES_HOME for pid/lock/status identity files; #56986)\n+    \"[redacted:email]\": \"gauravsaxena1997\",  # PR #59868 partial salvage (agent: guard response.text against httpx.ResponseNotRead in _summarize_api_error; #59769)\n     \"[redacted:email]\": \"irresi\",  # PR #59048 salvage (gateway: scope reset banners' session info to the serving profile; #59003)\n     \"[redacted:email]\": \"s905060\",  # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169)\n     \"[redacted:email]\": \"l0h1nth\",  # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184)"]},{"file":"tests/run_agent/test_summarize_api_error.py","hunks":["@@ -11,6 +11,9 @@\n \"\"\"\n \n from types import SimpleNamespace\n+from typing import Any\n+\n+import httpx\n \n from run_agent import AIAgent\n ","@@ -54,3 +57,25 @@ def test_empty_body_fallback_redacts_secrets(monkeypatch):\n     summary = AIAgent._summarize_api_error(err)\n     assert \"[redacted:openai-key]\" not in summary\n \n+\n+def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_message():\n+    \"\"\"Unread streaming responses must not replace the real provider error.\"\"\"\n+\n+    class _StreamingError(Exception):\n+        def __init__(self):\n+            super().__init__(\"Gemini HTTP 429: quota exceeded\")\n+            self.status_code = 429\n+            self.response: Any = None\n+\n+    err = _StreamingError()\n+\n+    class _UnreadStreamingResponse:\n+        @property\n+        def text(self):\n+            raise httpx.ResponseNotRead()\n+\n+    err.response = _UnreadStreamingResponse()\n+    summary = AIAgent._summarize_api_error(err)\n+    assert \"HTTP 429\" in summary\n+    assert \"Gemini HTTP 429: quota exceeded\" in summary\n+\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}