Testing SSE Reconnect Logic After a Dropped Connection
10 min read · updated August 11, 2026
A dropped stream is not one problem. Whether the right behaviour is to resume, to restart, or to give up depends on what the endpoint offers, and a client that guesses wrong either duplicates tokens or bills you twice for the same completion.
Three reconnect situations
- Plain server-sent events with ids. The SSE protocol has resumption built in. A server that emits an
id:field on each event causes a conforming client to remember it, and on reconnect the client sends it back in theLast-Event-IDrequest header so the server can continue from that point. Theretry:field lets the server suggest a reconnection delay in milliseconds. If you control the server — your own API in front of a model — this is the mechanism to implement, and the test is that the header goes out with the right value. - A provider stream with explicit resume. OpenAI’s background mode for the Responses API supports this: create the response with both
backgroundandstreamset to true, track thesequence_numbercarried on each event, and if the connection drops, reconnect and pass the last one you saw as thestarting_afterquery parameter. The documentation is explicit that you can only start a new stream from a background response if it was created with streaming enabled, and that the data is held for roughly ten minutes. See OpenAI’s background mode guide. - No resume at all. The ordinary synchronous streaming call is this case. If the socket dies, the generation is gone. Your only options are to reissue the whole request — paying again, and getting different text — or to fail. Pretending otherwise is where the duplicate-token bugs come from.
Write down which of the three each of your streaming call sites is in. Half the reconnect bugs in this area are a client implementing behaviour from the first situation against an endpoint in the third.
The invariant to assert
Do not assert on the sequence of events, and do not assert on the text’s content. Assert the equivalence:
// Given the same deterministic upstream, the assembled result must not // depend on whether the transport was interrupted. expect(assembledWithDrop).toEqual(assembledWithoutDrop);
That single assertion catches every duplication and every gap, because both change the assembled string. Around it, three narrower assertions are worth having explicitly, since each has a distinct cause:
- No duplicated span. The classic failure is resuming from the last completed event when the drop happened mid-event, replaying a fragment you already appended. Assert on a fixture whose deltas are distinguishable — not repeated words — so a duplicate is visible.
- Exactly one terminal event reaches the consumer. A reconnect that replays the stream’s end can fire your completion callback twice, which in an application that saves the result on completion writes two records.
- Bounded attempts. A reconnect loop with no ceiling against an endpoint that is refusing connections is an outage amplifier. Assert the attempt count and that the delay grows.
Faking a drop mid-event
The important detail is that a realistic drop happens in the middle of a line, not neatly between events. A test that only ever cuts on an event boundary will not exercise the buffer-handling code where the bugs live. Build the fake server so the cut point is a parameter, run the test at several cut points including inside the JSON of a delta and immediately after a data: prefix, and use a small local HTTP server rather than a mock at the SDK level — the point is to exercise the real parser against a real truncated byte stream.
import { createServer } from "node:http";
// Serves a fixed SSE script, then destroys the socket after N bytes.
export function sseServerThatDropsAfter(bytes: number, script: string) {
return createServer((req, res) => {
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
const resumeFrom = req.headers["last-event-id"];
const body = resumeFrom ? script.slice(Number(resumeFrom)) : script;
if (resumeFrom) {
res.end(body); // second connection: serve the remainder
return;
}
res.write(body.slice(0, bytes));
res.socket?.destroy(); // no end event, no close frame: a real drop
});
}Destroying the socket matters. Calling res.end() is a clean close, and most clients treat a clean close as the end of the stream rather than as a failure — so a test built that way asserts nothing about reconnection. The distinction between “the stream ended” and “the stream stopped” is exactly what the client has to get right.
Sequence numbers and resume
For an endpoint with explicit resume, the client state to test is a single number: the last sequence value it successfully processed. Two assertions cover it. First, that the number advances only after the event has been applied to the assembled output, not when it is received — otherwise a crash between the two loses an event forever. Second, that the reconnect request actually carries it; a mock that captures the outgoing query string and asserts starting_after equals the expected value is the cheapest possible test and it catches the case where the parameter is silently dropped by a request builder.
Also test the expiry path. If the resumable window has passed, the reconnect will not succeed, and the client needs a defined behaviour for that rather than a loop. Assert it surfaces a typed error the caller can act on.
When resume is impossible
For the third situation the honest design is not to reconnect. Two things are worth testing instead. One: partial output is surfaced to the caller as partial, with a flag, rather than returned as if it were complete — the worst outcome here is a truncated answer that nothing marks as truncated, which then gets stored, summarised and quoted. Two: if you do reissue, the reissue is idempotent from the user’s point of view, meaning it does not double-charge an internal budget and does not re-run whatever side effect the first attempt already performed.
If reconnect matters enough to build properly, the usual answer is to move generation off the request path entirely: run the job asynchronously, persist deltas as they arrive, and let the client poll or subscribe to the persisted record. That converts an unresumable stream into a resumable read, and the tests become ordinary database tests. The trade is latency and complexity, so it is worth doing only for long generations — for short ones, restarting is genuinely cheaper than the machinery. See how the transports differ for what each option costs.