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.
This commit is contained in:
MUH. IQRAM BAHRING
2026-08-07 03:51:38 +08:00
parent b75d665fa5
commit a0bf2e3f96
2 changed files with 44 additions and 4 deletions
+25 -2
View File
@@ -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;
}
/**
+19 -2
View File
@@ -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)