From 4f474d655b88a075ec7d0124d45cafa0f5565af7 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Fri, 13 Nov 2026 05:31:11 -0800 Subject: [PATCH] feat: report the turn's rate limit in rate-limit failure errors (#1610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a turn fails with `errorKind: "rate_limit"`, the error's `data` now also carries the turn's latest `rate_limit_event` info as `rateLimit`: ```json "data": { "errorKind": "rate_limit", "rateLimit": { "status": "rejected", "rateLimitType": "five_hour", "resetsAt": 1763002200 } } ``` ## Why ACP clients may want to handle a rate-limit failure better than showing its message, for example by showing when the limit resets or offering to continue the turn then. That needs the reset time as data. Today the adapter forwards `rate_limit_event` info only on a `usage_update` (`_meta["_claude/rateLimit"]`). A message sent while the account is already over its limit is refused before its first usage, so no `usage_update` goes out and the client gets only: ```json "error": { "code": -32603, "message": "Internal error: You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)", "data": { "errorKind": "rate_limit" } } ``` Without this change, the only way to get the reset time is parsing that text. ## What changed - `turnErrorData` in `src/result-end.ts` builds the `data` of the error that fails a turn. It adds `rateLimit` only for a `rate_limit` failure in a turn that saw a `rate_limit_event`. Other failures keep their current data. - v2: `TurnReport` records the turn's latest `rate_limit_event` info and clears it on `reset()`. `idleOutcome` passes it to `turnErrorData`. - v1: the prompt loop tracks `turnRateLimit` alongside `lastAssistantError`, resets it with the other per-turn scratch state, and passes it at the three result-failure sites that used `errorKindData(lastAssistantError)`. ## Testing - New v1 and v2 tests: a `rate_limit_event` followed by a `rate_limit` failure puts `rateLimit` in the error data. - `npm run build`, `npm run check` and `npm run test:run` pass. - Manually tested with a locally built binary: a client received `rateLimit` in the error for a message refused at the 5-hour limit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- src/result-end.ts | 17 +++++++++++++++++ src/tests/v1/acp-agent.test.ts | 24 ++++++++++++++++++++++++ src/tests/v2/v2.test.ts | 23 +++++++++++++++++++++++ src/v1/acp-agent.ts | 22 ++++++++++++++++++---- src/v2/outcome.ts | 8 ++++++-- src/v2/session.ts | 1 + 6 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/result-end.ts b/src/result-end.ts index 9a117515..a8557598 100644 --- a/src/result-end.ts +++ b/src/result-end.ts @@ -8,9 +8,26 @@ import type { SDKMessage, SDKMessageOrigin, + SDKRateLimitInfo, SDKResultMessage, } from "@anthropic-ai/claude-agent-sdk"; +/** + * The `data` of the error that fails a turn, for clients to dispatch on + * without matching its text: the assistant message's `errorKind`, and for a + * `rate_limit` failure the turn's latest `rate_limit_event` info as + * `rateLimit`, whose `resetsAt` says when the account can run again. A turn + * refused before its first usage sends no `usage_update` to carry that info. + * `undefined` without an error kind. + */ +export function turnErrorData( + errorKind: K | undefined, + rateLimit: SDKRateLimitInfo | undefined, +): { errorKind: K; rateLimit?: SDKRateLimitInfo } | undefined { + if (!errorKind) return undefined; + return errorKind === "rate_limit" && rateLimit ? { errorKind, rateLimit } : { errorKind }; +} + export type ResultEnd = | { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" } /** diff --git a/src/tests/v1/acp-agent.test.ts b/src/tests/v1/acp-agent.test.ts index c9a064f4..bff13dae 100644 --- a/src/tests/v1/acp-agent.test.ts +++ b/src/tests/v1/acp-agent.test.ts @@ -13406,6 +13406,30 @@ describe("stop reason propagation", () => { expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" }); }); + it("adds the turn's rate limit to a rate-limit failure's data", async () => { + const agent = createMockAgent(); + const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" }; + injectSession(agent, [ + { + type: "rate_limit_event", + rate_limit_info: rateLimit, + uuid: randomUUID(), + session_id: "test-session", + }, + createAssistantError("rate_limit"), + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)", + }), + ]); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }), + ).rejects.toMatchObject({ data: { errorKind: "rate_limit", rateLimit } }); + }); + it.each( [false, true].flatMap((typedFailures) => [undefined, "authentication_failed" as const].map((staleError) => ({ diff --git a/src/tests/v2/v2.test.ts b/src/tests/v2/v2.test.ts index ba6b7d4e..a5baf5c5 100644 --- a/src/tests/v2/v2.test.ts +++ b/src/tests/v2/v2.test.ts @@ -2467,6 +2467,29 @@ describe("ACP v2 core", () => { }); }); + it("adds the turn's rate limit to a rate-limit failure's error data", async () => { + const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" }; + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send({ + type: "rate_limit_event", + rate_limit_info: rateLimit, + uuid: "00000000-0000-4000-8000-00000000e002", + session_id: "session", + }); + claude.send({ ...assistant("msg_limited", []), error: "rate_limit" }); + claude.send(result({ is_error: true, result: "You've hit your 5-hour limit" })); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ + stopReason: "error", + error: { data: { errorKind: "rate_limit", rateLimit } }, + }); + }); + it.each([ ["a notice when the turn goes on to end", { result: "" }, "end_turn", 1], ["a notice once when the result repeats its text", {}, "end_turn", 1], diff --git a/src/v1/acp-agent.ts b/src/v1/acp-agent.ts index 707898b3..77214ae4 100644 --- a/src/v1/acp-agent.ts +++ b/src/v1/acp-agent.ts @@ -76,6 +76,7 @@ import { Query, query, SDKAssistantMessageError, + SDKRateLimitInfo, SDKActiveGoalMessage, SDKMessage, SDKMessageOrigin, @@ -421,7 +422,7 @@ import { providerCacheKeyFor, readContextWindow, } from "../context-window.js"; -import { isLoginRequiredText, resultEnd } from "../result-end.js"; +import { isLoginRequiredText, resultEnd, turnErrorData } from "../result-end.js"; import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "../startup-failure.js"; import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js"; import { subagentHookOptions } from "../subagent-configuration.js"; @@ -3094,6 +3095,8 @@ export class ClaudeAcpAgent { let lastAssistantModel: string | null = null; // A rate_limit_event that came before the turn had any usage to report. let pendingRateLimit: unknown = null; + // The turn's latest rate_limit_event info, for its failure (see `turnErrorData`). + let turnRateLimit: SDKRateLimitInfo | undefined; // When the Claude SDK classifies a turn as failed (e.g. rate limit, auth // problem, billing), it sets a categorical `error` field on the // `SDKAssistantMessage` that precedes the final `result` message. We capture @@ -3461,6 +3464,7 @@ export class ClaudeAcpAgent { lastAssistantUsage = null; lastAssistantModel = null; lastAssistantError = undefined; + turnRateLimit = undefined; lastAssistantWasUsageLimit = false; lastAssistantFailureTitle = undefined; lastRefusalExplanation = null; @@ -6079,7 +6083,10 @@ export class ClaudeAcpAgent { } await failActiveWithSessionFailure( providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), - internalErrorForClient(errorKindData(lastAssistantError), ended.error), + internalErrorForClient( + turnErrorData(lastAssistantError, turnRateLimit), + ended.error, + ), lastAssistantFailureTitle ?? ended.error, ); break; @@ -6139,7 +6146,10 @@ export class ClaudeAcpAgent { if ("error" in ended) { await failActiveWithSessionFailure( providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), - internalErrorForClient(errorKindData(lastAssistantError), ended.error), + internalErrorForClient( + turnErrorData(lastAssistantError, turnRateLimit), + ended.error, + ), lastAssistantFailureTitle ?? ended.error, ); break; @@ -6152,7 +6162,10 @@ export class ClaudeAcpAgent { if ("error" in ended) { await failActiveWithSessionFailure( LIMIT_FAILURE_CATEGORY[message.subtype], - internalErrorForClient(errorKindData(lastAssistantError), ended.error), + internalErrorForClient( + turnErrorData(lastAssistantError, turnRateLimit), + ended.error, + ), ended.error, ); break; @@ -6941,6 +6954,7 @@ export class ClaudeAcpAgent { } case "rate_limit_event": { session.idleCompaction?.noteRateLimit(message.rate_limit_info); + turnRateLimit = message.rate_limit_info; // With partial messages on, the SDK sends this before the turn's // first assistant frame, while there is no usage yet to send it with. if (lastAssistantTotalUsage === null) { diff --git a/src/v2/outcome.ts b/src/v2/outcome.ts index bda779a5..f44e2859 100644 --- a/src/v2/outcome.ts +++ b/src/v2/outcome.ts @@ -12,11 +12,12 @@ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import type { SDKAssistantMessage, SDKAssistantMessageError, + SDKRateLimitInfo, SDKResultMessage, } from "@anthropic-ai/claude-agent-sdk"; import { AUTONOMOUS_RESULT_ORIGINS } from "../result-end.js"; import { isSyntheticLoginMessage } from "../message-filters.js"; -import { resultEnd } from "../result-end.js"; +import { resultEnd, turnErrorData } from "../result-end.js"; import { assistantMessageText, providerFailureCategory } from "../session-failure-extension.js"; import { getMatchingModelUsage, @@ -96,7 +97,7 @@ export function idleOutcome( // authenticate: this login reached its time limit and cannot be // renewed." RequestError.authRequired(undefined, ended.loginRequired ? undefined : ended.error) - : RequestError.internalError(kind ? { errorKind: kind } : undefined, ended.error), + : RequestError.internalError(turnErrorData(kind, report?.rateLimit), ended.error), ); } @@ -118,6 +119,8 @@ function failed(error: RequestError): IdleOutcome { */ export class TurnReport { errorKind: SDKAssistantMessageError | undefined; + /** The turn's latest `rate_limit_event` info (see `turnErrorData`). */ + rateLimit: SDKRateLimitInfo | undefined; /** Claude Code showed its sign-in message ("Not logged in · Please run /login"). */ loginRequired = false; /** The explanation of the turn's refusal. */ @@ -181,6 +184,7 @@ export class TurnReport { reset(): void { this.errorKind = undefined; + this.rateLimit = undefined; this.loginRequired = false; this.refusalExplanation = undefined; this.apiError = undefined; diff --git a/src/v2/session.ts b/src/v2/session.ts index b4c37f6d..74fcd4ac 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -1044,6 +1044,7 @@ export class V2Session implements RequestTarget { * output (see {@link SessionReports}). */ private report(message: SDKMessage): void { + if (message.type === "rate_limit_event") this.turnReport.rateLimit = message.rate_limit_info; if (message.type === "tool_progress") { this.output.toolProgress(message); return;