Skip to content

Commit 81dedb7

Browse files
committed
raise on upstream brain errors instead of yielding an empty stream
1 parent 297a1d0 commit 81dedb7

4 files changed

Lines changed: 116 additions & 0 deletions

File tree

tests/test_brains_anthropic.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,47 @@ def test_anthropic_in_default_registry():
102102
from zhub.brains import REGISTRY
103103
names = [c.name for c in REGISTRY]
104104
assert "anthropic" in names
105+
106+
107+
class _FakeErrorStream:
108+
def __init__(self, status_code, body):
109+
self.status_code = status_code
110+
self._body = body.encode()
111+
112+
async def aiter_lines(self):
113+
yield self._body.decode()
114+
115+
async def aread(self):
116+
return self._body
117+
118+
async def __aenter__(self):
119+
return self
120+
121+
async def __aexit__(self, *exc):
122+
return None
123+
124+
125+
class _FakeErrorClient:
126+
def __init__(self, status_code, body):
127+
self._status = status_code
128+
self._body = body
129+
130+
def stream(self, method, url, **kw):
131+
return _FakeErrorStream(self._status, self._body)
132+
133+
async def aclose(self):
134+
pass
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_stream_raises_on_upstream_error():
139+
"""A non-2xx body is a JSON error, not Anthropic SSE — the adapter should
140+
raise rather than end the stream silently with no content."""
141+
fake = _FakeErrorClient(529, '{"type":"error","error":{"type":"overloaded_error"}}')
142+
adapter = AnthropicAdapter(api_key="sk-ant-x", model="claude-sonnet-4-5", http=fake)
143+
with pytest.raises(RuntimeError) as ei:
144+
async for _ in adapter.stream([{"role": "user", "content": "hi"}]):
145+
pass
146+
msg = str(ei.value)
147+
assert "529" in msg
148+
assert "overloaded_error" in msg

tests/test_brains_groq.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,48 @@ async def test_stream_ignores_lines_without_data_prefix():
105105
out = [c async for c in adapter.stream([{"role": "user", "content": "hi"}])]
106106
assert [c.delta for c in out if c.delta] == ["ok"]
107107
assert out[-1].done is True
108+
109+
110+
class _FakeErrorStream:
111+
def __init__(self, status_code, body):
112+
self.status_code = status_code
113+
self._body = body.encode()
114+
115+
async def aiter_lines(self):
116+
# an error response is not SSE; emit the raw json body as one line
117+
yield self._body.decode()
118+
119+
async def aread(self):
120+
return self._body
121+
122+
async def __aenter__(self):
123+
return self
124+
125+
async def __aexit__(self, *exc):
126+
return None
127+
128+
129+
class _FakeErrorClient:
130+
def __init__(self, status_code, body):
131+
self._status = status_code
132+
self._body = body
133+
134+
def stream(self, method, url, **kw):
135+
return _FakeErrorStream(self._status, self._body)
136+
137+
async def aclose(self):
138+
pass
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_stream_raises_on_upstream_error():
143+
"""A 429/4xx/5xx body is not SSE — without a status check it would parse
144+
to an empty stream. The adapter should raise so the publisher surfaces it."""
145+
fake = _FakeErrorClient(429, '{"error":{"message":"rate limit exceeded"}}')
146+
adapter = GroqAdapter(api_key="k", model="llama-3.3-70b-versatile", http=fake)
147+
with pytest.raises(RuntimeError) as ei:
148+
async for _ in adapter.stream([{"role": "user", "content": "hi"}]):
149+
pass
150+
msg = str(ei.value)
151+
assert "429" in msg
152+
assert "rate limit exceeded" in msg

zhub/brains/_openai_compat.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ async def stream_openai_compat(
7373
"POST", f"{base_url.rstrip('/')}/chat/completions",
7474
json=body, headers=headers,
7575
) as response:
76+
# An upstream error (429 rate limit, 401 bad key, 5xx) comes back as a
77+
# JSON body that isn't SSE, so the line loop below would skip every
78+
# line and the generator would end yielding nothing — a silent empty
79+
# completion. Catch it here and raise so the publisher surfaces a real
80+
# error to the caller instead. getattr default keeps test doubles that
81+
# don't set status_code working; real httpx responses always have it.
82+
status = getattr(response, "status_code", 200)
83+
if status >= 400:
84+
try:
85+
detail = (await response.aread()).decode("utf-8", "replace").strip()
86+
except Exception:
87+
detail = ""
88+
snippet = f": {detail[:300]}" if detail else ""
89+
raise RuntimeError(f"upstream returned HTTP {status}{snippet}")
7690
async for line in response.aiter_lines():
7791
line = line.rstrip("\r")
7892
if not line.startswith("data:"):

zhub/brains/anthropic.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ async def stream(
9595
async with self._http.stream(
9696
"POST", f"{_BASE_URL}/messages", json=body, headers=headers,
9797
) as response:
98+
# Non-2xx (429 overloaded, 401 bad key, 5xx) returns a JSON error
99+
# body, not SSE, so the loop below would skip every line and end
100+
# yielding nothing. Raise so the publisher surfaces a real error
101+
# instead of a silent empty completion. getattr default keeps test
102+
# doubles working; real httpx responses always have status_code.
103+
status = getattr(response, "status_code", 200)
104+
if status >= 400:
105+
try:
106+
detail = (await response.aread()).decode("utf-8", "replace").strip()
107+
except Exception:
108+
detail = ""
109+
snippet = f": {detail[:300]}" if detail else ""
110+
raise RuntimeError(f"upstream returned HTTP {status}{snippet}")
98111
async for line in response.aiter_lines():
99112
line = line.rstrip("\r")
100113
if not line.startswith("data:"):

0 commit comments

Comments
 (0)