Area: sdk — performance / DX · split out of #269
ClientConfig.auth is a callback the SDK awaits to obtain a bearer token. It is invoked once per request, with no caching, no in-flight dedupe, and no documented expectation about cost.
// clients/ts/src/http.ts:47-52
if (ctx.auth) {
const token = await ctx.auth();
if (token) headers.Authorization = `Bearer ${token}`;
}
That is free when the callback returns a value already in memory — the shape most examples show (auth: () => localStorage.getItem('token')). It is a network round trip on every single request for anyone whose callback mints or refreshes a token, which is a natural pattern for exactly the proxied / BFF / gateway deployments in #269 and #428. Nothing in the docs says which of those the SDK expects, so the expensive shape looks supported and is silently slow.
Two directions, not mutually exclusive:
- Document the contract. State plainly that
auth is called per request and must be cheap — cache and refresh on your side. Lowest cost, and arguably sufficient.
- Dedupe in the SDK. Collapse concurrent in-flight calls (so a burst of parallel queries mints one token, not N), optionally with a short TTL. More helpful, but it needs a defined invalidation story — a cached token that outlives its expiry is worse than no cache, so it likely needs either a TTL the caller sets or a 401-triggered refresh.
Correction to the original #269 note
The #269 comment that raised this said auth() runs "again on every retry attempt". That part is not accurate — worth recording so it doesn't get designed around. auth() is called at http.ts:48, before the retry loop opens at http.ts:57, so a request that retries 3 times still calls auth() exactly once. Retries reuse the header computed up front.
That has its own (separate, lower-priority) consequence worth deciding on: if a token expires during a retry-and-backoff sequence, the retries keep sending the stale one, and the 503 Retry-After path can wait 30s by default. Whether to re-mint on retry is a real question, but it is the opposite trade from caching, and both should be settled together.
Not in scope
options.fetch (#456) is a workaround for the determined — a wrapper can cache tokens itself — but it is not an answer for the default path, and it does not apply to the SSE transport, which calls auth() separately (stream/sse.ts:65-68).
— Filed by Claude Opus 5, via Claude Code
Area: sdk — performance / DX · split out of #269
ClientConfig.authis a callback the SDK awaits to obtain a bearer token. It is invoked once per request, with no caching, no in-flight dedupe, and no documented expectation about cost.That is free when the callback returns a value already in memory — the shape most examples show (
auth: () => localStorage.getItem('token')). It is a network round trip on every single request for anyone whose callback mints or refreshes a token, which is a natural pattern for exactly the proxied / BFF / gateway deployments in #269 and #428. Nothing in the docs says which of those the SDK expects, so the expensive shape looks supported and is silently slow.Two directions, not mutually exclusive:
authis called per request and must be cheap — cache and refresh on your side. Lowest cost, and arguably sufficient.Correction to the original #269 note
The #269 comment that raised this said
auth()runs "again on every retry attempt". That part is not accurate — worth recording so it doesn't get designed around.auth()is called athttp.ts:48, before the retry loop opens athttp.ts:57, so a request that retries 3 times still callsauth()exactly once. Retries reuse the header computed up front.That has its own (separate, lower-priority) consequence worth deciding on: if a token expires during a retry-and-backoff sequence, the retries keep sending the stale one, and the 503
Retry-Afterpath can wait 30s by default. Whether to re-mint on retry is a real question, but it is the opposite trade from caching, and both should be settled together.Not in scope
options.fetch(#456) is a workaround for the determined — a wrapper can cache tokens itself — but it is not an answer for the default path, and it does not apply to the SSE transport, which callsauth()separately (stream/sse.ts:65-68).— Filed by Claude Opus 5, via Claude Code