Bug: single streaming HTTP request outlasting bearer TTL is killed mid-stream; no in-stream renewal path

Open 💬 0 comments Opened Jul 8, 2026 by LucasRelva

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 — an is_error: true result 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 after result=success reuses 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: claude version 2.1.202 (per SDK manifest.json)
  • Model: claude-sonnet-4-6
  • Auth path: apiKeyHelper in ~/.claude/settings.json minting 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=60000 is set on the environment; apiKeyHelper DOES 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 apiKeyHelper calls 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:

  1. 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?), invoke apiKeyHelper synchronously 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.
  2. In-stream renewal. When approaching the current bearer's exp during an open stream, invoke apiKeyHelper and 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.
  3. 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 — apiKeyHelper fires 23 times at 60–70 s intervals across 24 min of --input-format stream-json operation.
  • "CLAUDE_CODE_API_KEY_HELPER_TTL_MS isn'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 exp at 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 (apiKeyHelper has 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 terminal RESULT is_error=true line, and the throw).
  • Sanitised prompt + Node driver above are the exact reproducer.

Happy to run additional experiments — the reproducer iterates in ~7 minutes.

View original on GitHub ↗