From 35541c06cdf6bb70a4c2b79f8781cbd149980955 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 09:36:17 -0300 Subject: [PATCH] fix(providers): carve cookie-auth providers out of terminal 401 'expired' classification so one 401 cooldowns instead of killing the connection (#8321) --- .../fixes/8200-perplexity-web-401-cooldown.md | 1 + src/sse/services/auth.ts | 23 +++-- .../8200-perplexity-web-401-cooldown.test.ts | 84 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/8200-perplexity-web-401-cooldown.md create mode 100644 tests/unit/8200-perplexity-web-401-cooldown.test.ts diff --git a/changelog.d/fixes/8200-perplexity-web-401-cooldown.md b/changelog.d/fixes/8200-perplexity-web-401-cooldown.md new file mode 100644 index 0000000000..83742bdef3 --- /dev/null +++ b/changelog.d/fixes/8200-perplexity-web-401-cooldown.md @@ -0,0 +1 @@ +- fix(providers): carve cookie-auth providers out of terminal 401 'expired' classification so one 401 cooldowns instead of killing the connection diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index b7e8341cf2..9e3f689c44 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -373,10 +373,21 @@ function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean return status === "credits_exhausted" || status === "banned" || status === "expired"; } +// #8200: cookie-auth providers (perplexity-web, grok-web, ...) use a rotating browser +// session, not a static API key — a 401 means "session needs a refresh", not "dead". +function isRecoverableCookieAuth401(provider: string | null, providerErrorType: string | null): boolean { + return ( + providerErrorType !== PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED && + provider != null && + resolveProviderId(provider) in WEB_COOKIE_PROVIDERS + ); +} + function resolveTerminalConnectionStatus( status: number, result: { permanent?: boolean; creditsExhausted?: boolean }, - providerErrorType: string | null = null + providerErrorType: string | null = null, + provider: string | null = null ): string | null { if (result.creditsExhausted || status === 402) return "credits_exhausted"; if ( @@ -389,9 +400,10 @@ function resolveTerminalConnectionStatus( return "banned"; } if ( - providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED || - providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED || - status === 401 + (providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED || + providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED || + status === 401) && + !isRecoverableCookieAuth401(provider, providerErrorType) ) { return "expired"; } @@ -2086,7 +2098,8 @@ export async function markAccountUnavailable( const terminalStatus = resolveTerminalConnectionStatus( status, result as { permanent?: boolean; creditsExhausted?: boolean }, - providerErrorType + providerErrorType, + provider ); const cooldownMs = terminalStatus ? 0 : rawCooldownMs; diff --git a/tests/unit/8200-perplexity-web-401-cooldown.test.ts b/tests/unit/8200-perplexity-web-401-cooldown.test.ts new file mode 100644 index 0000000000..d98b22351b --- /dev/null +++ b/tests/unit/8200-perplexity-web-401-cooldown.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Permanent regression guard for #8200: a single recoverable 401 on a +// cookie-auth provider (perplexity-web) must NOT terminal-expire the only +// connection. Before the fix, resolveTerminalConnectionStatus() mapped ANY +// non-OAuth 401 to the TERMINAL testStatus "expired" (cooldownMs 0, no +// self-heal), which then made getProviderCredentials() filter the +// connection out on the very next request -> "No active credentials". + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-8200-perplexity-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("BUG #8200: single perplexity-web 401 (cookie expiry) does not terminal-expire the only connection", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "perplexity-web", + authType: "apikey", + apiKey: "__Secure-next-auth.session-token=stale-cookie", + isActive: true, + testStatus: "active", + }); + const connId = String(conn.id); + + await auth.markAccountUnavailable( + connId, + 401, + "Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token.", + "perplexity-web", + "pplx-auto" + ); + + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(after.isActive, true, "connection row should stay active"); + assert.notEqual( + after.testStatus, + "expired", + "a recoverable perplexity-web 401 must not be classified terminal 'expired'" + ); + + const credentials = await auth.getProviderCredentials("perplexity-web"); + assert.notEqual( + credentials, + null, + "getProviderCredentials must still return the connection after exactly one 401" + ); +}); + +test("markAccountUnavailable keeps the existing 401->expired terminal mapping for plain apikey providers", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-expired", + isActive: true, + testStatus: "active", + }); + const connId = String(conn.id); + + await auth.markAccountUnavailable(connId, 401, "unauthorized", "openai", "gpt-4.1"); + + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(after.testStatus, "expired"); +});