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
This commit is contained in:
@@ -10,6 +10,28 @@ const OPTIONAL_FIELDS = [
|
|||||||
"consecutiveUseCount", "idToken", "lastRefreshAt",
|
"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) {
|
function rowToConn(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const extra = parseJson(row.data, {});
|
const extra = parseJson(row.data, {});
|
||||||
@@ -147,7 +169,8 @@ export async function createProviderConnection(data) {
|
|||||||
// access_token: never dedup — user manages duplicates manually
|
// access_token: never dedup — user manages duplicates manually
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const merged = { ...existing, ...data, updatedAt: now };
|
const normalized = resetHealthStateOnActivation(existing, data);
|
||||||
|
const merged = { ...existing, ...normalized, updatedAt: now };
|
||||||
upsert(db, merged);
|
upsert(db, merged);
|
||||||
result = merged;
|
result = merged;
|
||||||
return;
|
return;
|
||||||
@@ -196,7 +219,8 @@ export async function updateProviderConnection(id, data) {
|
|||||||
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
|
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
|
||||||
if (!row) { result = null; return; }
|
if (!row) { result = null; return; }
|
||||||
const existing = rowToConn(row);
|
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);
|
upsert(db, merged);
|
||||||
if (data.priority !== undefined) reorderInTx(db, existing.provider);
|
if (data.priority !== undefined) reorderInTx(db, existing.provider);
|
||||||
result = merged;
|
result = merged;
|
||||||
|
|||||||
@@ -101,6 +101,104 @@ describe("DB SQLite layer — public API parity", () => {
|
|||||||
expect(back.providerSpecificData).toEqual({ foo: "bar" });
|
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 () => {
|
it("providerConnections: GitHub OAuth uses account identity as fallback name", async () => {
|
||||||
const c = await sqliteDb.createProviderConnection({
|
const c = await sqliteDb.createProviderConnection({
|
||||||
provider: "github",
|
provider: "github",
|
||||||
|
|||||||
Reference in New Issue
Block a user