From 7fee56bacd72c27eb0d7c73568ab9400dc3f253d Mon Sep 17 00:00:00 2001 From: Sutarto Jordan Chrisfivo Date: Wed, 9 Sep 2026 10:24:30 +0700 Subject: [PATCH] fix(providers): clear stale locks after validation (#3830) Clear stale connection health state (modelLock_*, backoffLevel, rateLimitedUntil, errorCode) whenever a connection is explicitly marked active after successful validation or OAuth re-login. Closes #3810 --- src/lib/db/repos/connectionsRepo.js | 28 +++++++- tests/unit/db-sqlite-vs-lowdb.test.js | 98 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index 4181843f..78abfc90 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -10,6 +10,28 @@ const OPTIONAL_FIELDS = [ "consecutiveUseCount", "idToken", "lastRefreshAt", ]; +const MODEL_LOCK_PREFIX = "modelLock_"; + +function resetHealthStateOnActivation(existing, patch) { + if (patch?.testStatus !== "active") return patch; + + const normalized = { + ...patch, + testStatus: "active", + lastError: Object.hasOwn(patch, "lastError") ? patch.lastError : null, + lastErrorAt: Object.hasOwn(patch, "lastErrorAt") ? patch.lastErrorAt : null, + errorCode: null, + rateLimitedUntil: null, + backoffLevel: 0, + }; + + for (const key of Object.keys(existing || {})) { + if (key.startsWith(MODEL_LOCK_PREFIX)) normalized[key] = null; + } + + return normalized; +} + function rowToConn(row) { if (!row) return null; const extra = parseJson(row.data, {}); @@ -147,7 +169,8 @@ export async function createProviderConnection(data) { // access_token: never dedup — user manages duplicates manually if (existing) { - const merged = { ...existing, ...data, updatedAt: now }; + const normalized = resetHealthStateOnActivation(existing, data); + const merged = { ...existing, ...normalized, updatedAt: now }; upsert(db, merged); result = merged; return; @@ -196,7 +219,8 @@ export async function updateProviderConnection(id, data) { const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]); if (!row) { result = null; return; } const existing = rowToConn(row); - const merged = { ...existing, ...data, updatedAt: new Date().toISOString() }; + const normalized = resetHealthStateOnActivation(existing, data); + const merged = { ...existing, ...normalized, updatedAt: new Date().toISOString() }; upsert(db, merged); if (data.priority !== undefined) reorderInTx(db, existing.provider); result = merged; diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js index 52a80884..4955cd88 100644 --- a/tests/unit/db-sqlite-vs-lowdb.test.js +++ b/tests/unit/db-sqlite-vs-lowdb.test.js @@ -101,6 +101,104 @@ describe("DB SQLite layer — public API parity", () => { expect(back.providerSpecificData).toEqual({ foo: "bar" }); }); + it("providerConnections: successful validation clears stale routing locks", async () => { + const c = await sqliteDb.createProviderConnection({ + provider: "health-reset-update", + authType: "oauth", + email: "update@example.com", + accessToken: "old-token", + }); + await sqliteDb.updateProviderConnection(c.id, { + testStatus: "unavailable", + lastError: "Access denied", + lastErrorAt: "2026-09-05T00:00:00.000Z", + errorCode: 403, + backoffLevel: 3, + rateLimitedUntil: "2099-01-01T00:00:00.000Z", + modelLock_modelA: "2099-01-01T00:00:00.000Z", + modelLock_modelB: "2099-01-01T00:00:00.000Z", + }); + + await sqliteDb.updateProviderConnection(c.id, { testStatus: "active" }); + + const back = await sqliteDb.getProviderConnectionById(c.id); + expect(back).toMatchObject({ + testStatus: "active", + lastError: null, + lastErrorAt: null, + errorCode: null, + backoffLevel: 0, + rateLimitedUntil: null, + modelLock_modelA: null, + modelLock_modelB: null, + }); + }); + + it("providerConnections: re-saving valid OAuth credentials clears stale routing locks", async () => { + const existing = await sqliteDb.createProviderConnection({ + provider: "health-reset-resave", + authType: "oauth", + email: "resave@example.com", + accessToken: "old-token", + }); + await sqliteDb.updateProviderConnection(existing.id, { + testStatus: "unavailable", + lastError: "Access denied", + errorCode: 403, + backoffLevel: 2, + modelLock_modelA: "2099-01-01T00:00:00.000Z", + }); + + const resaved = await sqliteDb.createProviderConnection({ + provider: "health-reset-resave", + authType: "oauth", + email: "resave@example.com", + accessToken: "new-token", + testStatus: "active", + }); + + expect(resaved.id).toBe(existing.id); + const back = await sqliteDb.getProviderConnectionById(existing.id); + expect(back).toMatchObject({ + accessToken: "new-token", + testStatus: "active", + lastError: null, + errorCode: null, + backoffLevel: 0, + modelLock_modelA: null, + }); + }); + + it("providerConnections: active soft warnings survive the health reset", async () => { + const c = await sqliteDb.createProviderConnection({ + provider: "health-reset-warning", + authType: "oauth", + email: "warning@example.com", + }); + await sqliteDb.updateProviderConnection(c.id, { + testStatus: "unavailable", + lastError: "Old failure", + modelLock_modelA: "2099-01-01T00:00:00.000Z", + }); + + const warningAt = "2026-09-06T00:00:00.000Z"; + await sqliteDb.updateProviderConnection(c.id, { + testStatus: "active", + lastError: "Connected, but credits are exhausted", + lastErrorAt: warningAt, + }); + + const back = await sqliteDb.getProviderConnectionById(c.id); + expect(back).toMatchObject({ + testStatus: "active", + lastError: "Connected, but credits are exhausted", + lastErrorAt: warningAt, + errorCode: null, + backoffLevel: 0, + modelLock_modelA: null, + }); + }); + it("providerConnections: GitHub OAuth uses account identity as fallback name", async () => { const c = await sqliteDb.createProviderConnection({ provider: "github",