Skip to content

fix(sdk): a rejected resumption preflight leaves a stream re-dialing forever #471

Description

@EricAndrechek

Area: sdk — streaming reliability. Surfaced reviewing #470 (#203), documented there but deliberately not mitigated.

A cross-origin browser stream can reach a state where it re-dials forever and can never succeed, in a deployment where the old EventSource transport worked.

The sequence

  1. First connect is a simple request. The transport sends Accept: text/event-stream, which is CORS-safelisted, so no preflight. It connects and delivers events.
  2. Reconnect adds Last-Event-ID (clients/ts/src/stream/sse.ts), set on every attempt once an event has been seen. That header is not safelisted, so this request preflights.
  3. If a proxy answers CORS itself and doesn't allow Last-Event-ID on OPTIONS /v1/stream, the preflight fails, the fetch rejects, and the transport reports SSE_NETWORK_ERROR and re-dials.
  4. _lastEventId is never cleared, so every subsequent attempt carries the same header and hits the same never-satisfiable preflight. The stream is permanently down.

WaveHouse's own CORS allow-lists Last-Event-ID (internal/api/router.go), so this only bites when something in front terminates CORS. But that is exactly the deployment #203's header work is aimed at.

Why it's a regression in shape. A native EventSource never preflights at all — its request isn't a fetch(), so Fetch's unsafe-request flag is never set and Last-Event-ID rides on the plain GET. An operator upgrading the SDK sees streams that connected fine start dying on their first reconnect, with a CORS error that names a header they never configured.

Note this is not the credentialed case. That one fails loudly at first connect, because Authorization isn't safelisted either — it's already in the proxy checklist in docs/src/content/docs/reverse-proxy.mdx. The uncredentialed path is worse precisely because it works until it doesn't.

Options

  1. Self-heal. After N consecutive attempts that fail before a response arrives, drop _lastEventId and re-open without it. The stream resumes with a gap rather than never resuming. Needs a decision on N, and on whether to surface the dropped-resumption-point as an error rather than silently losing history — silently is arguably worse than the current loud-but-stuck behavior.
  2. Fall back to ?since=. The server accepts since as a query parameter and prefers Last-Event-ID only when both are present (internal/api/stream.go). A query parameter never preflights, so resuming via since sidesteps the problem entirely — at the cost of putting a timestamp in the URL and losing the header's precedence semantics.
  3. Document only. Where it stands today: the proxy checklist says to allow the header. That is a real mitigation for operators who read it, and no mitigation at all for those who don't.

Option 2 looks strongest — the server already supports it, it removes the failure mode rather than recovering from it, and a timestamp in a URL carries none of the risk that moving the token out of the URL was about. Worth measuring against whatever multiplexing decides, since #204 may move resumption into a POST body anyway.

Acceptance

  • A cross-origin stream whose resumption preflight is rejected either recovers or fails in a way an operator can act on, rather than looping silently
  • Whatever is chosen is pinned by a test at the transport level
  • reverse-proxy.mdx updated — it currently describes the failure without a remedy beyond "allow the header"

Related: #203 (introduced the fetch transport), #215 (added the CORS allow-list), #204 (multiplexing may relocate resumption), #469 (the other streaming re-dial edge).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/sdkTypeScript SDK (clients/ts/)area/streamingSSE / live-query delivery path (/v1/stream)bugSomething isn't working

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions