mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-25 12:01:03 +02:00
fix(cli): refine Claude Code session limit retry handling
This commit is contained in:
parent
6ea442717b
commit
bc179c2234
2 changed files with 77 additions and 7 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue