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:
Alex Newman
2026-05-09 00:30:00 -07:00
parent a8cbd6515e
commit 8cb73b8c07
2 changed files with 169 additions and 1 deletions

View File

@@ -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 });

View 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');
});
});