Skip to content
12 changes: 12 additions & 0 deletions docs/sse.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,16 @@ If the response does not have a `text/event-stream` content type, iterating the

`SSEError` is a subclass of [`TransportError`](exceptions.md), so it is also caught by `except httpx2.TransportError`.

## Limiting event size

An event is buffered until its terminating blank line arrives. To stop a stream that keeps sending data without ever completing an event from growing the buffer indefinitely, `client.sse()` caps the bytes buffered for a single event at 1 MiB by default and raises `SSEError` once an event exceeds the limit. Pass `max_event_size` to change the cap:

```pycon
>>> with client.sse("https://example.com/sse", max_event_size=8 * 1024 * 1024) as source:
Comment thread
Kludex marked this conversation as resolved.
... for event in source: # raise the 1 MiB default to 8 MiB
... print(event.data)
```

The counter resets after each event, so the limit applies per event rather than to the stream as a whole. Set `max_event_size=None` to buffer events without any limit.

[mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
15 changes: 13 additions & 2 deletions src/httpx2/httpx2/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop this please.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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(
Expand All @@ -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(
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop this please.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped in 6f31fb0.


**Parameters**: See `httpx2.request`.
"""
async with self.stream(
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/httpx2/httpx2/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ def __repr__(self) -> str:
DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20)
DEFAULT_MAX_REDIRECTS = 20

DEFAULT_MAX_EVENT_SIZE_BYTES = 1024 * 1024

DEFAULT_MAX_MESSAGE_SIZE_BYTES = 65_536
DEFAULT_QUEUE_SIZE = 512
DEFAULT_KEEPALIVE_PING_INTERVAL_SECONDS = 20.0
Expand Down
63 changes: 37 additions & 26 deletions src/httpx2/httpx2/_sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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

Expand All @@ -61,6 +65,10 @@ def decode(self, line: str) -> ServerSentEvent | None:
if line.startswith(":"):
return None

self._event_size += len(line.encode("utf-8"))
Comment thread
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

Expand All @@ -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

Expand All @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 _buffer are checked independently rather than as parts of the same event. Account for their combined size after processing each chunk, while resetting/partitioning at blank lines so an adjacent event's partial line is not charged to its predecessor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_sse.py, line 112:

<comment>An event can exceed the cap while its final line is still unterminated: completed fields and `_buffer` are checked independently rather than as parts of the same event. Account for their combined size after processing each chunk, while resetting/partitioning at blank lines so an adjacent event's partial line is not charged to its predecessor.</comment>

<file context>
@@ -105,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:
+            raise SSEError(f"Server-sent event exceeded the {self._max_event_size} byte limit.")
         return lines
</file context>

raise SSEError(f"Server-sent event exceeded the {self._max_event_size} byte limit.")
return lines

def flush(self) -> list[str]:
Expand All @@ -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:
Expand All @@ -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
Loading
Loading