Skip to content

Streaming responses

The problem. pipeline.execute() waits for one value. A streaming call (LLM tokens, a large download, server-sent events) produces many, and a failure halfway through is different from a failure at the start:

  • Before the first chunk, nothing has reached the user. The call can be retried, hedged, timed out or replaced by a fallback like any other.
  • After the first chunk, the user has seen part of the answer. Retrying would send those chunks again; a fallback would splice a second answer onto half of the first.

pipeline.executeStream(factory) draws that line for you: the pipeline protects everything up to the first chunk, and from then on the stream is committed to the caller.

const
const llm: ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>
llm
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'llm' })
.
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 2,
RetryOptions<any>.delay?: Duration | undefined

Base delay. Default 2000 ms.

delay
: '500ms',
RetryOptions<any>.backoffType?: BackoffType | undefined

Default 'constant'.

backoffType
: 'exponential',
RetryOptions<any>.useJitter?: Jitter | undefined
useJitter
: true })
.
ResiliencePipelineBuilder<any, never, never>.addCircuitBreaker(options?: CircuitBreakerOptions<any>): Next<any, never, never, BrokenCircuitError>
addCircuitBreaker
({
CircuitBreakerOptions<any>.failureRatio?: number | undefined

Failure ratio in the window that breaks the circuit. Default 0.1.

failureRatio
: 0.5,
CircuitBreakerOptions<any>.minimumThroughput?: number | undefined

Minimum recorded calls in the window before the ratio is considered. Default 100.

minimumThroughput
: 10 })
.
ResiliencePipelineBuilder<any, BrokenCircuitError, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, BrokenCircuitError, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('10s') // time to first token, per attempt
.
ResiliencePipelineBuilder<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>
build
();
for await (const
const token: string
token
of
const llm: ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>
llm
.
ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>.executeStream<string>(factory: (context: ResilienceContext) => MaybePromise<AsyncIterable<string> | ReadableStream<string>>, options?: ExecuteOptions): AsyncGenerator<string, void, undefined>

Runs a streaming call (an AsyncIterable or a ReadableStream) through the pipeline. Everything up to the first chunk is protected: retries, timeouts (time to first chunk), breaker, limiters. Once the first chunk arrives the stream is committed: later errors reach the consumer and are not retried or replaced by a fallback, which would repeat data already delivered. Breaking out of the loop closes the underlying stream.

executeStream
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function openCompletion(prompt: string, options: {
signal: AbortSignal;
}): Promise<AsyncIterable<string>>
openCompletion
('Summarize…', {
signal: AbortSignal
signal
}))) {
function render(token: string): void
render
(
const token: string
token
);
}

The factory returns an AsyncIterable or a ReadableStream (or a promise of one). executeStream returns an async generator you consume with for await. It accepts the same options as execute (signal, operationKey, idempotent…).

The pipeline runs the factory and reads the first chunk inside execute(). That execute() returns as soon as the first chunk arrives, so every strategy sees the stream only up to that point:

Strategy Before the first chunk After the first chunk
Retry Retries a failed connect or a stream that fails before yielding. A stream that throws before its first chunk is closed first. Not retried. The error reaches your for await.
Timeout Time to first chunk. A slow first token is a TimeoutRejectedError. Off: its timer was cleared when execute() returned.
Circuit breaker Counts connect failures and first-chunk timeouts. Mid-stream errors are not counted.
Fallback Answers instead of the stream: a value is yielded as one chunk, a stream is iterated. Never replaces a stream that already yielded.
Cache Not supported: a cached stream can only be read once, so the second hit throws a TypeError. —
Concurrency limiter, rate limiter Hold a permit until the first chunk. The permit is released: a long stream doesn’t occupy a slot.
Hedging Starts a second stream if the first is slow to produce a chunk. The first stream to yield wins. See the caution below.

Two consequences worth knowing:

  • A limiter does not bound concurrent streams. If you need “at most 20 open streams”, count them yourself around the for await, or wrap the whole loop in execute() with a concurrency limiter (then the pipeline protects the entire stream, but retries would repeat chunks, so put no retry in that pipeline).
  • Fallbacks only cover the connect phase. If the stream fails after its first chunk, the error reaches your loop; switch sources there only if nothing was yielded yet (example below).

The pipeline timeout only bounds the wait for the first chunk. For a limit on the whole stream, or on the gap between chunks, use the source’s own signal:

const
const total: AbortSignal
total
=
var AbortSignal: {
new (): AbortSignal;
prototype: AbortSignal;
abort(reason?: any): AbortSignal;
any(signals: AbortSignal[]): AbortSignal;
timeout(milliseconds: number): AbortSignal;
}

The AbortSignal interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object.

MDN Reference

AbortSignal
.
function timeout(milliseconds: number): AbortSignal

The AbortSignal.timeout() static method returns an AbortSignal that will automatically abort after a specified time.

MDN Reference

timeout
(60_000); // the whole answer, including retries before the first token
const
const stream: AsyncGenerator<string, void, undefined>
stream
=
const llm: ResiliencePipeline<any, any>
llm
.
ResiliencePipeline<any, any>.executeStream<string>(factory: (context: ResilienceContext) => MaybePromise<AsyncIterable<string> | ReadableStream<string>>, options?: ExecuteOptions): AsyncGenerator<string, void, undefined>

Runs a streaming call (an AsyncIterable or a ReadableStream) through the pipeline. Everything up to the first chunk is protected: retries, timeouts (time to first chunk), breaker, limiters. Once the first chunk arrives the stream is committed: later errors reach the consumer and are not retried or replaced by a fallback, which would repeat data already delivered. Breaking out of the loop closes the underlying stream.

executeStream
(
({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function openCompletion(prompt: string, options: {
signal: AbortSignal;
}): Promise<AsyncIterable<string>>
openCompletion
('Summarize…', {
signal: AbortSignal
signal
:
var AbortSignal: {
new (): AbortSignal;
prototype: AbortSignal;
abort(reason?: any): AbortSignal;
any(signals: AbortSignal[]): AbortSignal;
timeout(milliseconds: number): AbortSignal;
}

The AbortSignal interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object.

MDN Reference

AbortSignal
.
function any(signals: AbortSignal[]): AbortSignal

The AbortSignal.any() static method takes an iterable of abort signals and returns an AbortSignal.

MDN Reference

any
([
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
,
const total: AbortSignal
total
]) }),
{
ExecuteOptions.signal?: AbortSignal | undefined

Cancels the execution. Combined with the signal of the execution this one runs inside, if any.

signal
:
const total: AbortSignal
total
},
);
for await (const
const token: string
token
of
const stream: AsyncGenerator<string, void, undefined>
stream
)
function render(token: string): void
render
(
const token: string
token
);

AbortSignal.any([signal, total]) reaches the source both before the first chunk (the pipeline’s own timeouts and retries abort signal) and after it (total still runs). For an idle timeout between chunks, reset a timer per chunk in your loop and break when it fires.

async function*
function answer(prompt: string): AsyncGenerator<string>
answer
(
prompt: string
prompt
: string):
interface AsyncGenerator<T = unknown, TReturn = any, TNext = any>
AsyncGenerator
<string> {
let
let yielded: boolean
yielded
= false;
try {
for await (const
const t: string
t
of
const llm: ResiliencePipeline<any, any>
llm
.
ResiliencePipeline<any, any>.executeStream<string>(factory: (context: ResilienceContext) => MaybePromise<AsyncIterable<string> | ReadableStream<string>>, options?: ExecuteOptions): AsyncGenerator<string, void, undefined>

Runs a streaming call (an AsyncIterable or a ReadableStream) through the pipeline. Everything up to the first chunk is protected: retries, timeouts (time to first chunk), breaker, limiters. Once the first chunk arrives the stream is committed: later errors reach the consumer and are not retried or replaced by a fallback, which would repeat data already delivered. Breaking out of the loop closes the underlying stream.

executeStream
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function openPrimary(prompt: string, signal: AbortSignal): Promise<AsyncIterable<string>>
openPrimary
(
prompt: string
prompt
,
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
))) {
let yielded: boolean
yielded
= true;
yield
const t: string
t
;
}
} catch (
function (local var) e: unknown
e
) {
// Mid-stream failure: the user already has part of the answer. Surface it, don't splice another one on.
if (
let yielded: boolean
yielded
) throw
function (local var) e: unknown
e
;
// Nothing delivered yet (open circuit, timeout to first token, retries exhausted): switch providers.
yield*
const backup: ResiliencePipeline<any, any>
backup
.
ResiliencePipeline<any, any>.executeStream<string>(factory: (context: ResilienceContext) => MaybePromise<AsyncIterable<string> | ReadableStream<string>>, options?: ExecuteOptions): AsyncGenerator<string, void, undefined>

Runs a streaming call (an AsyncIterable or a ReadableStream) through the pipeline. Everything up to the first chunk is protected: retries, timeouts (time to first chunk), breaker, limiters. Once the first chunk arrives the stream is committed: later errors reach the consumer and are not retried or replaced by a fallback, which would repeat data already delivered. Breaking out of the loop closes the underlying stream.

executeStream
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function openBackup(prompt: string, signal: AbortSignal): Promise<AsyncIterable<string>>
openBackup
(
prompt: string
prompt
,
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
));
}
}

