diff --git a/changelog.d/fixes/10272-provider-test-statuscode-propagation.md b/changelog.d/fixes/10272-provider-test-statuscode-propagation.md new file mode 100644 index 0000000000..5102bf9c45 --- /dev/null +++ b/changelog.d/fixes/10272-provider-test-statuscode-propagation.md @@ -0,0 +1 @@ +- **fix(providers):** preserve validator HTTP status codes in API-key and web connection-test results so callers can distinguish authentication, rate-limit, and upstream failures ([#10272](https://github.com/diegosouzapw/OmniRoute/pull/10272)) — thanks @Zartharas diff --git a/src/app/api/providers/[id]/test/apiKeyTestResult.ts b/src/app/api/providers/[id]/test/apiKeyTestResult.ts new file mode 100644 index 0000000000..80e8788f7d --- /dev/null +++ b/src/app/api/providers/[id]/test/apiKeyTestResult.ts @@ -0,0 +1,28 @@ +export interface ApiKeyValidationResult { + valid: boolean; + warning?: string | null; + statusCode?: number | null; + deployments?: unknown; +} + +export interface ApiKeyTestDiagnosis { + type: string; + source: string; + message: string | null; + code: string | null; +} + +export function buildApiKeyConnectionTestResult( + result: ApiKeyValidationResult, + error: string | null, + diagnosis: ApiKeyTestDiagnosis +) { + return { + valid: !!result.valid, + error, + warning: result.warning || null, + statusCode: result.valid ? null : (result.statusCode ?? null), + diagnosis, + ...(Array.isArray(result.deployments) ? { deployments: result.deployments } : {}), + }; +} diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 4d440e6936..1201826c83 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -23,6 +23,7 @@ import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; +import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; import { OAUTH_TEST_CONFIG } from "./oauthTestConfig"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue @@ -614,15 +615,7 @@ async function testApiKeyConnection(connection: any) { ? makeDiagnosis("ok", "upstream", null, null) : classifyFailure({ error, statusCode: result.statusCode, provider: connection.provider }); - return { - valid: !!result.valid, - error, - warning: result.warning || null, - diagnosis, - ...(Array.isArray((result as any).deployments) - ? { deployments: (result as any).deployments } - : {}), - }; + return buildApiKeyConnectionTestResult(result, error, diagnosis); } /** @@ -709,7 +702,8 @@ export async function testSingleConnection(connectionId: string, validationModel // failures a short cooldown so the lazy-recovery path retries them. const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]); const isTerminalFailure = - !result.valid && terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase()); + !result.valid && + terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase()); const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window const updateData: Record = { diff --git a/tests/unit/provider-test-statuscode-propagation.test.ts b/tests/unit/provider-test-statuscode-propagation.test.ts new file mode 100644 index 0000000000..74831a5589 --- /dev/null +++ b/tests/unit/provider-test-statuscode-propagation.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildApiKeyConnectionTestResult } = + await import("../../src/app/api/providers/[id]/test/apiKeyTestResult.ts"); + +const FAILURE_DIAGNOSIS = { + type: "synthetic_failure", + source: "test", + message: "Synthetic validator failure", + code: "synthetic", +}; + +test("API-key connection tests preserve validator failure status codes", () => { + for (const statusCode of [401, 403, 429, 503]) { + const result = buildApiKeyConnectionTestResult( + { + valid: false, + warning: null, + statusCode, + }, + `Synthetic validator failure ${statusCode}`, + FAILURE_DIAGNOSIS + ); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, statusCode); + assert.deepEqual(result.diagnosis, FAILURE_DIAGNOSIS); + } +}); + +test("status-less semantic failures remain status-less", () => { + const result = buildApiKeyConnectionTestResult( + { + valid: false, + warning: null, + }, + "Synthetic semantic failure", + FAILURE_DIAGNOSIS + ); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, null); + assert.deepEqual(result.diagnosis, FAILURE_DIAGNOSIS); +}); + +test("successful validation does not synthesize an HTTP status", () => { + const diagnosis = { + type: "ok", + source: "upstream", + message: null, + code: null, + }; + + const result = buildApiKeyConnectionTestResult( + { + valid: true, + warning: null, + statusCode: 200, + }, + null, + diagnosis + ); + + assert.equal(result.valid, true); + assert.equal(result.statusCode, null); + assert.deepEqual(result.diagnosis, diagnosis); +});