anthropics / anthropics/claude-agent-sdk-typescript

Async iterator does not terminate after rate_limit_event - consumer stalls indefinitely on closed SSE stream

Aperta
#403 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
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.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.