executeStream reads a ReadableStream with its reader when the stream isn’t async-iterable, so a fetch body works directly. Check the status inside the factory, so a 503 counts as a failure before the first chunk and is retried:

const
const download: AsyncGenerator<Uint8Array<ArrayBuffer>, void, undefined>
download
=
const llm: ResiliencePipeline<any, any>
llm
.
ResiliencePipeline<any, any>.executeStream<Uint8Array<ArrayBuffer>>(factory: (context: ResilienceContext) => MaybePromise<ReadableStream<Uint8Array<ArrayBuffer>> | AsyncIterable<Uint8Array<ArrayBuffer>>>, options?: ExecuteOptions): AsyncGenerator<Uint8Array<ArrayBuffer>, void, undefined>

Runs a streaming call (an AsyncIterable or a ReadableStream) through the pipeline. Everything up to the first chunk is protected: retries, timeouts (time to first chunk), breaker, limiters. Once the first chunk arrives the stream is committed: later errors reach the consumer and are not retried or replaced by a fallback, which would repeat data already delivered. Breaking out of the loop closes the underlying stream.

executeStream
(async ({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) => {
const
const res: Response
res
= await
function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)
fetch
('https://files.example.com/export.csv', {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
});
if (!
const res: Response
res
.
Response.ok: boolean

The ok read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.

MDN Reference

ok
|| !
const res: Response
res
.
Body.body: ReadableStream<Uint8Array<ArrayBuffer>> | null
body
) throw
var Object: ObjectConstructor

Provides functionality common to all JavaScript objects.

Object
.
ObjectConstructor.assign<Error, {
status: number;
}>(target: Error, source: {
status: number;
}): Error & {
status: number;
} (+3 overloads)

Copy the values of all of the enumerable own properties from one or more source objects to a target object. Returns the target object.

@param ― target The target object to copy to.

@param ― source The source object from which to copy properties.

assign
(new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
(`HTTP ${
const res: Response
res
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
}`), {
status: number
status
:
const res: Response
res
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
});
return
const res: Response
res
.
Body.body: ReadableStream<Uint8Array<ArrayBuffer>>
body
;
});
for await (const
const bytes: Uint8Array<ArrayBuffer>
bytes
of
const download: AsyncGenerator<Uint8Array<ArrayBuffer>, void, undefined>
download
)
function write(bytes: Uint8Array): void
write
(
const bytes: Uint8Array<ArrayBuffer>
bytes
);

The thrown error carries status, so the default retry predicate skips 4xx answers and retries 5xx, 408 and 429.

Hedging starts a second stream when the first is slow to yield, and the first one to produce a chunk wins. The losers’ signals are aborted.

A streaming call that changes state (starts a job, charges credits per request) must not run twice. Pass idempotent: false: retries and hedging then only repeat attempts that never started (rejected by a limiter or an open circuit).

llm.executeStream(({ signal }) => startExport(jobSpec, signal), { idempotent: false });
  • Pass the pipeline’s signal to the source; combine it with your own signal for limits beyond the first chunk.
  • The pipeline timeout is time to first chunk. Bound the whole stream yourself.
  • Stop a committed stream with break, not by aborting the signal you passed in options.
  • Fall back only when nothing was yielded; mid-stream errors go to the consumer.
  • Hedge only sources that stop when aborted.