anthropics / anthropics/claude-agent-sdk-typescript
Async iterator does not terminate after rate_limit_event - consumer stalls indefinitely on closed SSE stream
- Lingua principale
- Shell
- Stelle
- 1.8k
- Fork
- 226
- Metriche di merge delle PR
- Nessuna PR unita negli ultimi 30g
Descrizione
When Anthropic emits a `rate_limit_event` message inside a streaming `/v1/messages` response and then closes the SSE stream, the SDK's `query()` async iterator (`AsyncIterable`) does not terminate on the consumer side. A `for await (const message of sdkResult)` loop hangs indefinitely — the stream is closed at the transport layer but the iterator never resolves `{ done: true }`.
Consumers with no external timeout will hang forever. Consumers with a wall-clock timeout observe the timeout firing rather than a clean stream end.
### Evidence
Observed in production on `@anthropic-ai/claude-agent-sdk@0.3.197`, 2026-07-26 11:30 SAST (09:30 UTC). Full container log excerpt from our runner:
```
[agent-runner] Starting v2 agent-runner (provider: claude)
[poll-loop] Processing 1 message(s), kinds: chat-sdk
[poll-loop] Error: Rate limit (retryable: false, quota)
[poll-loop] Result: Hey DW.
[poll-loop] B-7 cap fired reason=wall-clock elapsedMs=300001 limitMs=300000 messageCount=4
[claude-provider] Query completed after 4 SDK messages
[poll-loop] Query error: hard cap exceeded reason=wall-clock elapsedMs=300001 limitMs=300000
```
Only **4 SDK messages** in **~300s wall-clock** (`elapsedMs=300001`). Cross-referenced with our TLS-terminating egress proxy:
| Time (UTC) | Event |
|---|---|
| 09:25:15 | `POST /v1/messages?beta=true` → 200, SSE stream opened |
| 09:25:28 → 09:30:11 | **~5 min of silence, no further /v1/messages traffic** |
| 09:30:11 | External wall-clock cap fires, iterator abort |
Anthropic accepted the request, streamed init + rate_limit_event + a degraded result, then closed the stream. The SDK's async iterator did not observe the close.
### Minimal reproduction sketch
A test that mirrors the observed shape by mocking the iterable (this is what our regression test does — the real SDK bug reproduces the same way, replacing our fake with the real SDK call under a rate-limited account):
```ts
async function* rateLimitThenStall() {
yield { type: 'system', subtype: 'init', session_id: 's-1' };
yield { type: 'rate_limit_event' };
await new Promise(() => {}); // stand-in for SDK never terminating
}
```
A consumer doing `for await (const m of sdkResult) { ... }` will not exit unless the loop is externally aborted.
### Proposed fix
When the SDK receives a `rate_limit_event` on the SSE stream, terminate the underlying async iterator (resolve `{ done: true }`) rather than leaving it open. The SSE stream is already closed by Anthropic at this point, so no further messages are coming.
### Our workaround
We break out of the for-await ourselves after yielding the rate-limit error to our internal event bus. See our provider patch (private fork, commit 507e6901) - happy to share the diff on request.
We also carry an external wall-clock cap (5 minutes) as a defense-in-depth measure, which is what surfaced the bug — otherwise the container would have hung forever. Not every downstream consumer will have such a cap.
### Environment
- `@anthropic-ai/claude-agent-sdk@0.3.197`
- Bun 1.3.14 runtime, headless container (Docker)
- Anthropic API accessed via a MITM proxy (TLS re-terminated, request/response bodies unchanged) — same behavior confirmed against direct API access as well
### Follow-up question for Anthropic — which limit fires this event?
**Auth context**: our container authenticates via OAuth session tokens (injected by a per-agent credential proxy — the SDK sees a normal Anthropic-issued OAuth Bearer token). This affects the framing of the follow-up: the limits below are the ones enforced against an OAuth-authenticated request, not a raw API-key request.
Observed 3 hits in production over 3 days (2026-07-26 → 2026-07-28), all classified `quota` on our side because the SDK message shape (`rate_limit_event`) doesn't distinguish the underlying limit type. Our Anthropic console shows daily quota well within budget when these fire, which strongly suggests these are **burst / per-minute limits** (requests-per-minute or tokens-per-minute) rather than 24h quota exhaustion.
Would appreciate clarification on:
1. Which specific limit type triggers `rate_limit_event` in the streaming path — RPM, TPM, ITPM (input tokens/minute), OTPM (output tokens/minute), or account-level daily quota?
2. Whether the event payload carries a machine-readable indicator of *which* limit was hit (currently we can't distinguish burst throttling from real quota exhaustion, so we can't backoff-and-retry vs. surface-and-fail differently).
3. Recommended client-side handling: is the correct pattern to wait N seconds and resume the same session, or to abandon the turn?
If (1) is documented somewhere I've missed, a link is fine.
Guida per i contributori
Nessuna guida per i contributori indicizzata per questo repository
Valutazione
Questa issue non è ancora stata valutata.