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";
|
return v === "1" || v === "true" || v === "yes" || v === "on";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNonServerRuntime() {
|
export function isNonServerRuntime() {
|
||||||
if (typeof window !== "undefined") return true;
|
if (typeof window !== "undefined") return true;
|
||||||
const phase = process.env.NEXT_PHASE || "";
|
const phase = process.env.NEXT_PHASE || "";
|
||||||
if (
|
if (
|
||||||
@@ -54,6 +54,9 @@ export function selectConnectionsNeedingRefresh(connections, nowMs = Date.now())
|
|||||||
const authType = String(conn.authType || "").toLowerCase().replace(/_/g, "");
|
const authType = String(conn.authType || "").toLowerCase().replace(/_/g, "");
|
||||||
if (authType !== "oauth") continue;
|
if (authType !== "oauth") continue;
|
||||||
if (!conn.refreshToken) 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);
|
const expiresAtMs = getCredentialExpiryMs(conn);
|
||||||
if (expiresAtMs === null) continue;
|
if (expiresAtMs === null) continue;
|
||||||
@@ -79,7 +82,27 @@ async function loadActiveConnections() {
|
|||||||
|
|
||||||
async function refreshOne(connection) {
|
async function refreshOne(connection) {
|
||||||
const { checkAndRefreshToken } = await import("./tokenRefresh.js");
|
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,
|
formatProviderCredentials as _formatProviderCredentials,
|
||||||
getAllAccessTokens as _getAllAccessTokens,
|
getAllAccessTokens as _getAllAccessTokens,
|
||||||
refreshKiroToken as _refreshKiroToken,
|
refreshKiroToken as _refreshKiroToken,
|
||||||
getRefreshLeadMs as _getRefreshLeadMs
|
getRefreshLeadMs as _getRefreshLeadMs,
|
||||||
|
isUnrecoverableRefreshError,
|
||||||
} from "open-sse/services/tokenRefresh.js";
|
} from "open-sse/services/tokenRefresh.js";
|
||||||
import {
|
import {
|
||||||
refreshProviderCredentials as _refreshProviderCredentials,
|
refreshProviderCredentials as _refreshProviderCredentials,
|
||||||
@@ -238,10 +239,26 @@ export async function checkAndRefreshToken(provider, credentials, options = {})
|
|||||||
});
|
});
|
||||||
|
|
||||||
const newCreds = await _refreshProviderCredentials(provider, creds, log);
|
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) {
|
if (newCreds?.accessToken || newCreds?.apiKey || newCreds?.copilotToken) {
|
||||||
const mergedCreds = {
|
const mergedCreds = {
|
||||||
...newCreds,
|
...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)
|
// Persist to DB (non-blocking path continues below)
|
||||||
|
|||||||
Reference in New Issue
Block a user