-
Notifications
You must be signed in to change notification settings - Fork 38
Add max_event_size to cap SSE event buffering
#1071
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
74895ce
1433d15
b6a7b1e
b77c3ab
0ff6a03
dd8c117
00be306
e660dc6
9c1c0ad
6f31fb0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| DEFAULT_KEEPALIVE_PING_INTERVAL_SECONDS, | ||
| DEFAULT_KEEPALIVE_PING_TIMEOUT_SECONDS, | ||
| DEFAULT_LIMITS, | ||
| DEFAULT_MAX_EVENT_SIZE_BYTES, | ||
| DEFAULT_MAX_MESSAGE_SIZE_BYTES, | ||
| DEFAULT_MAX_REDIRECTS, | ||
| DEFAULT_QUEUE_SIZE, | ||
|
|
@@ -871,12 +872,17 @@ def sse( | |
| follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, | ||
| timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, | ||
| extensions: RequestExtensions | None = None, | ||
| max_event_size: int | None = DEFAULT_MAX_EVENT_SIZE_BYTES, | ||
| ) -> Generator[EventSource]: | ||
| """ | ||
| Connect to a server-sent events endpoint and yield an `EventSource`. | ||
|
|
||
| Iterating the `EventSource` yields `ServerSentEvent` instances. | ||
|
|
||
| `max_event_size` caps the number of bytes buffered for a single event; | ||
| iterating raises `SSEError` if an event exceeds it. Set it to `None` to | ||
| buffer events without a size limit. | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drop this please.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dropped in 6f31fb0 (both sync and async). The parameter stays documented in docs/sse.md. |
||
|
|
||
| **Parameters**: See `httpx2.request`. | ||
| """ | ||
| with self.stream( | ||
|
|
@@ -894,7 +900,7 @@ def sse( | |
| timeout=timeout, | ||
| extensions=extensions, | ||
| ) as response: | ||
| yield EventSource(response) | ||
| yield EventSource(response, max_event_size=max_event_size) | ||
|
|
||
| @contextmanager | ||
| def websocket( | ||
|
|
@@ -1708,12 +1714,17 @@ async def sse( | |
| follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, | ||
| timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, | ||
| extensions: RequestExtensions | None = None, | ||
| max_event_size: int | None = DEFAULT_MAX_EVENT_SIZE_BYTES, | ||
| ) -> AsyncGenerator[EventSource]: | ||
| """ | ||
| Connect to a server-sent events endpoint and yield an `EventSource`. | ||
|
|
||
| Iterating the `EventSource` yields `ServerSentEvent` instances. | ||
|
|
||
| `max_event_size` caps the number of bytes buffered for a single event; | ||
| iterating raises `SSEError` if an event exceeds it. Set it to `None` to | ||
| buffer events without a size limit. | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drop this please.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dropped in 6f31fb0. |
||
|
|
||
| **Parameters**: See `httpx2.request`. | ||
| """ | ||
| async with self.stream( | ||
|
|
@@ -1731,7 +1742,7 @@ async def sse( | |
| timeout=timeout, | ||
| extensions=extensions, | ||
| ) as response: | ||
| yield EventSource(response) | ||
| yield EventSource(response, max_event_size=max_event_size) | ||
|
|
||
| @asynccontextmanager | ||
| async def websocket( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,8 @@ | |
| from collections.abc import AsyncIterator, Iterator | ||
| from dataclasses import dataclass | ||
|
|
||
| from ._exceptions import TransportError | ||
| from ._config import DEFAULT_MAX_EVENT_SIZE_BYTES | ||
| from ._exceptions import TransportError, request_context | ||
| from ._models import Response | ||
|
|
||
| __all__ = ["EventSource", "SSEError", "ServerSentEvent"] | ||
|
|
@@ -34,15 +35,18 @@ def json(self) -> object: | |
|
|
||
|
|
||
| class _SSEDecoder: | ||
| def __init__(self) -> None: | ||
| def __init__(self, max_event_size: int | None = None) -> None: | ||
| self._max_event_size = max_event_size | ||
| self._event = "" | ||
| self._data: list[str] = [] | ||
| self._event_size = 0 | ||
| self._last_event_id = "" | ||
| self._retry: int | None = None | ||
| self._pending = False | ||
|
|
||
| def decode(self, line: str) -> ServerSentEvent | None: | ||
| if not line: | ||
| self._event_size = 0 | ||
| if not self._pending: | ||
| return None | ||
|
|
||
|
|
@@ -61,6 +65,10 @@ def decode(self, line: str) -> ServerSentEvent | None: | |
| if line.startswith(":"): | ||
| return None | ||
|
|
||
| self._event_size += len(line.encode("utf-8")) | ||
|
Kludex marked this conversation as resolved.
|
||
| if self._max_event_size is not None and self._event_size > self._max_event_size: | ||
| raise SSEError(f"Server-sent event exceeded the {self._max_event_size} byte limit.") | ||
|
|
||
| fieldname, _, value = line.partition(":") | ||
| value = value[1:] if value.startswith(" ") else value | ||
|
|
||
|
|
@@ -85,7 +93,8 @@ def decode(self, line: str) -> ServerSentEvent | None: | |
|
|
||
|
|
||
| class _SSELineDecoder: | ||
| def __init__(self) -> None: | ||
| def __init__(self, max_event_size: int | None = None) -> None: | ||
| self._max_event_size = max_event_size | ||
| self._buffer = "" | ||
| self._trailing_cr = False | ||
|
|
||
|
|
@@ -100,6 +109,8 @@ def decode(self, text: str) -> list[str]: | |
| text = self._buffer + text.replace("\r\n", "\n").replace("\r", "\n") | ||
| lines = text.split("\n") | ||
| self._buffer = lines.pop() | ||
| if self._max_event_size is not None and len(self._buffer.encode("utf-8")) > self._max_event_size: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: An event can exceed the cap while its final line is still unterminated: completed fields and Prompt for AI agents |
||
| raise SSEError(f"Server-sent event exceeded the {self._max_event_size} byte limit.") | ||
| return lines | ||
|
|
||
| def flush(self) -> list[str]: | ||
|
|
@@ -114,8 +125,9 @@ def flush(self) -> list[str]: | |
|
|
||
|
|
||
| class EventSource: | ||
| def __init__(self, response: Response) -> None: | ||
| def __init__(self, response: Response, max_event_size: int | None = DEFAULT_MAX_EVENT_SIZE_BYTES) -> None: | ||
| self._response = response | ||
| self._max_event_size = max_event_size | ||
|
|
||
| @property | ||
| def response(self) -> Response: | ||
|
|
@@ -124,35 +136,34 @@ def response(self) -> Response: | |
| def _check_content_type(self) -> None: | ||
| content_type, _, _ = self._response.headers.get("content-type", "").partition(";") | ||
| if content_type.strip().lower() != "text/event-stream": | ||
| raise SSEError( | ||
| f"Expected response with content type 'text/event-stream', got {content_type.strip()!r}.", | ||
| request=self._response.request, | ||
| ) | ||
| raise SSEError(f"Expected response with content type 'text/event-stream', got {content_type.strip()!r}.") | ||
|
|
||
| def __iter__(self) -> Iterator[ServerSentEvent]: | ||
| self._check_content_type() | ||
| decoder = _SSEDecoder() | ||
| lines = _SSELineDecoder() | ||
| for chunk in self._response.iter_text(): | ||
| for line in lines.decode(chunk): | ||
| with request_context(request=self._response.request): | ||
| self._check_content_type() | ||
| decoder = _SSEDecoder(self._max_event_size) | ||
| lines = _SSELineDecoder(self._max_event_size) | ||
| for chunk in self._response.iter_text(): | ||
| for line in lines.decode(chunk): | ||
| sse = decoder.decode(line) | ||
| if sse is not None: | ||
| yield sse | ||
| for line in lines.flush(): | ||
| sse = decoder.decode(line) | ||
| if sse is not None: | ||
| yield sse | ||
| for line in lines.flush(): | ||
| sse = decoder.decode(line) | ||
| if sse is not None: | ||
| yield sse | ||
|
|
||
| async def __aiter__(self) -> AsyncIterator[ServerSentEvent]: | ||
| self._check_content_type() | ||
| decoder = _SSEDecoder() | ||
| lines = _SSELineDecoder() | ||
| async for chunk in self._response.aiter_text(): | ||
| for line in lines.decode(chunk): | ||
| with request_context(request=self._response.request): | ||
| self._check_content_type() | ||
| decoder = _SSEDecoder(self._max_event_size) | ||
| lines = _SSELineDecoder(self._max_event_size) | ||
| async for chunk in self._response.aiter_text(): | ||
| for line in lines.decode(chunk): | ||
| sse = decoder.decode(line) | ||
| if sse is not None: | ||
| yield sse | ||
| for line in lines.flush(): | ||
| sse = decoder.decode(line) | ||
| if sse is not None: | ||
| yield sse | ||
| for line in lines.flush(): | ||
| sse = decoder.decode(line) | ||
| if sse is not None: | ||
| yield sse | ||
Uh oh!
There was an error while loading. Please reload this page.