mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-07-06 14:02:05 +08:00
fix(worker): classify Claude SDK HTTP 400 as unrecoverable
ClaudeProvider previously had no explicit HTTP 400 handling — the default branch classified all errors as `transient`, so a permanent 400 (e.g., model rejecting an `effort` parameter forwarded from a leaked CLAUDE_CODE_EFFORT_LEVEL) would be retried indefinitely (#1874+ retries observed in one session per #2357). Mirror GeminiProvider/OpenRouterProvider's pattern: classify 400 as `unrecoverable`, 401/403 as `auth_invalid`, 429 as `rate_limit`, default to `transient`. When the 400 body matches the "effort parameter" signature, emit a one-time SDK warn log pointing at the env-leak fix in ~/.claude-mem/.env. Adds tests/claude-provider-error-classifier.test.ts. Fixes #2357. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,19 @@ import {
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { ClassifiedProviderError } from './provider-errors.js';
|
||||
|
||||
/**
|
||||
* Module-scoped guard so the "effort parameter" hint only fires once per
|
||||
* worker process. The underlying cause (a leaked CLAUDE_CODE_EFFORT_LEVEL in
|
||||
* ~/.claude-mem/.env, see #2357) is environmental — re-logging it on every
|
||||
* SDK call would spam the logs without adding signal.
|
||||
*
|
||||
* Exported solely for tests to reset the latch between cases.
|
||||
*/
|
||||
let effortHintLogged = false;
|
||||
export function __resetEffortHintLatchForTesting(): void {
|
||||
effortHintLogged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a ClaudeProvider error (executable spawn failures, SDK errors,
|
||||
* Anthropic API errors). Provider-specific because it relies on:
|
||||
@@ -36,7 +49,7 @@ import { ClassifiedProviderError } from './provider-errors.js';
|
||||
*/
|
||||
export function classifyClaudeError(err: unknown): ClassifiedProviderError {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const errAny = err as { name?: string; status?: number; error?: { type?: string } };
|
||||
const errAny = err as { name?: string; status?: number; error?: { type?: string }; body?: unknown };
|
||||
|
||||
// Executable / spawn issues — unrecoverable, no point retrying.
|
||||
if (
|
||||
@@ -88,6 +101,39 @@ export function classifyClaudeError(err: unknown): ClassifiedProviderError {
|
||||
return new ClassifiedProviderError(message, { kind: 'unrecoverable', cause: err });
|
||||
}
|
||||
|
||||
// HTTP 400 from the Anthropic SDK — bad request, never recoverable. Mirrors
|
||||
// the pattern in GeminiProvider.classifyGeminiError / classifyOpenRouterError
|
||||
// (see #2357: the SDK forwards `effort` to the Messages API when
|
||||
// CLAUDE_CODE_EFFORT_LEVEL leaks into the subprocess env, and models like
|
||||
// Haiku/Sonnet 4.5 reject with 400 — without this branch the default
|
||||
// `transient` classification retried indefinitely).
|
||||
if (errAny.status === 400) {
|
||||
// Inspect both the message and any structured body for the effort marker.
|
||||
const bodyText = (() => {
|
||||
const body = errAny.body;
|
||||
if (typeof body === 'string') return body;
|
||||
if (body && typeof body === 'object') {
|
||||
try { return JSON.stringify(body); } catch { return ''; }
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
const haystack = `${message}\n${bodyText}`;
|
||||
if (/effort parameter/i.test(haystack) && !effortHintLogged) {
|
||||
effortHintLogged = true;
|
||||
logger.warn(
|
||||
'SDK',
|
||||
'Anthropic API rejected request with HTTP 400: this model does not support the `effort` parameter. ' +
|
||||
'CLAUDE_CODE_EFFORT_LEVEL is likely leaking into the SDK subprocess env via ~/.claude-mem/.env — ' +
|
||||
'remove it or scope it to models that support effort. See https://github.com/thedotmack/claude-mem/issues/2357.',
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return new ClassifiedProviderError(
|
||||
message || 'Anthropic bad request (status 400)',
|
||||
{ kind: 'unrecoverable', cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
// Server errors → transient.
|
||||
if (typeof errAny.status === 'number' && errAny.status >= 500 && errAny.status < 600) {
|
||||
return new ClassifiedProviderError(message, { kind: 'transient', cause: err });
|
||||
|
||||
122
tests/claude-provider-error-classifier.test.ts
Normal file
122
tests/claude-provider-error-classifier.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
|
||||
import {
|
||||
classifyClaudeError,
|
||||
__resetEffortHintLatchForTesting,
|
||||
} from '../src/services/worker/ClaudeProvider.js';
|
||||
import { isClassified } from '../src/services/worker/provider-errors.js';
|
||||
import { logger } from '../src/utils/logger.js';
|
||||
|
||||
/**
|
||||
* Tests for HTTP 400 classification in ClaudeProvider's classifyClaudeError.
|
||||
*
|
||||
* Regression coverage for #2357: ClaudeProvider previously had no explicit
|
||||
* HTTP 400 handling, so the default branch classified all 400s as `transient`
|
||||
* and the retry loop would hammer a permanent error indefinitely (e.g. when
|
||||
* CLAUDE_CODE_EFFORT_LEVEL leaks into the SDK subprocess and the model
|
||||
* rejects the `effort` parameter).
|
||||
*/
|
||||
describe('classifyClaudeError — HTTP 400 handling (#2357)', () => {
|
||||
let warnSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
__resetEffortHintLatchForTesting();
|
||||
warnSpy = spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
__resetEffortHintLatchForTesting();
|
||||
});
|
||||
|
||||
it('classifies 400 with "effort parameter" body as unrecoverable AND logs an SDK warn once', () => {
|
||||
const sdkErr = Object.assign(
|
||||
new Error('This model does not support the effort parameter.'),
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const classified = classifyClaudeError(sdkErr);
|
||||
|
||||
expect(isClassified(classified)).toBe(true);
|
||||
expect(classified.kind).toBe('unrecoverable');
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
// First positional arg of logger.warn is the component category.
|
||||
const [component, hintMessage] = warnSpy.mock.calls[0] as [string, string, ...unknown[]];
|
||||
expect(component).toBe('SDK');
|
||||
expect(hintMessage).toMatch(/effort/i);
|
||||
expect(hintMessage).toMatch(/2357/);
|
||||
});
|
||||
|
||||
it('classifies 400 with effort marker in a structured body field', () => {
|
||||
const sdkErr = Object.assign(
|
||||
new Error('Bad request'),
|
||||
{
|
||||
status: 400,
|
||||
body: { error: { message: 'This model does not support the effort parameter.' } },
|
||||
},
|
||||
);
|
||||
|
||||
const classified = classifyClaudeError(sdkErr);
|
||||
|
||||
expect(classified.kind).toBe('unrecoverable');
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('classifies 400 without effort body as unrecoverable WITHOUT firing the effort hint', () => {
|
||||
const sdkErr = Object.assign(
|
||||
new Error('some other 400 error'),
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const classified = classifyClaudeError(sdkErr);
|
||||
|
||||
expect(classified.kind).toBe('unrecoverable');
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throttles the effort hint to one log per process even on repeated 400s', () => {
|
||||
const sdkErr = Object.assign(
|
||||
new Error('This model does not support the effort parameter.'),
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const classified = classifyClaudeError(sdkErr);
|
||||
expect(classified.kind).toBe('unrecoverable');
|
||||
}
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyClaudeError — sibling status codes (regression sanity)', () => {
|
||||
let warnSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
__resetEffortHintLatchForTesting();
|
||||
warnSpy = spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
__resetEffortHintLatchForTesting();
|
||||
});
|
||||
|
||||
it('classifies status=401 as auth_invalid', () => {
|
||||
const sdkErr = Object.assign(new Error('unauthorized'), { status: 401 });
|
||||
const classified = classifyClaudeError(sdkErr);
|
||||
expect(classified.kind).toBe('auth_invalid');
|
||||
});
|
||||
|
||||
it('classifies status=429 as rate_limit', () => {
|
||||
const sdkErr = Object.assign(new Error('rate limited'), { status: 429 });
|
||||
const classified = classifyClaudeError(sdkErr);
|
||||
expect(classified.kind).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('classifies a network error with no status as transient', () => {
|
||||
const networkErr = new Error('ECONNRESET: socket hang up');
|
||||
const classified = classifyClaudeError(networkErr);
|
||||
expect(classified.kind).toBe('transient');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user