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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user