Files
thedotmack--claude-mem/tests/worker/provider-classifiers.test.ts
wehub-resource-sync f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

303 lines
11 KiB
TypeScript

import { describe, it, expect } from 'bun:test';
import {
ClassifiedProviderError,
isClassified,
} from '../../src/services/worker/provider-errors.js';
import { classifyClaudeError } from '../../src/services/worker/ClaudeProvider.js';
import {
categorizeGeminiBadRequest,
classifyGeminiError,
} from '../../src/services/worker/GeminiProvider.js';
import { classifyOpenRouterError } from '../../src/services/worker/OpenRouterProvider.js';
// Hard cases per F4 spec — provider-specific classifiers must map raw HTTP
// shapes / SDK errors to ClassifiedProviderError with the right kind.
describe('classifyGeminiError', () => {
for (const [category, bodyText] of [
['role_sequence', 'Please ensure that multiturn requests alternate between user and model.'],
['context_limit', 'Request contains 120000 tokens which exceeds the maximum token limit.'],
['model_unsupported', 'Model gemini-example is not supported for generateContent.'],
['api_key', 'API_KEY_INVALID: API key not valid.'],
['unknown_bad_request', 'Invalid JSON payload received. Unknown name "foo".'],
] as const) {
it(`categorizes Gemini 400 bad request body as ${category}`, () => {
expect(categorizeGeminiBadRequest(bodyText)).toBe(category);
});
}
it('classifies 429 with no Retry-After as rate_limit with no retryAfterMs', () => {
const headers = new Headers(); // no Retry-After
const cause = new Error('Gemini API error: 429 - quota');
const err = classifyGeminiError({
status: 429,
bodyText: 'Too Many Requests',
headers,
cause,
});
expect(isClassified(err)).toBe(true);
expect(err.kind).toBe('rate_limit');
expect(err.retryAfterMs).toBeUndefined();
expect(err.cause).toBeInstanceOf(Error);
expect((err.cause as Error).message).toContain('status 429');
expect((err.cause as Error).message).not.toContain('quota');
});
it('classifies 429 with Retry-After: 5 as rate_limit with retryAfterMs=5000', () => {
const headers = new Headers({ 'Retry-After': '5' });
const err = classifyGeminiError({
status: 429,
bodyText: '',
headers,
cause: new Error('rate limited'),
});
expect(err.kind).toBe('rate_limit');
expect(err.retryAfterMs).toBe(5000);
});
it('classifies 500 with body containing "quota exceeded" as quota_exhausted', () => {
const err = classifyGeminiError({
status: 500,
bodyText: 'Internal: quota exceeded for model',
cause: new Error('500 - quota exceeded'),
});
expect(err.kind).toBe('quota_exhausted');
expect(err.retryAfterMs).toBeUndefined();
});
it('classifies 401 with "API key not valid" body as auth_invalid', () => {
const err = classifyGeminiError({
status: 401,
bodyText: 'API key not valid. Please pass a valid API key.',
cause: new Error('401'),
});
expect(err.kind).toBe('auth_invalid');
});
it('classifies 403 PERMISSION_DENIED as auth_invalid', () => {
const err = classifyGeminiError({
status: 403,
bodyText: 'PERMISSION_DENIED',
cause: new Error('403'),
});
expect(err.kind).toBe('auth_invalid');
});
it('classifies 503 as transient', () => {
const err = classifyGeminiError({
status: 503,
bodyText: 'service unavailable',
cause: new Error('503'),
});
expect(err.kind).toBe('transient');
});
it('classifies network error (no status) as transient', () => {
const cause = new Error('fetch failed: ECONNREFUSED');
const err = classifyGeminiError({ cause });
expect(err.kind).toBe('transient');
expect(err.cause).toBe(cause);
});
it('classifies 400 as unrecoverable with a stable category message', () => {
const rawBody = 'Please ensure that multiturn requests alternate between user and model. RAW_PROVIDER_BODY';
const cause = new Error(`400 - ${rawBody}`);
const err = classifyGeminiError({
status: 400,
bodyText: rawBody,
cause,
});
expect(err.kind).toBe('unrecoverable');
expect(err.message).toBe('Gemini bad request: role_sequence');
expect(err.message).not.toContain('RAW_PROVIDER_BODY');
expect(err.cause).toBeInstanceOf(Error);
expect((err.cause as Error).message).toContain('status 400');
expect((err.cause as Error).message).not.toContain('RAW_PROVIDER_BODY');
});
it('redacts non-400 fallback response bodies from message and cause', () => {
const rawBody = 'RAW_PROVIDER_BODY with credential sk-secret';
const err = classifyGeminiError({
status: 418,
bodyText: rawBody,
cause: new Error(`Gemini API error: 418 - ${rawBody}`),
requestId: 'gemini-request-1',
});
expect(err.kind).toBe('unrecoverable');
expect(err.message).toBe('Gemini API error (status 418)');
expect(err.message).not.toContain(rawBody);
expect(err.cause).toBeInstanceOf(Error);
expect((err.cause as Error).message).toContain('status 418');
expect((err.cause as Error).message).toContain('gemini-request-1');
expect((err.cause as Error).message).not.toContain(rawBody);
});
});
describe('classifyOpenRouterError', () => {
it('classifies 429 with no Retry-After as rate_limit with no retryAfterMs', () => {
const headers = new Headers(); // no Retry-After
const err = classifyOpenRouterError({
status: 429,
bodyText: 'rate limit exceeded',
headers,
cause: new Error('429'),
});
expect(err.kind).toBe('rate_limit');
expect(err.retryAfterMs).toBeUndefined();
});
it('classifies 429 with Retry-After: 10 as rate_limit with retryAfterMs=10000', () => {
const headers = new Headers({ 'retry-after': '10' });
const err = classifyOpenRouterError({
status: 429,
bodyText: '',
headers,
cause: new Error('429'),
});
expect(err.kind).toBe('rate_limit');
expect(err.retryAfterMs).toBe(10_000);
});
it('classifies 500 with body containing "quota exceeded" as quota_exhausted', () => {
const err = classifyOpenRouterError({
status: 500,
bodyText: 'something quota exceeded',
cause: new Error('500'),
});
expect(err.kind).toBe('quota_exhausted');
});
it('classifies "insufficient credits" body as quota_exhausted regardless of status', () => {
const err = classifyOpenRouterError({
status: 402,
bodyText: 'insufficient credits',
cause: new Error('402'),
});
expect(err.kind).toBe('quota_exhausted');
});
it('classifies 401 as auth_invalid', () => {
const err = classifyOpenRouterError({
status: 401,
bodyText: 'unauthorized',
cause: new Error('401'),
});
expect(err.kind).toBe('auth_invalid');
});
it('classifies 502 as transient', () => {
const err = classifyOpenRouterError({
status: 502,
bodyText: 'bad gateway',
cause: new Error('502'),
});
expect(err.kind).toBe('transient');
});
it('classifies network error (no status) as transient', () => {
const cause = new Error('ECONNRESET');
const err = classifyOpenRouterError({ cause });
expect(err.kind).toBe('transient');
});
});
describe('classifyClaudeError', () => {
it('classifies SDK-level OverloadedError as transient', () => {
class OverloadedError extends Error {
constructor() {
super('Overloaded');
this.name = 'OverloadedError';
}
}
const err = classifyClaudeError(new OverloadedError());
expect(isClassified(err)).toBe(true);
expect(err.kind).toBe('transient');
});
it('classifies 529 status as transient', () => {
const sdkErr = Object.assign(new Error('overloaded'), { status: 529 });
const err = classifyClaudeError(sdkErr);
expect(err.kind).toBe('transient');
});
it('classifies anthropic error.type=overloaded_error as transient', () => {
const sdkErr = Object.assign(new Error('upstream'), {
error: { type: 'overloaded_error' },
});
const err = classifyClaudeError(sdkErr);
expect(err.kind).toBe('transient');
});
it('classifies "Invalid API key" message as auth_invalid', () => {
const err = classifyClaudeError(new Error('Invalid API key: configure ~/.claude-mem/.env'));
expect(err.kind).toBe('auth_invalid');
});
it('classifies status=401 as auth_invalid', () => {
const sdkErr = Object.assign(new Error('unauthorized'), { status: 401 });
const err = classifyClaudeError(sdkErr);
expect(err.kind).toBe('auth_invalid');
});
it('classifies ENOENT spawn error as setup_required', () => {
const spawnErr = Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' });
const err = classifyClaudeError(spawnErr);
expect(err.kind).toBe('setup_required');
});
it('classifies "Claude executable not found" as setup_required', () => {
const err = classifyClaudeError(new Error('Claude executable not found at $CLAUDE_CODE_PATH'));
expect(err.kind).toBe('setup_required');
});
it('classifies too-old Claude CLI finder errors as setup_required', () => {
const err = classifyClaudeError(
new Error(
'Every Claude CLI found is too old for claude-mem (each rejects flags the memory agent passes on every spawn)'
)
);
expect(err.kind).toBe('setup_required');
});
it('classifies desktop app headless-mode setup error as setup_required', () => {
const err = classifyClaudeError(
new Error(
'Found desktop app at "/Applications/Claude.app" but it doesn\'t support headless mode. Install Claude Code CLI: npm install -g @anthropic-ai/claude-code'
)
);
expect(err.kind).toBe('setup_required');
});
it('classifies prompt-too-long as unrecoverable', () => {
const err = classifyClaudeError(new Error('Claude session context overflow: prompt is too long'));
expect(err.kind).toBe('unrecoverable');
});
it('classifies structured context-window errors as unrecoverable', () => {
const err = classifyClaudeError(new Error('Claude SDK error: context window exceeded'));
expect(err.kind).toBe('unrecoverable');
});
it('classifies status=429 as rate_limit', () => {
const sdkErr = Object.assign(new Error('rate limited'), { status: 429 });
const err = classifyClaudeError(sdkErr);
expect(err.kind).toBe('rate_limit');
});
it('classifies "quota exceeded" message as quota_exhausted', () => {
const err = classifyClaudeError(new Error('upstream: quota exceeded'));
expect(err.kind).toBe('quota_exhausted');
});
it('classifies status=503 as transient', () => {
const sdkErr = Object.assign(new Error('service unavailable'), { status: 503 });
const err = classifyClaudeError(sdkErr);
expect(err.kind).toBe('transient');
});
it('classifies unknown error as transient (preserve old default)', () => {
const err = classifyClaudeError(new Error('something weird happened'));
expect(err.kind).toBe('transient');
});
});