Bug: single streaming HTTP request outlasting bearer TTL is killed mid-stream; no in-stream renewal path
Summary
When claude-code (spawned by @anthropic-ai/claude-agent-sdk or invoked directly) opens a single streaming HTTP request whose response streams for longer than the current bearer token's remaining TTL, the auth layer kills the connection when the token expires. There is no mechanism to renew auth on an already-open stream — apiKeyHelper is only consulted between HTTP requests. Consumers using short-lived bearers (e.g. via apiKeyHelper) hit this reliably on any single turn that generates a large model response.
Surfaces two ways depending on when the auth check trips:
Response stalled mid-stream— anis_error: trueresult when the auth layer kills the streaming connection itself.Failed to authenticate. API Error: 403 {"error":["The token is expired"]}— when a follow-up HTTP call afterresult=successreuses a token that expired during the preceding stream.
Environment
- Node: 24 (also observed on 22)
- SDK:
@anthropic-ai/claude-agent-sdk@0.3.202 - Bundled CLI:
claudeversion2.1.202(per SDKmanifest.json) - Model:
claude-sonnet-4-6 - Auth path:
apiKeyHelperin~/.claude/settings.jsonminting short-lived bearer tokens (~300 s TTL) against a bearer-validating proxy that fronts the Anthropic-compatible API.CLAUDE_CODE_API_KEY_HELPER_TTL_MS=60000is set on the environment;apiKeyHelperDOES fire on the ~60 s cadence between requests (verified — see cadence evidence below).
Reproducer
The failure requires two things: (1) an apiKeyHelper that returns tokens with short TTL that the auth proxy strictly enforces, and (2) a prompt that forces the model to produce a single long streaming response. We built a minimal repro on GitHub Actions that reliably fires the failure in ~7 min. Sanitised setup:
1. ~/.claude/settings.json:
{
"apiKeyHelper": "/path/to/helper.sh",
"env": { "ANTHROPIC_BASE_URL": "https://<bearer-validating-proxy>/<instance>" }
}
2. helper.sh — the specific implementation of apiKeyHelper. Ours mints a token via a short-lived OIDC endpoint on every invocation and logs exp from the decoded JWT so we can correlate helper calls against stream timing:
#!/usr/bin/env bash
set -euo pipefail
LOG=/tmp/repro/helper.log
token=$(<mint fresh short-TTL bearer>)
exp=$(echo "$token" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.exp // 0')
now=$(date +%s)
printf '%s ppid=%d exp=%s ttl_s=%s\n' "$(date +%s.%N)" "$PPID" "$exp" "$((exp - now))" >> "$LOG"
printf '%s' "$token"
3. Node driver — calls the SDK directly, no other wrappers:
import { query } from '@anthropic-ai/claude-agent-sdk';
const prompt = [
`You are working in ./workdir. There are 40 fixture .md files.`,
`STEP 1 — Read the first 40 fixtures sequentially.`,
`STEP 2 — Write ONE massive synthesis document to synthesis.md via a SINGLE Write tool call.`,
`The document MUST be at least 15,000 words covering trade-offs, history, and production caveats`,
`for each of the topics referenced in the fixtures. Do the entire document in ONE Write call.`,
].join('\n');
const iter = query({
prompt,
options: {
cwd: process.cwd(),
env: process.env,
permissionMode: 'acceptEdits',
tools: ['Read','Write','Grep','Glob','Edit'],
allowedTools: ['Read','Write','Grep','Glob','Edit'],
disallowedTools: ['Bash'],
model: 'claude-sonnet-4-6',
settingSources: ['user'],
},
});
for await (const m of iter) { /* log */ }
Fixture files: 40 markdown files of ~1 KB each, generated deterministically. Any content works — the fixtures exist so Read produces context; the failure is triggered by the subsequent Write.
Timing evidence — failing run
Our apiKeyHelper logged:
1783550720.062 ppid=2055 exp=1783551019 ttl_s=299
1783550780.667 ppid=2110 exp=1783551080 ttl_s=300
Two helper invocations. Second call at t+60 s (relative to first) minted a token with exp = t+360 s. No further helper calls fire until the run terminates 351 s later.
Our SDK message-loop logged (times relative to run start, so first helper call ≈ t+2 s from below):
t+8s turn=1
t+9s turn=3
t+16s turn=4
... (40 short Read turns, one HTTP request each)
t+95s turn=42
t+110s turn=43
t+110s turn=44 ← last Read; the massive Write turn begins immediately after
[ONE HTTP REQUEST, STREAMING BEGINS HERE]
no more helper calls fire — no new request to auth
t+411s turn=45
t+411s RESULT subtype=success is_error=true turns=45
result="API Error: Response stalled mid-stream. The response above may be incomplete."
t+411s THREW: Claude Code returned an error result: API Error: Response stalled mid-stream.
Key data points:
- Streaming request opens ~t+110 s using the token from the t+60 s helper call (exp = t+360 s).
- Token expires mid-stream at t+360 s.
- Stream dies 51 s later at t+411 s — right around the auth layer's next check on the open connection.
- Between t+60 s and t+411 s, no
apiKeyHelpercalls fire — a streaming request is one HTTP request, so no cache-boundary crossing, so no helper invocation.
Timing evidence — baseline that works
Same setup, same auth path, same model. Only difference: the prompt asks for many short Read+Write cycles (120 fixtures, one summary each) instead of one massive Write.
Helper log (24-minute run):
1783548646.643 exp=1783548946 ttl_s=300 ← t+0 s
1783548713.742 exp=1783549013 ttl_s=300 ← t+67 s
1783548774.748 exp=1783549074 ttl_s=300 ← t+128 s
... (23 total, all 60–70 s apart)
1783550050.617 exp=1783550350 ttl_s=300 ← t+1404 s (end)
Every helper call is ~60 s after the previous one. CLAUDE_CODE_API_KEY_HELPER_TTL_MS=60000 is honored. No individual streaming request lasts long enough to cross a token boundary. Run completed successfully at 248 turns with RESULT subtype=success is_error=false.
This falsifies "interactive mode holds one token" — the CLI does re-invoke the helper on the ~60 s cadence between turns. The bug is specifically about a single HTTP request whose response streams past its bearer's exp, not about session-level caching.
Two additional occurrences from a different agent workload
Two other production agent runs on the same auth setup, doing complex code-writing tasks. Both hit the "The token is expired" variant on a trailing SDK call after result=success (rather than the "stalled" variant). Full inline transcript from one of them (times UTC):
20:10:45 [agent:implement] start (workspace-write, claude-sonnet-4-6)
20:10:47 [agent:implement] turn 1
20:10:48 [agent:implement] turn 2
20:10:48 [agent:implement] turn 3: Glob
...
20:11:01 [agent:implement] turn 18: Read(pyproject.toml)
20:15:20 [agent:implement] turn 19 ← 4:19 GAP: single-stream turn
20:15:23 [agent:implement] turn 20
20:16:04 [agent:implement] turn 21: Write(app.py)
20:16:06 [agent:implement] turn 22
20:16:06.42 [agent:implement] result=success turns=22 changed=1
20:16:06.51 Error: Claude Code returned an error result: Failed to authenticate.
API Error: 403 {"result":{"allow":false,"error":["The token is expired"],
"github_audience_valid":true,"has_required_claims":true,
"iss_is_external":true,"token_type":"GITHUB"}}
The 4:19 gap between turns 18 and 19 is a single streaming turn that spans the exp boundary. Then the model returns result=success, and 96 ms later a trailing HTTP call from the SDK/CLI fires with the (now-expired) token used during the stream and gets the 403.
Comparison — claude -p "$PROMPT" doesn't hit this
The same auth setup with claude -p "$PROMPT" --output-format stream-json --verbose runs 12+ minutes without failure. The difference is workload shape: review-style workloads produce many short streaming turns (one HTTP request per file read, one per comment posted). Every new request fires apiKeyHelper afresh, so no individual stream ever exceeds the bearer's TTL. It's specifically the single long stream that breaks — the mode (interactive vs one-shot) or the flag (--input-format stream-json vs -p) is not the discriminator.
Requested behavior
Any of these, in order of preference:
- Pre-open TTL check. Before opening a new streaming request, check the currently-cached bearer's remaining TTL. If less than N seconds (configurable? default
60?), invokeapiKeyHelpersynchronously first. This would fix ~95 % of cases — a bearer minted moments before opening a long stream would carry its full TTL into it. Simple, no wire-protocol change, no consumer-side integration needed. - In-stream renewal. When approaching the current bearer's
expduring an open stream, invokeapiKeyHelperand present the new bearer to the server on subsequent frames (or force a reconnect if the protocol requires it). More complex; probably requires server cooperation. - Consumer-side hook. Expose an SDK option (
onStreamAuthRefresh?) or env var so consumers who know their workload will produce long turns can force a proactive refresh.
What we've ruled out (to save triage time)
- "Session mode holds one token for the whole session." Falsified by our baseline run above —
apiKeyHelperfires 23 times at 60–70 s intervals across 24 min of--input-format stream-jsonoperation. - "
CLAUDE_CODE_API_KEY_HELPER_TTL_MSisn't honored." It IS honored — cache TTL is respected between requests. The bug is that a single streaming request outlasts any cache TTL setting could bridge. - "Auth proxy rejecting valid tokens." The token really is past its
expat the moment of rejection — verified against the JWT claims logged from our helper. - "Version-specific regression." Observed on the versions in the Environment section; no reason to believe earlier versions differ (
apiKeyHelperhas always been per-request).
Attached artifacts
helper.log(2 lines, sanitised — token content stripped, exp values preserved).run.log(49 lines, sanitised — turn-by-turn timestamps, the terminalRESULT is_error=trueline, and the throw).- Sanitised prompt + Node driver above are the exact reproducer.
Happy to run additional experiments — the reproducer iterates in ~7 minutes.