From a0bf2e3f96e310cd12c3ab80fab5bcdbe5a41bcc Mon Sep 17 00:00:00 2001 From: "MUH. IQRAM BAHRING" Date: Fri, 7 Aug 2026 03:51:38 +0800 Subject: [PATCH] fix(token-refresh): stop dead-token retry loop, lift block on refresh success Unrecoverable refresh errors (invalid_grant/invalid_request) are persisted as a refreshBlocked marker so the background scheduler stops hammering the provider every 5 minutes and surfaces re-login required; the marker is lifted automatically when a later refresh succeeds. Cooldown maps gained lazy pruning. --- src/sse/services/backgroundTokenRefresh.js | 27 ++++++++++++++++++++-- src/sse/services/tokenRefresh.js | 21 +++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/sse/services/backgroundTokenRefresh.js b/src/sse/services/backgroundTokenRefresh.js index b10a86ba..d8bb0a56 100644 --- a/src/sse/services/backgroundTokenRefresh.js +++ b/src/sse/services/backgroundTokenRefresh.js @@ -21,7 +21,7 @@ function isTruthyEnv(value) { return v === "1" || v === "true" || v === "yes" || v === "on"; } -function isNonServerRuntime() { +export function isNonServerRuntime() { if (typeof window !== "undefined") return true; const phase = process.env.NEXT_PHASE || ""; if ( @@ -54,6 +54,9 @@ export function selectConnectionsNeedingRefresh(connections, nowMs = Date.now()) const authType = String(conn.authType || "").toLowerCase().replace(/_/g, ""); if (authType !== "oauth") continue; if (!conn.refreshToken) continue; + // Refresh token known-dead (invalid_grant/invalid_request) — stop retrying + // every tick; surfaced as "re-login required" instead. + if (conn.providerSpecificData?.refreshBlocked) continue; const expiresAtMs = getCredentialExpiryMs(conn); if (expiresAtMs === null) continue; @@ -79,7 +82,27 @@ async function loadActiveConnections() { async function refreshOne(connection) { const { checkAndRefreshToken } = await import("./tokenRefresh.js"); - return checkAndRefreshToken(connection.provider, connection, { force: true }); + const result = await checkAndRefreshToken(connection.provider, connection, { force: true }); + + // Dead refresh token (revoked/reused/expired): persist the block marker so + // future ticks skip it, then surface the re-login requirement. The marker is + // lifted by checkAndRefreshToken on the next successful refresh. + if (result?.refreshError) { + const { updateProviderConnection } = await import("../../lib/db/repos/connectionsRepo.js"); + await updateProviderConnection(connection.id, { + providerSpecificData: { + ...(connection.providerSpecificData || {}), + refreshBlocked: result.refreshError, + refreshBlockedAt: result.refreshErrorAt, + }, + }); + log.warn("BG_TOKEN_REFRESH", "Refresh token unrecoverable — auto-refresh stopped, re-login required", { + id: connection.id, + provider: connection.provider, + error: result.refreshError, + }); + } + return result; } /** diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index 6c808f07..4ca55920 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -20,7 +20,8 @@ import { formatProviderCredentials as _formatProviderCredentials, getAllAccessTokens as _getAllAccessTokens, refreshKiroToken as _refreshKiroToken, - getRefreshLeadMs as _getRefreshLeadMs + getRefreshLeadMs as _getRefreshLeadMs, + isUnrecoverableRefreshError, } from "open-sse/services/tokenRefresh.js"; import { refreshProviderCredentials as _refreshProviderCredentials, @@ -238,10 +239,26 @@ export async function checkAndRefreshToken(provider, credentials, options = {}) }); const newCreds = await _refreshProviderCredentials(provider, creds, log); + if (isUnrecoverableRefreshError(newCreds)) { + // Refresh token is dead (revoked/reused/expired) — retrying forever just + // spams xAI's endpoint every tick. Tag the result so the background + // scheduler can stop retrying and surface "re-login required". + log.warn("TOKEN_REFRESH", `Refresh token unrecoverable for ${provider} — re-login required`, { + error: newCreds.error, + }); + return { ...creds, refreshError: newCreds.error, refreshErrorAt: new Date().toISOString() }; + } if (newCreds?.accessToken || newCreds?.apiKey || newCreds?.copilotToken) { const mergedCreds = { ...newCreds, - existingProviderSpecificData: creds.providerSpecificData, + // Lift any previous refreshBlocked marker — the refresh just succeeded + // (covers in-place re-auth flows that keep the same connection row). + existingProviderSpecificData: { + ...(creds.providerSpecificData || {}), + ...(creds.providerSpecificData?.refreshBlocked + ? { refreshBlocked: undefined, refreshBlockedAt: undefined } + : {}), + }, }; // Persist to DB (non-blocking path continues below)