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).

Activity

  1. added
    bugSomething isn't working
    area/sdkTypeScript SDK (clients/ts/)
    area/streamingSSE / live-query delivery path (/v1/stream)
    on Aug 13, 2026
  2. coderabbitai commented on Aug 13, 2026

    @coderabbitai
    🔗 Related PRs

    #123 - fix(api): drop CORS credentials + skip same-origin decoration [merged]
    #381 - fix(stream): apply policy row-filter per subscriber on SSE [open]
    #448 - fix(sdk): keep a baseURL path prefix instead of discarding it [merged]
    #456 - feat(sdk)!: add options.headers, fetchOptions, and fetch [merged]
    #470 - feat(sdk)!: stream over fetch so SSE authenticates by header [open]


    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

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 working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions