How-to › Make calls that survive failure

How to retry a streaming response without replaying received data#

Resume a stream that dies mid-body from the last event id or byte offset, stop on an explicit end, and refuse to retry a stream that carries no ids.

Audience
Agent builder
Level
advanced
Topic
Set retries and timeouts
Languages
TypeScript
Verified

Read first: Retry fetch calls with exponential backoff in Node.js

An export streams 10,000 rows to your agent and the connection drops at row 3,400. The retry asks for the stream from the start, so rows 1 to 3,400 are processed twice. A deploy ends the response cleanly partway through, and one client library takes the clean close as the end of the data and returns what it has.

What you get

You will end up with a loop that resumes an SSE stream from the last event id and a download from the last byte, and stops on an explicit end. This is for you if your code reads a stream that can be cut.

Short answer

Track the last event id or byte offset your code has handled, and send it on the retry. Use Last-Event-ID for Server-Sent Events, and Range with If-Range for a download, accepting a 206 only at the offset you asked for. Stop on an explicit end event or a 204, never on a clean close. A stream that carries no ids cannot be resumed, so restart it on purpose or give up.

You will need

Node 22.12 or later, which eventsource 5 requires, and an SSE or chunked endpoint to call. Verified 2026-10-15 against Node 22.22.0, eventsource 5.1.2, @microsoft/fetch-event-source 2.0.1, and eventsource-parser 4.1.1. Resuming only works when the server can start from a point: the SSE standard defines Last-Event-ID, and the server decides what to do with it. The backoff between attempts is the subject of retrying fetch with exponential backoff, linked below.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
@microsoft/fetch-event-sourceBrowser code that needs headers or a POST body and an onerror that decidesA clean close resolves the promise, so a graceful server shutdown ends the read earlyThe stream has to be read to its end across deploys
eventsource package reconnectA spec-shaped client, and a server that honors Last-Event-ID and answers 204 when doneIt reconnects after every close, a finished stream included, stops for good on any status but 200, and has no retry budget to setThe server cannot resume, or you must stop after repeated failures
hand-rolled fetch loop with Last-Event-ID or RangeYou need a budget, an explicit end, a duplicate guard, or byte-range downloadsAbout a hundred lines to own, and a test for every way a stream can endA library already matches your server’s end-of-stream contract

The two libraries disagree about what a clean close means. eventsource follows the standard and reconnects, which is right after a deploy and wrong after the server has finished, unless the server answers the reconnect with 204. fetch-event-source treats a clean close as the end, so a deploy cuts the read short unless your onclose throws, as its README shows. The loop decides on an explicit end event, and it costs you the code.

Measure how each client ends a cut stream

The sample server sends ten events and fails five ways. A crash drops the socket every three events, and a deploy ends the response cleanly every three. A finished stream with no end event replays from the start on reconnect, one crash ignores Last-Event-ID, and the last answers the first reconnect with 503 and Retry-After: 1.

node compare.mjs
client              events  dupes  requests  how it ended
-- crash: drops every 3 events
eventsource         10      0      4         end event, close()
fetch-event-source  10      0      4         end event, abort()
hand-rolled loop    10      0      4         end event
-- deploy: ends cleanly every 3 events
eventsource         10      0      4         end event, close()
fetch-event-source  3       0      1         returned: the stream closed cleanly
hand-rolled loop    10      0      4         end event
-- finished, no end event, replays on reconnect
eventsource         20      10     2         still going at 20 events
fetch-event-source  10      0      1         returned: the stream closed cleanly
hand-rolled loop    10      0      4         gave up after 3 reconnects without a new event
-- drops every 3, ignores Last-Event-ID
eventsource         20      17     7         still going at 20 events
fetch-event-source  20      17     7         still going at 20 events
hand-rolled loop    3       0      4         gave up after 3 reconnects without a new event
-- drops every 3, first reconnect gets 503
eventsource         3       0      2         closed by the library: 503
fetch-event-source  10      0      5         end event, abort()
hand-rolled loop    10      0      5         end event

In the first block, against a server that resumes, all three read ten events once. The deploy is where they part: fetch-event-source returned after three events with no error. The third block is the duplicate stream: the server finished, eventsource reconnected as the standard says, and the server sent everything again. The demo stopped it at 20 events, because the library has no limit of its own. In the fourth block the server cannot resume at all, so both libraries loop on rows 1 to 3 until stopped, and the loop gives up. The last block stands in for a load balancer with no instance ready during a deploy. The standard fails the connection on any status other than 200, so eventsource stopped for good at row 3.

Track the last id and stop on an explicit end

The loop’s core is the event handler. It ends on an end event and skips an id it has already handled. It keeps a handler’s failure apart from a dropped connection, and records the id only after the handler has finished with the event.

for await (const event of events) {
  if (event.event === 'end') return { reason: 'end event', lastId, requests, skipped }
  if (event.id !== undefined && seen.has(event.id)) {
    skipped.push(event.id)
    continue
  }
  // The handler's own failure is the caller's to see, not a dropped connection.
  try { await onEvent(event) } catch (err) { handlerError = err; break }
  delivered++
  if (event.id === undefined) {
    idlessSinceLastId++
  } else {
    idlessSinceLastId = 0
    lastId = event.id
    seen.add(event.id)
    if (seen.size > dedupeWindow) seen.delete(seen.values().next().value)
  }
}

