Server-Sent Events (SSE)¶
sse() opens a GET request with Accept: text/event-stream and always yields an SSEEvent,
as the server sends them. It's an async generator on HTTPClient and a plain generator on
SyncHTTPClient; to release the connection as soon as you stop reading, close it (aclosing/
closing, or .aclose()/.close()), as streaming shows.
@dataclass(slots=True, kw_only=True)
class SSEEvent[TData]:
id: str = ""
event: str = "message"
data: TData
SSEEvent is generic in TData — response_data_type controls what .data becomes (raw
str, or decoded into a typed object). .event and .id are always plain str, never None,
with the same defaults a browser's EventSource uses: an event with no event: field is type
"message", and a stream that has sent no id: gives "".
Raw events (default)¶
If you don't pass response_data_type, .data is exactly the text the server sent in the
event's data: line(s) (multiple data: lines get joined with \n) — untouched, no JSON
parsing, no model construction, just a plain str:
The id field¶
.id is the text of the server's id: field, or "" when there isn't one (the spec makes it
optional, and most real servers, OpenAI/Anthropic-style streams included, never send it). To use
it as a number or a UUID, convert it yourself: int(event.id).
It follows the spec's last event ID buffer semantics, not "this record had an id: line":
once the server has sent an id:, every later event inherits it until the server sends a new
one, and an explicit empty id: line resets it to "". The same buffer is what goes out as
Last-Event-ID on a reconnect (below), which is omitted while it's empty.
Decoding events¶
Pass response_data_type to decode .data into a typed object instead of leaving it as str —
.event/.id come along for the ride unchanged. Same decode targets as everywhere else:
Discriminated-union event streams¶
response_data_type also accepts a pydantic TypeAdapter or a prebuilt msgspec Decoder, so a
stream mixing different event shapes decodes natively:
from msgspec.json import Decoder
item_event_decoder = Decoder(ItemCreated | ItemDeleted)
async for event in client.sse("events", response_data_type=item_event_decoder):
match event.data:
case ItemCreated():
...
case ItemDeleted():
...
msgspec dispatches on a tagged union by default — give ItemCreated/ItemDeleted a
distinguishing tag/tag_field (see the
msgspec docs on tagged unions)
so Decoder knows which one to build.
from pydantic import TypeAdapter
item_event_adapter = TypeAdapter(ItemCreated | ItemDeleted)
async for event in client.sse("events", response_data_type=item_event_adapter):
match event.data:
case ItemCreated():
...
case ItemDeleted():
...
pydantic can also dispatch on an explicit discriminator field instead of trying each member in turn — see the pydantic docs on discriminated unions.
Timeouts¶
The client's timeout does not apply to sse(). It's a total-request timeout — connect
through end of body — and an SSE body is open-ended, so applying it would kill every healthy
stream on schedule (with the default timeout=30.0, at the 30 second mark, as HTTPTimeoutError).
sse() overrides it per request instead. Two knobs remain:
read_timeouton the client — the maximum idle gap between two consecutive chunks. This is the right way to detect a stalled stream: a server that stops sending (without closing) trips it, a server that keeps sending never does, however long the stream lives. Client-level, likeconnect_timeout— see Cookies, redirects, proxy & TLS.timeout=on thesse()call itself — bounds one connection attempt, not the whole stream. Rarely what you want;read_timeoutusually is.
Either one firing is a transport error, which the reconnect logic below handles like any other dropped connection.
Reconnecting — max_reconnects, reconnect_delay, reconnect_on_close¶
A dropped connection (a transport error mid-stream, or a timeout) reconnects automatically, following the WHATWG EventSource processing model — the caller's loop never sees the seam:
- The client waits
reconnect_delayseconds (default3.0, the browser default) before reconnecting. If the server has sent aretry:field (milliseconds), that value replaces the caller's for the rest of the stream — the server knows its own restart time better than you do. - The reconnect request carries a
Last-Event-IDheader with the last id seen, so a spec-compliant server resumes from the right place instead of replaying (or skipping) events. max_reconnects(default5) caps consecutive reconnects that yield no event — the "server is down" case. Every event received resets the count, so a flaky server that drops the connection every few minutes is reconnected to indefinitely, while a dead one fails aftermax_reconnectsattempts and the transport error is raised.Nonemeans unlimited (browser behavior);0disables reconnecting entirely and raises on the first drop.- A clean close ends the stream —
sse()returns and the loop finishes — unlessreconnect_on_close=True, in which case a clean close reconnects too (again, browser behavior; a server that always closes after a burst of events is reconnected to for as long as you keep iterating). The samemax_reconnectsbudget applies. - A 204 response always ends the stream, regardless of
reconnect_on_close— it's the spec's explicit "stop, and don't reconnect" signal.
async for event in client.sse(
"events",
max_reconnects=None, # keep reconnecting for as long as we iterate
reconnect_on_close=True, # even when the server closes cleanly
):
...
Records without a data: line dispatch no event, but their id: and retry: fields still take
effect — a server can prime the reconnect delay or the last-event-id before sending anything.
Errors¶
error_for_status (default True) is checked once per connection, before its first event is
yielded — a 4xx/5xx response raises HTTPResponseError immediately rather than partway through
iteration, and (per spec) is never reconnected. A 2xx response whose Content-Type isn't
text/event-stream raises ValueError — it isn't a stream, and parsing it as one would just
silently yield nothing. Once streaming has started, a dropped connection reconnects as described
above; only once the reconnect budget is exhausted does the usual HTTPTransportError family
propagate. See Error handling.
Ctrl-C-interruptible streaming (sync only) — interruptible¶
On SyncHTTPClient only, a blocking wait for the next event is dead to Ctrl-C: CPython only
converts SIGINT into KeyboardInterrupt on the main thread while it's executing Python
bytecode, and the wait between SSE events happens inside a blocking Rust call with no bytecode
running at all. HTTPClient.sse() (async) doesn't have this problem — the event loop's
selector is already signal-interruptible — so interruptible only exists on the sync client.
Pass interruptible=True to fix this: the read loop runs on a daemon worker thread instead,
and the calling thread only ever does short, signal-interruptible waits on a queue, so Ctrl-C
fires within roughly 0.2 seconds instead of never:
for event in sync_client.sse("events", interruptible=True):
... # Ctrl-C now works while waiting for the next event
Abandoning an interruptible stream before EOF always leaks a thread and a socket
There is no cancellation path — dropping or exiting a streamed response does not cancel
an in-flight read on the worker thread, it blocks until that read resolves one way or
another. So abandoning an interruptible stream before it reaches EOF — an early break, or
the generator getting garbage-collected — always leaves the worker thread and its open
socket parked until the peer closes the connection or a timeout fires; process exit is what
actually reclaims it.
Ctrl-C itself is rarely the exposure here: a long-lived process has no controlling terminal
to receive it from, and when it does, SIGINT there is usually aimed at killing the whole
process anyway, which reclaims the leak along with everything else. The real risk is code
that repeatedly breaks out of an interruptible stream early while the process stays up —
pair that with a short timeout/connect_timeout so each leaked connection is bounded,
rather than assuming avoiding Ctrl-C is the mitigation.
The same flag, with the same behavior and the same caveat, is also available on
stream_get/stream_post.