Skip to content

Commit a571396

Browse files
Raise StreamDisconnectedError on SSE error frames (#596)
* feat(workflows): raise StreamDisconnectedError on SSE error frames Adds an AfterSuccess hook that converts a workflow SSE `event: error` frame into a raised StreamDisconnectedError (reason + error), so consumers use try/except around stream iteration instead of inspecting each event. * refactor(workflows): move StreamDisconnectedError into extra/exceptions.py * test: narrow hook result to Response before iterating (pyright) * refactor(workflows): move stream error hook into _hooks and cover logs streams - Move WorkflowStreamErrorHook to client/_hooks alongside the other hooks - Cover deployment + execution logs SSE streams (same error-frame contract) - Derive valid reasons from the StreamDisconnectReason Literal - Add tests: error frame without trailing boundary; all stream operations * refactor: rename to STREAM_OPERATIONS_WITH_ERROR_EVENT for clarity * refactor: drop redundant content-encoding strip; test hook composition - Error hook forwards the raw stream unchanged, so it must keep Content-Encoding (httpx decodes downstream); removes duplication with workflow_encoding_hook - Add test that the encoding hook + stream error hook compose correctly * fix: parse error frames with data split across multiple SSE data lines Use json.loads(strict=False) so a value spanning several data: lines (rejoined with a literal newline per the SSE spec) still yields reason/error instead of silently falling back.
1 parent c94f595 commit a571396

4 files changed

Lines changed: 467 additions & 1 deletion

File tree

src/mistralai/client/_hooks/registration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .traceparent import TraceparentInjectionHook
44
from .tracing import TracingHook
55
from .types import Hooks
6+
from .stream_error_hook import WorkflowStreamErrorHook
67
from .workflow_encoding_hook import WorkflowEncodingHook
78

89
# This file is only ever generated once on the first generation and then is free to be modified.
@@ -26,3 +27,4 @@ def init_hooks(hooks: Hooks):
2627
hooks.register_after_error_hook(tracing_hook)
2728
hooks.register_before_request_hook(workflow_encoding_hook)
2829
hooks.register_after_success_hook(workflow_encoding_hook)
30+
hooks.register_after_success_hook(WorkflowStreamErrorHook())
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import json
2+
import re
3+
from typing import Any, AsyncIterator, Dict, Iterator, Optional, Tuple, Union, get_args
4+
5+
import httpx
6+
from httpx._types import AsyncByteStream, SyncByteStream
7+
8+
from .types import AfterSuccessContext, AfterSuccessHook
9+
from mistralai.extra.exceptions import (
10+
StreamDisconnectReason,
11+
StreamDisconnectedError,
12+
)
13+
14+
# Operation IDs of the SSE-backed workflow stream endpoints that can emit a
15+
# terminal ``event: error`` frame (event, execution, and logs streams).
16+
STREAM_OPERATIONS_WITH_ERROR_EVENT = {
17+
"get_stream_events_v1_workflows_events_stream_get",
18+
"stream_v1_workflows_executions__execution_id__stream_get",
19+
"stream_deployment_logs",
20+
"stream_workflow_execution_logs",
21+
}
22+
23+
_ERROR_EVENT = "error"
24+
_VALID_REASONS = get_args(StreamDisconnectReason)
25+
_DEFAULT_REASON: StreamDisconnectReason = "stream_error"
26+
27+
# SSE frame boundaries (blank line), longest first so the full separator is consumed.
28+
_BOUNDARIES = [
29+
b"\r\n\r\n",
30+
b"\r\n\r",
31+
b"\r\n\n",
32+
b"\r\r\n",
33+
b"\n\r\n",
34+
b"\r\r",
35+
b"\n\r",
36+
b"\n\n",
37+
]
38+
39+
40+
def _find_boundary(buffer: bytearray) -> Optional[Tuple[int, int]]:
41+
"""Return (index, length) of the earliest frame boundary, or None if incomplete."""
42+
best: Optional[Tuple[int, int]] = None
43+
for boundary in _BOUNDARIES:
44+
idx = buffer.find(boundary)
45+
if idx == -1:
46+
continue
47+
if (
48+
best is None
49+
or idx < best[0]
50+
or (idx == best[0] and len(boundary) > best[1])
51+
):
52+
best = (idx, len(boundary))
53+
return best
54+
55+
56+
def _parse_error_payload(data: str) -> Tuple[str, StreamDisconnectReason]:
57+
payload: Dict[str, Any] = {}
58+
try:
59+
# strict=False: SSE joins multi-line data with "\n", so a value spanning
60+
# several data: lines contains literal newlines that strict JSON rejects.
61+
parsed = json.loads(data.strip(), strict=False)
62+
if isinstance(parsed, dict):
63+
payload = parsed
64+
except json.JSONDecodeError:
65+
pass
66+
error = str(payload.get("error", data.strip()))
67+
reason = payload.get("reason", _DEFAULT_REASON)
68+
if reason not in _VALID_REASONS:
69+
reason = _DEFAULT_REASON
70+
return error, reason
71+
72+
73+
def _raise_if_error_frame(block: bytes) -> None:
74+
"""Raise StreamDisconnectedError if the SSE frame is an ``event: error`` frame."""
75+
event_name: Optional[str] = None
76+
data = ""
77+
for line in re.split(r"\r?\n|\r", block.decode("utf-8", errors="replace")):
78+
if not line or line.startswith(":"):
79+
continue
80+
field, _, value = line.partition(":")
81+
if value.startswith(" "):
82+
value = value[1:]
83+
if field == "event":
84+
event_name = value
85+
elif field == "data":
86+
data += value + "\n"
87+
88+
if event_name != _ERROR_EVENT:
89+
return
90+
91+
error, reason = _parse_error_payload(data)
92+
raise StreamDisconnectedError(reason=reason, error=error)
93+
94+
95+
class _FrameScanner:
96+
"""Buffers raw SSE bytes, raising on error frames and passing others through."""
97+
98+
def __init__(self) -> None:
99+
self._buffer = bytearray()
100+
101+
def feed(self, chunk: bytes) -> Iterator[bytes]:
102+
self._buffer += chunk
103+
while True:
104+
found = _find_boundary(self._buffer)
105+
if found is None:
106+
return
107+
idx, length = found
108+
block = bytes(self._buffer[:idx])
109+
frame = bytes(self._buffer[: idx + length])
110+
del self._buffer[: idx + length]
111+
_raise_if_error_frame(block)
112+
yield frame
113+
114+
def flush(self) -> Iterator[bytes]:
115+
if not self._buffer:
116+
return
117+
block = bytes(self._buffer)
118+
self._buffer.clear()
119+
_raise_if_error_frame(block)
120+
yield block
121+
122+
123+
class _ErrorDetectingSyncByteStream(SyncByteStream):
124+
def __init__(self, original: SyncByteStream) -> None:
125+
self._original = original
126+
self._scanner = _FrameScanner()
127+
128+
def __iter__(self) -> Iterator[bytes]:
129+
for chunk in self._original:
130+
yield from self._scanner.feed(chunk)
131+
yield from self._scanner.flush()
132+
133+
def close(self) -> None:
134+
self._original.close()
135+
136+
137+
class _ErrorDetectingAsyncByteStream(AsyncByteStream):
138+
def __init__(self, original: AsyncByteStream) -> None:
139+
self._original = original
140+
self._scanner = _FrameScanner()
141+
142+
async def __aiter__(self) -> AsyncIterator[bytes]:
143+
async for chunk in self._original:
144+
for frame in self._scanner.feed(chunk):
145+
yield frame
146+
for frame in self._scanner.flush():
147+
yield frame
148+
149+
async def aclose(self) -> None:
150+
await self._original.aclose()
151+
152+
153+
class WorkflowStreamErrorHook(AfterSuccessHook):
154+
"""Raise StreamDisconnectedError when a workflow SSE stream sends an error frame.
155+
156+
Wraps the response byte stream for the workflow SSE operations so that an
157+
``event: error`` frame raises during iteration, terminating the consumer's
158+
``for event in stream`` loop instead of yielding the error as a normal event.
159+
"""
160+
161+
def after_success(
162+
self,
163+
hook_ctx: AfterSuccessContext,
164+
response: httpx.Response,
165+
) -> Union[httpx.Response, Exception]:
166+
if hook_ctx.operation_id not in STREAM_OPERATIONS_WITH_ERROR_EVENT:
167+
return response
168+
if "text/event-stream" not in response.headers.get("content-type", ""):
169+
return response
170+
171+
stream = response.stream
172+
wrapped: Union[SyncByteStream, AsyncByteStream]
173+
if isinstance(stream, AsyncByteStream):
174+
wrapped = _ErrorDetectingAsyncByteStream(stream)
175+
elif isinstance(stream, SyncByteStream):
176+
wrapped = _ErrorDetectingSyncByteStream(stream)
177+
else:
178+
return response
179+
180+
# Keep the original headers: this hook forwards the raw stream unchanged,
181+
# so httpx still applies any Content-Encoding when the consumer iterates.
182+
return httpx.Response(
183+
status_code=response.status_code,
184+
headers=response.headers,
185+
stream=wrapped,
186+
request=response.request,
187+
extensions=response.extensions,
188+
)

src/mistralai/extra/exceptions.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44
from enum import Enum
5-
from typing import Any, Optional, Union, TYPE_CHECKING
5+
from typing import Any, Literal, Optional, Union, TYPE_CHECKING
66
import typing
77

88
from mistralai.client.models import (
@@ -32,6 +32,25 @@ class WorkflowPayloadCompressionException(MistralClientException):
3232
"""Workflow payload compression exception"""
3333

3434

35+
StreamDisconnectReason = Literal["read_error", "stream_error", "internal_error"]
36+
37+
38+
class StreamDisconnectedError(MistralClientException):
39+
"""Raised when a workflow SSE stream is terminated by a server error frame.
40+
41+
The server ends a stream by emitting an ``event: error`` SSE frame. The SDK
42+
surfaces this as a raised exception so consumers can wrap stream iteration in
43+
``try`` / ``except`` instead of inspecting each event for ``event == "error"``.
44+
45+
Both attributes are populated from the frame's ``data`` JSON payload.
46+
"""
47+
48+
def __init__(self, *, reason: StreamDisconnectReason, error: str) -> None:
49+
self.reason: StreamDisconnectReason = reason
50+
self.error = error
51+
super().__init__("Workflow stream disconnected by server")
52+
53+
3554
class RunException(MistralClientException):
3655
"""Conversation run errors."""
3756

0 commit comments

Comments
 (0)