Skip to content

bug(sdk): a terminal stream error emitted before .subscribe() is delivered to nobody and never replayed #517

Open

Description

@jfwoods

Area: sdk · streaming — bug · found while reconciling the Go SDK (#434) with main

Expected: a terminal stream error reaches the subscriber's error callback, as sdk/reference.md promises without qualification.

Actual: if the error is emitted before .subscribe() runs, it is delivered to nobody and never replayed. The subscriber learns the stream is closed but never learns why.

The core defect

StreamController fans errors out over whoever is subscribed at the instant they fire, and retains nothing (clients/ts/src/stream/controller.ts:57-61):

this._transport.onError = (error) => {
  for (const sub of this._subscribers) {
    sub.error?.(error);
  }
};

subscribe() replays status to a newcomer, but there is no equivalent for errors (:128-130):

subscribe(subscriber: StreamSubscriber<T>): () => void {
  this._subscribers.add(subscriber);
  subscriber.status?.(this._status);   // status is replayed; the last error is not

Errors are the only thing the controller drops. Pre-subscription events are buffered for the async iterator, and status is replayed on subscribe. The asymmetry is unintentional rather than designed — see below.

Reproduction

Against the built SDK, with a stub transport that emits a terminal error one macrotask after connect() (the real ordering in sse.ts: _emitError(...), then disconnect() → onStatus("closed")):

A subscribe-immediately : GOT the error
B await-then-subscribe   : MISSED the error
  status visible to caller: closed

Any await between creating the stream and subscribing is enough:

const stream = client.from('clicks').stream();
await somethingElse();                                  // terminal error lands here
stream.subscribe({ next: ..., error: (e) => { /* never runs */ } });

In practice that is subscribing inside a React effect after an await, or after awaiting a backfill query.

Why this is a bug and not "you don't get events from before you subscribed"

sse.ts:188-194 already treats "the error fires before anyone is listening" as a failure mode to engineer around — it yields a microtask specifically to avoid it:

Yield before the first attempt so a failure that is detectable synchronously — an unresolvable baseURL — still reaches a subscriber. connect() runs inside the StreamController constructor, so anything emitted before this point fires before .stream() has returned and nobody is listening yet.

That fix covers only the synchronous case. The await-gap residual was never addressed. And sdk/reference.md states the contract with no subscription-timing caveat (:26, :48):

streams report SSE_CONNECT_ERROR to the subscriber's error callback

The SSE_* codes arrive on the subscriber's error callback rather than in a Result.error

Honest scope

This is narrower than its Go counterpart, and worth stating plainly:

  • TypeScript is single-threaded, and the transport deliberately yields, so the documented pattern — stream(), then a synchronous .subscribe(), then await connected() — always receives the error. Every doc example and every test in tests/e2e/sdk/streaming.test.ts follows it.
  • The late subscriber is not left blind: status: "closed" is still replayed, and connected() rejects. What is lost is the reason, which is exactly what a consumer needs to distinguish "bad credentials" from "server restarting".

Reference implementation

The Go SDK had the same defect as a genuine scheduling race — Stream() starts its goroutine immediately, so the window is real rather than await-dependent. Fixed in 29c6b6b on #434: the controller records lastErr, and Subscribe replays it to a late subscriber. Recording and snapshotting the subscriber list share one critical section, so a subscriber racing emitError is served by exactly one path and never sees the error twice. clients/go/stream_test.go pins it — reverting stream.go alone fails the test.

The TS fix is the same shape, minus the locking: keep _lastError, replay it in subscribe() right after the status replay.

Acceptance

  • A subscriber that registers after a terminal error has been emitted receives that error
  • A subscriber that registers before it still receives it exactly once — no double delivery
  • Pinned by a test with an await between .stream() and .subscribe()
  • Go and TypeScript agree on the behaviour (AGENTS.md API-tree parity)

Related: #473 (same file, same fan-out loop, but consumer-throw isolation — different trigger and different fix), #434 (the Go fix), #516 (the other Go-fix-implies-TS-bug finding from the same reconciliation).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/sdkTypeScript SDK (clients/ts/)area/streamingSSE / live-query delivery path (/v1/stream)bugSomething isn't workingjavascriptPull requests that update javascript code

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions