Repository navigation
feat(sdk): allow custom request headers (or a fetch override) for header-gated origins #269
Description
Area: sdk — friction (DX) / gap · found via WaveHouse-Stats dogfooding
Expected: point the SDK at a WaveHouse fronted by a header-gated proxy (Cloudflare Access service token, mTLS sidecar, auth gateway) by attaching arbitrary per-request headers — or by supplying a custom fetch.
Actual: ClientConfig exposes only baseURL, auth (which sets just Authorization: Bearer …), and options.maxRetries. No headers option, no fetch injection point — so the SDK can't carry headers like CF-Access-Client-*.
Impact (Stats): initially blocked them (Worker writes went through a CF-Access-gated origin the SDK couldn't satisfy). They've since moved ingest to a public JWT-gated endpoint, so it no longer blocks them — but it still bites any deployment fronting WaveHouse behind a header-gated proxy. A defense-in-depth gate shouldn't force a consumer off the SDK.
Additional consumers named later: cookie-authenticated origins, where the blocker isn't a missing header but that fetch defaults to credentials: "same-origin", so a cross-origin browser request sends no cookie at all.
Related: SDK epic #194; SSE-auth migration #203; base path in baseURL #428 (a deployment behind a BFF generally needs both).
From WaveHouse-Stats WAVEHOUSE-FEEDBACK.md dogfooding (dev 71c2dc4), 2026-06-04.
Scope — all three knobs land together in #456
Rescoped 2026-08-12. #456 already implements the fetch override; headers and fetchOptions are being folded into that same PR so the HTTP-customization surface lands as one coherent API rather than in three increments.
-
options.fetch— supply the HTTP implementation. Exported asFetchLike, deliberately narrower thantypeof fetch(astringURL is all the SDK passes), which accepts strictly more implementations than the wide type does. REST path only, by construction. (implemented in feat(sdk)!: add options.headers, fetchOptions, and fetch #456) -
headers— arbitrary headers on every request. The ergonomic answer for CF-Access and friends; afetchoverride is a power tool that shouldn't be the only route to a static header. -
fetchOptions— aRequestInitmerged into every request. Coverscredentials: "include"for the cookie case above, plus undicidispatcher,mode,cache, etc., without forcing a full fetch wrapper.
Prior art driving the shape
| SDK | Options | Fetch type |
|---|---|---|
| Supabase | global.fetch, global.headers |
type Fetch = typeof fetch |
| Anthropic | fetch, fetchOptions, defaultHeaders, defaultQuery |
(input: string | URL | Request, init?) => Promise<Response> |
| OpenAI | fetch, fetchOptions |
same (both Stainless-generated) |
fetchOptions is the established name for "extra RequestInit on every request" — OpenAI's own documented undici-proxy example is new OpenAI({ fetch, fetchOptions: { dispatcher: proxyAgent } }). Adopting it is cheaper than inventing a credentials knob that solves one case.
Open design questions
- Naming/nesting. Supabase nests under
global: {}; Anthropic/OpenAI keep options flat. WaveHouse currently nests transport config underoptions: {}(maxRetries,fetch). Doheaders/fetchOptionssit underoptionsfor consistency, or at the top ofClientConfignext toauth? - Static vs dynamic headers.
Record<string, string>alone can't carry a rotating service token. A() => Record<string,string> | Promise<…>form can, but inherits the per-call cost question in feat(sdk): define the auth() callback contract — per-request cost, caching, retry staleness #458. - Precedence. When
auth,headers, andfetchOptions.headersall setAuthorization, which wins? Needs to be defined and tested, not emergent. Anthropic's convention is that per-request options override client defaults, and an explicitnullremoves a default header. - Whether
headersreaches SSE. NativeEventSourcecannot set headers at all — see below.
Out of scope — split out
- Streaming/SSE coverage → feat(sdk): migrate SSE auth from ?token=JWT to Fetch EventSource #203.
stream/sse.tsusesnew EventSource(url), which can set no headers and takes no fetch, which is why the JWT rides in?token=today. None of the three knobs above can reach it without a transport change; feat(sdk): migrate SSE auth from ?token=JWT to Fetch EventSource #203 owns that decision and now carries the full analysis (polyfill fetch-override vs. fetch-based transport vs. hybrid, and theLast-Event-ID/ auto-reconnect cost). Until feat(sdk): migrate SSE auth from ?token=JWT to Fetch EventSource #203 lands, this issue's fix is REST-only, and that limit should be documented rather than left implicit. auth()per-request cost and caching → feat(sdk): define the auth() callback contract — per-request cost, caching, retry staleness #458. Note that the original comment here claimedauth()also runs on every retry attempt; that is not accurate — it is called once perrequest(), before the retry loop (http.ts:48vs:57). feat(sdk): define the auth() callback contract — per-request cost, caching, retry staleness #458 records the correction.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
EricAndrechek commentedon Aug 5, 2026
Two more consumers for this, plus one gap the current scope wouldn't close.
Cookie-authenticated origins need
credentials, not just headers. Where WaveHouse sits behind an app server that authenticates with a session cookie, the blocker isn't a missing header — it's thatfetchdefaults tocredentials: "same-origin", so a cross-origin request from a browser sends no cookie at all. Thefetchoverride proposed here does solve it (the wrapper can setcredentials: "include"), but it's worth naming the case explicitly, since "custom headers" alone wouldn't — and a plaincredentials?: RequestCredentialspassthrough may be the simpler knob for people who need only that.The SSE transport wouldn't be covered.
stream/sse.tsusesnew EventSource(url)(sse.ts:79) and never touchesfetch, so afetchoverride leaves streaming unauthenticated on exactly the deployments this issue is about.EventSourcetakes{ withCredentials: true }for the cookie case, and it can't set headers at all — which the SDK already works around via?token=(sse.ts:62-65). Whatever lands here should say what the streaming transport does, or it'll be half a fix that looks complete until someone opens a subscription.Minor, adjacent:
ctx.auth()is awaited on every request, and again on every retry attempt (http.ts:45-50), with no caching or in-flight dedupe. That's free whenauthreturns a value from memory, but it's a network round-trip per request for anyone whose callback fetches or mints a token — a natural pattern for precisely the proxied/BFF deployments above. Either a documented expectation ("cache it yourself, this is called per request") or built-in dedupe with a short TTL would help; right now it's silent.Related: #428 (base path in
baseURL) — the other half of making the SDK usable behind a proxy. A deployment behind a BFF generally needs both.fix(sdk): keep a baseURL path prefix instead of discarding it (#448)
EricAndrechek commentedon Aug 12, 2026
Rescoped the issue body (2026-08-12) rather than appending, since the shape of the work changed rather than grew.
What changed: #456 was opened as just the
fetchoverride. It's now takingheadersandfetchOptionstoo, so the whole HTTP-customization surface lands as one API instead of three increments that each half-answer the header-gated-origin case.What moved out:
stream/sse.tsconstructsnew EventSource(url), which accepts no headers and no fetch — that's precisely why the JWT rides in?token=. I've posted the full analysis on feat(sdk): migrate SSE auth from ?token=JWT to Fetch EventSource #203 (theeventsource@4.1.0fetch-override option, a fetch-based transport, or a hybrid) along with the costs each carries, notably that nativeEventSourceis currently giving us auto-reconnect and automaticLast-Event-IDresumption for free, and the server depends on the latter (internal/api/stream.go:86-89). I deliberately did not recommend one — that call has SSE-side context I don't have. Until it lands, this issue's fix is REST-only and should say so in the docs.auth()caching → feat(sdk): define the auth() callback contract — per-request cost, caching, retry staleness #458. With one correction recorded there: the comment above saysauth()runs "again on every retry attempt", which doesn't hold. It's called athttp.ts:48, before the retry loop opens athttp.ts:57, so a request retrying three times still calls it once. The per-request cost is real; the per-retry multiplier isn't. (There is a separate question in the other direction — retries reuse the token minted up front, so one expiring mid-backoff is sent stale — captured in feat(sdk): define the auth() callback contract — per-request cost, caching, retry staleness #458.)Prior art is now in the body, because it should drive the API shape: Supabase ships
global.fetch+global.headers; Anthropic and OpenAI shipfetch+fetchOptions+defaultHeaders.fetchOptionsis the established name for "extraRequestIniton every request", and it subsumes thecredentials: "include"case from the comment above more cleanly than a bespokecredentialsknob would.Four design questions are listed in the body and are not settled — nesting (
options.headersvs top-level, given Supabase nests and Anthropic doesn't), static vs dynamic headers, precedence whenauth/headers/fetchOptions.headerscollide, and how the REST-only limit gets documented. Those get decided before code.— Claude Opus 5, via Claude Code
feat(sdk): add options.headers and options.fetchOptions, widen FetchLike
feat(sdk)!: add options.headers, fetchOptions, and fetch (#456)
feat(sdk)!: stream over fetch so SSE authenticates by header (#470)