fix(cli): stop framing Claude Code session limits as auth failures (KLO-734) (#300)

The Claude Code auth probe wrapped every error as "authentication is not
usable", so a subscription session limit told users to re-authenticate —
which cannot help, since auth already succeeded and only a reset clears it.

Discriminate probe failures with describeClaudeProbeFailure: session-limit
and rate-limit hits get their own messages (preserving the upstream reset
text), and genuine auth errors keep the original guidance. Also add the
session/usage-limit markers to the shared rate-limit classifier so the
governor stops treating a cap as a generic error.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Messiaen 2026-06-15 19:58:03 +07:00 committed by GitHub
parent 7e29543398
commit b81391cd9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 87 additions and 2 deletions

View file

@ -151,7 +151,26 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set<strin
return tools && Object.keys(tools).length > 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set();
}
const CLAUDE_RATE_LIMIT_ERROR_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i;
// "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.
const CLAUDE_RATE_LIMIT_ERROR_MARKERS =
/\b429\b|rate limit|session limit|usage 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.
const CLAUDE_SESSION_LIMIT_MARKERS = /session limit|usage limit/i;
function describeClaudeProbeFailure(message: string): string {
if (CLAUDE_SESSION_LIMIT_MARKERS.test(message)) {
return `Claude Code session limit reached. Wait for the reset shown, then rerun setup or the command. Details: ${message}`;
}
if (CLAUDE_RATE_LIMIT_ERROR_MARKERS.test(message)) {
return `Claude Code is rate limited. Retry shortly, then rerun setup or the command. Details: ${message}`;
}
return `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`;
}
function normalizeClaudeResetAtMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
@ -497,7 +516,7 @@ export async function runClaudeCodeAuthProbe(input: {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
message: `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`,
message: describeClaudeProbeFailure(message),
};
}
}

View file

@ -738,4 +738,70 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
message: 'Unsupported Claude Code model "gpt-5". Use sonnet, opus, haiku, or a claude-* model id.',
});
});
it('reports a Claude Code session limit as a session limit, not an auth or ktx failure', async () => {
const query = vi.fn((_input: any) =>
stream([
initMessage(),
resultMessage({
subtype: 'error_during_execution',
is_error: true,
errors: ["You've hit your session limit · resets 10:50pm (Asia/Saigon)"],
}),
]),
);
const result = await runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: {} });
const message = (result as { message: string }).message;
expect(result.ok).toBe(false);
// Auth worked; the subscription is capped. Must not tell the user to re-authenticate.
expect(message).not.toContain('authentication is not usable');
// Frame it as Claude Code's session limit, tell them to wait, and preserve the reset text.
expect(message).toContain('Claude Code session limit reached');
expect(message).toContain('Wait for the reset shown');
expect(message).toContain('resets 10:50pm (Asia/Saigon)');
});
it('reports a Claude Code rate limit as a rate limit, not an auth failure', async () => {
const query = vi.fn((_input: any) =>
stream([
initMessage(),
resultMessage({
subtype: 'error_during_execution',
is_error: true,
errors: ['API Error: 429 Too Many Requests'],
}),
]),
);
const result = await runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: {} });
const message = (result as { message: string }).message;
expect(result.ok).toBe(false);
expect(message).not.toContain('authentication is not usable');
expect(message).toContain('Claude Code is rate limited');
expect(message).toContain('429 Too Many Requests');
});
it('still reports a genuine auth failure as an auth failure', async () => {
const query = vi.fn((_input: any) =>
stream([
initMessage(),
resultMessage({
subtype: 'error_during_execution',
is_error: true,
errors: ['Invalid API key · Please run `claude login`'],
}),
]),
);
const result = await runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: {} });
const message = (result as { message: string }).message;
expect(result.ok).toBe(false);
expect(message).toContain('authentication is not usable');
expect(message).not.toContain('session limit reached');
expect(message).not.toContain('rate limited');
});
});