A clean close without an end event counts as a cut, so the loop reconnects with Last-Event-ID. A 204 on that reconnect means the server has nothing more, which is how the standard tells a client to stop. A 429, 502, 503, or 504 counts as a reconnect that delivered nothing, and the loop waits for its Retry-After, capped at a minute, before the next one. Three reconnects in a row that deliver nothing new end the loop with an error, so a server that replays forever costs four requests rather than an afternoon.

Resume a download with Range and If-Range

A file has no event ids, so the offset is the count of bytes already written. Ask for the rest with Range, and send the ETag in If-Range so the server answers 200 with the whole file if it changed in between. RFC 9110 forbids a weak tag in If-Range, so the loop starts over when the tag is weak.1 A 206 is accepted only when its Content-Range starts at the byte the loop asked for. The loop also sends Accept-Encoding: identity on every request. fetch decodes a gzip body but keeps the encoded Content-Length, and a range counts encoded bytes, so a count of decoded bytes matches neither. When a server compresses anyway, the loop has no offset it can trust, so it starts over after a cut.

node demo.mjs
download, cut after every third of the file
  Range + If-Range                    200 206 206     50450 bytes, intact true
  Range + If-Range, file replaced     200 200 206 206 50465 bytes, intact true
  Range only, file replaced           200 206 206 206 50465 bytes, intact false

The second line is If-Range at work: the file changed after the first cut, the server sent a full 200, and the loop threw away its partial copy. The third line is the same download without If-Range. The server honored the range against the second version, and the client appended it to the first, a file that matches neither version and passes every length check.

Refuse to retry a stream with no ids

A model’s token stream is the common case with nothing to resume from. The example stream in Anthropic’s streaming documentation carries event and data lines and no id, so a reconnect has no position to send. The loop refuses rather than ask for the stream again.

node demo.mjs
model token stream with no ids, cut after 3 deltas
  received "The answer is " in 1 request
  NotResumableError: 3 events arrived after the last id, so a retry would replay them

Restarting is a second generation, not a resume. It is a new request, billed as one, and need not produce the same text, so the caller has to decide between that and giving up. Anthropic documents a third option, a request that includes the partial response and asks the model to continue.2 OpenAI’s background mode does carry a cursor, a sequence_number on each event, and resumes with a starting_after query parameter in place of Last-Event-ID.

Check it worked

The test runs every scenario, checks that the loop sends Last-Event-ID: 3, 6, and 9 on its reconnects, and asserts that a handler’s exception is not retried. It proves that two 503 responses in a row are waited out for the Retry-After they carry, and that a weak ETag never reaches If-Range. A download from a server that compresses anyway starts over instead of resuming.

node --test stream.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1519.194759

When it goes wrong

Every row arrives twice after the server finishes. eventsource reconnected after a clean close, and the server ignored Last-Event-ID and started again. Send an end event and close the client on it, and answer a reconnect past the last id with 204.

A command-line reader exits with code 0 after the first drop, holding three events of ten. eventsource 5.1.2 calls unref() on its reconnect timer in Node, so a process with nothing else pending exits during the wait instead of reconnecting.

node exit.mjs
reader and server in separate processes, server drops every 3 events
  eventsource      events 1 2 3, exited with readyState 0
                   exit code 0, requests at the server 1
  hand-rolled loop events 1 2 3 4 5 6 7 8 9 10, returned: end event
                   exit code 0, requests at the server 4

Await a promise that settles on the end event, or use a loop whose wait keeps the process alive.

A resumed file fails its checksum. The download sent Range without If-Range, so a file replaced between attempts was spliced. Send the ETag with every range request.

When not to do this

Do not resume against a server that keeps no history. Last-Event-ID is a request to replay from a point, and a server that holds nothing past the live edge can only start again or skip ahead.

Do not write the loop when eventsource fits. If your server honors Last-Event-ID, answers 204 when it is done, and never fails a reconnect with an error status, the library’s behavior is the standard’s, and you test less. Your process also has to stay alive while it waits.

Do not treat a resumed model response as the original. A continuation request produces a new generation, and an agent that stitches it onto the cut text has two answers joined at a seam.

Do not skip duplicates by ids the server reuses. The guard assumes an id names one event; a server that numbers each connection from 1 needs a cursor instead.

Last verified

Verified 2026-10-15 against Node 22.22.0, eventsource 5.1.2, @microsoft/fetch-event-source 2.0.1, and eventsource-parser 4.1.1. Every output block is what the command preceding it printed, against the server in the sample directory. fetch-event-source ran under Node with a shim for window and document. The model streams come from the sample server, which stands in for a model API.

Footnotes

  1. The same section also says how a server tells the two kinds of If-Range value apart. It examines the first three characters for a double quote. A strong tag starts with one, a weak tag starts W/", and a date starts with the name of a day, which is the whole of the parsing rule. ↩︎ Back to text

  2. For Claude 4.6 and later models the documented recovery is a user message that hands back the partial response. Its sample prompt is Your previous response was interrupted and ended with [previous_response]. Continue from where you left off. The streaming guide adds that tool use and thinking blocks cannot be partially recovered. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.