fix(cli): refine Claude Code session limit retry handling

This commit is contained in:
Andrey Avtomonov 2026-06-15 15:11:04 +02:00
parent 6ea442717b
commit bc179c2234
2 changed files with 77 additions and 7 deletions

View file

@ -151,15 +151,16 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set<strin
return tools && Object.keys(tools).length > 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set();
}
// "session limit" is the Claude Code subscription cap ("You've hit your session
// limit · resets …"); the rest are transient 429-style throttling. All mean
// Claude Code authenticated successfully, so they must not be read as auth
// failures by the governor classifier or the auth probe.
// Transient 429-style throttling that a retry can clear. Drives both the runtime
// retry classifier and the auth probe message; all mean Claude Code authenticated,
// so they must never surface as auth failures. The subscription cap is excluded —
// see CLAUDE_SESSION_LIMIT_MARKERS — because retrying it only re-hits the cap.
const CLAUDE_RATE_LIMIT_ERROR_MARKERS =
/\b429\b|rate limit|session limit|usage limit|too many requests|quota exceeded|overloaded|max_retries/i;
/\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i;
// The subscription cap is its own case: re-authenticating and retrying both fail
// until reset, so it gets a distinct message from transient rate limiting.
// The subscription cap ("You've hit your session limit · resets …") is the
// non-retryable case: re-authenticating and retrying both fail until reset, so it
// stays out of the retry classifier and gets its own probe message.
const CLAUDE_SESSION_LIMIT_MARKERS = /session limit|usage limit/i;
function describeClaudeProbeFailure(message: string): string {

View file

@ -219,6 +219,75 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
});
});
it('surfaces a Claude Code session limit without retrying the capped query', async () => {
const query = vi.fn((_input: any) =>
stream([
initMessage(),
resultMessage({
subtype: 'error_during_execution',
is_error: true,
result: '',
errors: ["You've hit your session limit · resets 10:50pm (Asia/Saigon)"],
} as never),
]),
);
const runtime = new ClaudeCodeKtxLlmRuntime({
projectDir: '/tmp/project',
modelSlots: { default: 'sonnet' },
query,
env: {},
rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never,
});
// The subscription cap cannot clear until reset, so retrying only re-hits the
// cap; surface it on the first attempt instead of spinning maxRetryAttempts times.
await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).rejects.toThrow(/session limit/);
expect(query).toHaveBeenCalledTimes(1);
});
it('cooperatively retries a session-limit result when the SDK reports a structured rejected reset', async () => {
const waitForReady = vi.fn().mockResolvedValue(undefined);
const report = vi.fn();
const resetAtMs = 1_700_000_000_000;
const query = vi
.fn()
.mockReturnValueOnce(
stream([
{
type: 'rate_limit_event',
rate_limit_info: { status: 'rejected', resetsAt: resetAtMs, rateLimitType: 'five_hour', utilization: 1 },
} as unknown as SDKMessage,
resultMessage({
subtype: 'error_during_execution',
is_error: true,
result: '',
errors: ["You've hit your session limit · resets 10:50pm (Asia/Saigon)"],
} as never),
]),
)
.mockReturnValueOnce(stream([resultMessage({ result: 'ok' })]));
const runtime = new ClaudeCodeKtxLlmRuntime({
projectDir: '/tmp/project',
modelSlots: { default: 'sonnet' },
query,
env: {},
rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never,
});
// A structured rejected event carries the real reset time, so the governor pauses
// until reset and resumes — the session-limit text must not suppress that cooperative
// retry, unlike the bare-string case above where no reset time is available to wait on.
await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok');
expect(query).toHaveBeenCalledTimes(2);
expect(report).toHaveBeenCalledWith({
provider: 'claude-subscription',
status: 'rejected',
resetAtMs,
rateLimitType: 'five_hour',
utilization: 1,
});
});
it('reports Claude Code api retry messages as warning signals', async () => {
const report = vi.fn();
const query = vi.fn((_input: any) =>