diff --git a/src/services/worker/ClaudeProvider.ts b/src/services/worker/ClaudeProvider.ts index e7cf54654..054fa3893 100644 --- a/src/services/worker/ClaudeProvider.ts +++ b/src/services/worker/ClaudeProvider.ts @@ -136,6 +136,19 @@ export function classifyClaudeError(err: unknown): ClassifiedProviderError { ); } + // Status-less Anthropic 400s — SDK wrapping can drop `.status`, leaving only + // the message or an `invalid_request_error` body; classify those as + // unrecoverable so the worker stops retrying a permanent config error (#2656). + // The status guard keeps statused 4xx/5xx on their own branches. + if ( + typeof errAny.status !== 'number' && + (errAny.error?.type === 'invalid_request_error' || + /\bthe provided model identifier is invalid\b/i.test(message) || + /\binvalid_request_error\b/i.test(message)) + ) { + return new ClassifiedProviderError(message, { 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 }); diff --git a/tests/claude-provider-error-classifier.test.ts b/tests/claude-provider-error-classifier.test.ts index 78b35e6f1..df27945f8 100644 --- a/tests/claude-provider-error-classifier.test.ts +++ b/tests/claude-provider-error-classifier.test.ts @@ -120,3 +120,74 @@ describe('classifyClaudeError — sibling status codes (regression sanity)', () expect(classified.kind).toBe('transient'); }); }); + +/** + * Regression coverage for #2656: when the Anthropic Agent SDK wraps a 400 + * `invalid_request_error` (e.g. "The provided model identifier is invalid") + * the `.status` field can be lost in the wrapping. Without a message-based + * fallback the error fell through to the default `transient` branch and the + * worker retried indefinitely while `/health` kept reporting `ok`. + */ +describe('classifyClaudeError — model identifier rejections without .status (#2656)', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + __resetEffortHintLatchForTesting(); + warnSpy = spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + __resetEffortHintLatchForTesting(); + }); + + it('classifies "The provided model identifier is invalid" as unrecoverable even without a status field', () => { + const sdkErr = new Error('The provided model identifier is invalid'); + const classified = classifyClaudeError(sdkErr); + expect(classified.kind).toBe('unrecoverable'); + }); + + it('classifies wrapped errors exposing error.type=invalid_request_error as unrecoverable', () => { + const sdkErr = Object.assign( + new Error('Anthropic SDK error'), + { error: { type: 'invalid_request_error' } }, + ); + const classified = classifyClaudeError(sdkErr); + expect(classified.kind).toBe('unrecoverable'); + }); + + it('classifies errors carrying the "invalid_request_error" string in the message as unrecoverable', () => { + const sdkErr = new Error('Request failed: invalid_request_error from upstream'); + const classified = classifyClaudeError(sdkErr); + expect(classified.kind).toBe('unrecoverable'); + }); + + it('does not match unrelated messages containing the word "invalid"', () => { + const sdkErr = new Error('Some unrelated invalid input from a tool'); + const classified = classifyClaudeError(sdkErr); + // Must NOT be unrecoverable just because the word "invalid" appears — + // matching is anchored on the canonical Anthropic phrases only. + expect(classified.kind).toBe('transient'); + }); + + it('still routes statused 400s through the existing branch (does not fall through)', () => { + const sdkErr = Object.assign( + new Error('The provided model identifier is invalid'), + { status: 400 }, + ); + const classified = classifyClaudeError(sdkErr); + expect(classified.kind).toBe('unrecoverable'); + // The pre-existing status=400 branch handles this case before the new + // fallback runs; no effort-hint should fire (no effort marker present). + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('keeps a statused 5xx carrying invalid_request_error transient (status guard)', () => { + const sdkErr = Object.assign( + new Error('gateway error: invalid_request_error from upstream'), + { status: 503, error: { type: 'invalid_request_error' } }, + ); + const classified = classifyClaudeError(sdkErr); + expect(classified.kind).toBe('transient'); + }); +});