diff --git a/changelog.d/fixes/6829-classify-404-model-not-found.md b/changelog.d/fixes/6829-classify-404-model-not-found.md new file mode 100644 index 0000000000..77a741377c --- /dev/null +++ b/changelog.d/fixes/6829-classify-404-model-not-found.md @@ -0,0 +1 @@ +- **fix(providers):** classify upstream `404` responses as `MODEL_NOT_FOUND` (model lockout) instead of a retryable provider error, stopping the retry storm when a single model is missing (#6829 — thanks @AndrianBalanescu). diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 676292a4c5..8319e5ba80 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3306,6 +3306,16 @@ export async function handleChatCore({ console.warn( `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning` ); + } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) { + // 404 — model/endpoint does not exist upstream. Lock the model so the + // retry/backoff loop stops hammering the dead endpoint (which would + // otherwise degenerate into a 429 rate-limit storm). Connection stays + // active since only the specific model is unavailable. (#6827) + const notFoundCooldownMs = COOLDOWN_MS.notFound; + lockModel(provider, errorConnectionId, currentModel, "model_not_found", notFoundCooldownMs); + console.warn( + `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)` + ); } } catch { // Best-effort state update; request flow should continue with fallback handling. diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index f68bebea3d..3d978fec4c 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -76,6 +76,7 @@ export const PROVIDER_ERROR_TYPES = { CONTEXT_OVERFLOW: "context_overflow", OAUTH_INVALID_TOKEN: "oauth_invalid_token", EMPTY_CONTENT: "empty_content", + MODEL_NOT_FOUND: "model_not_found", }; export const CONTEXT_OVERFLOW_SIGNALS = [ @@ -144,6 +145,15 @@ export function classifyProviderError( return PROVIDER_ERROR_TYPES.RATE_LIMITED; } + // 404 — model or endpoint not found. Without classification the error + // falls through to `return null`, so no cooldown/lockout is applied and the + // retry/backoff loop keeps hammering the dead endpoint until the upstream + // rate-limits it (404 + 429 storm). Classify as MODEL_NOT_FOUND so the model + // gets locked via the cooldown layer and retries stop. (#6827) + if (statusCode === 404) { + return PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND; + } + if (statusCode === 401) { if (oauthInvalid) { return PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN; diff --git a/tests/unit/error-classifier.test.ts b/tests/unit/error-classifier.test.ts index c3a2ea1589..ef9b740955 100644 --- a/tests/unit/error-classifier.test.ts +++ b/tests/unit/error-classifier.test.ts @@ -120,3 +120,22 @@ test("classifyProviderError: OAuth provider 429 with daily quota signal => QUOTA ); assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED); }); + +// #6827 — 404 must be classified as MODEL_NOT_FOUND, not fall through to null. +// Without this, no cooldown/lockout is applied and the retry loop keeps hitting +// the dead endpoint until the upstream rate-limits it (404 + 429 storm). +test("classifyProviderError: 404 => MODEL_NOT_FOUND", () => { + const result = classifyProviderError(404, { + error: { message: "model v0-1.5-md not found" }, + }); + assert.equal(result, PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND); +}); + +test("classifyProviderError: 404 with provider => MODEL_NOT_FOUND", () => { + const result = classifyProviderError( + 404, + { error: { message: "Not Found" } }, + "v0-vercel" + ); + assert.equal(result, PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND); +});