Files

3.9 KiB

Web streaming: the client contract

What a client must implement to consume SSE, NDJSON or application/json-seq from this platform.

Three outcomes, not two

After the first byte the HTTP status is 200 and cannot change. So the status tells you nothing about whether the stream succeeded, and a closed connection is ambiguous. There are three endings:

  1. A Complete envelope. The stream finished. lastSequence is the final position.
  2. A Failure envelope. Something failed after commit. Carries a ProblemCode and a client-safe message. The status is still 200.
  3. Neither. The connection closed mid-stream. The server records this as ABRUPT_CLOSE; from the client's side it is indistinguishable from a completed stream unless the client is looking for the terminal envelope.

A client that treats a closed connection as completion will silently truncate data. No server change can fix that for it. Looking for the terminal envelope is the contract.

Positions

Every Item carries a sequence, counting from 1. Positions are strictly increasing, and the server enforces it — a repeat or a regression is a server-side error, not something a client has to tolerate.

Zero is never a valid position. A stream whose first item claimed 0 and one whose sequence was never set would look identical.

Resuming

Where a route supports it, send the last position you applied as Last-Event-ID.

  • If the source still holds it, delivery continues from the next position.
  • If it does not, the response is a resnapshot-required error, not a partial stream. Resuming from the oldest retained position would give you contiguous positions with a hole in the middle, and nothing in the data would say so.

A resnapshot means re-reading the resource from its normal endpoint and starting a fresh stream. Clients that cannot do that cheaply should not use the resume path.

Framing

Format Media type Framing Truncation behaviour
SSE text/event-stream data: lines, blank-line separated Partial event at the end
NDJSON application/x-ndjson One JSON value, then \n The truncated line has no newline, and the delimiter is what was lost
JSON-seq application/json-seq 0x1E, value, \n The next 0x1E unambiguously starts the next record

Prefer JSON-seq where truncation matters. Its separator comes first, so a parser resynchronises at the next record rather than trying to parse the truncation joined to what follows. For a long-lived stream, truncation is the normal way it ends.

Records never contain a raw newline. The server refuses to write one, because for NDJSON the consumer's line split is the only record boundary there is.

Heartbeats and timeouts

The server writes a keepalive every heartbeatInterval. A client that sees nothing for longer than that should assume the connection is dead — a silent connection and a disconnected one are the same thing at the socket, and the heartbeat is what separates them.

Streams are closed at idleTimeout (nothing produced) and at maxStreamAge (regardless of activity). Both are normal endings, and both send a terminal envelope where the connection permits.

Slow consumers

The server buffers at most maxBufferedItems for a consumer that is behind. Past that it closes the connection rather than growing the buffer. A client that cannot keep up should reconnect with a resume cursor, not expect the server to hold its backlog.

Shutdown

During a rolling deploy, open streams receive a reconnect request before the node stops accepting. Reconnect promptly — the node force-closes anything still open at its drain deadline, and that arrives as an abrupt close.

Do not reconnect immediately on an abrupt close without backoff and jitter. If a node's streams are all cut at once, every client reconnecting at once is what keeps the replacement down.