Files
OmniRoute/open-sse/services/tokenRefresh.ts
T

997 lines
29 KiB
TypeScript

import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { createHash } from "node:crypto";
// Token expiry buffer (refresh if expires within 5 minutes)
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
// In-flight refresh promise cache to prevent race conditions
// Key: "provider:sha256(refreshToken)" → Value: Promise<result>
const refreshPromiseCache = new Map();
function getRefreshCacheKey(provider, refreshToken) {
const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
return `${provider}:${tokenHash}`;
}
/**
* Refresh OAuth access token using refresh token
*/
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
const config = PROVIDERS[provider];
const refreshEndpoint = config?.refreshUrl || config?.tokenUrl;
if (!config || !refreshEndpoint) {
log?.warn?.("TOKEN_REFRESH", `No refresh endpoint configured for provider: ${provider}`);
return null;
}
if (!refreshToken) {
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
return null;
}
try {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
});
if (config.clientId) params.set("client_id", config.clientId);
if (config.clientSecret) params.set("client_secret", config.clientSecret);
const response = await fetch(refreshEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params,
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
error: error.message,
});
return null;
}
}
/**
* Specialized refresh for Cline OAuth tokens.
* Cline refresh endpoint expects JSON body and returns camelCase fields.
*/
export async function refreshClineToken(refreshToken, log) {
const endpoint = PROVIDERS.cline?.refreshUrl;
if (!endpoint) {
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Cline");
return null;
}
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
refreshToken,
grantType: "refresh_token",
clientType: "extension",
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", {
status: response.status,
error: errorText,
});
return null;
}
const payload = await response.json();
const data = payload?.data || payload;
const expiresAtIso = data?.expiresAt;
const expiresIn = expiresAtIso
? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000))
: undefined;
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cline token", {
hasNewAccessToken: !!data?.accessToken,
hasNewRefreshToken: !!data?.refreshToken,
expiresIn,
});
return {
accessToken: data?.accessToken,
refreshToken: data?.refreshToken || refreshToken,
expiresIn,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Cline token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for Kimi Coding OAuth tokens.
* Uses custom X-Msh-* headers required by Kimi OAuth API.
*/
export async function refreshKimiCodingToken(refreshToken, log) {
const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl;
if (!endpoint) {
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding");
return null;
}
// Generate device info for headers (same as OAuth flow)
const deviceId = "kimi-refresh-" + Date.now();
const platform = "omniroute";
const version = "2.1.2";
const deviceModel =
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
try {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS["kimi-coding"]?.clientId || "",
});
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"X-Msh-Platform": platform,
"X-Msh-Version": version,
"X-Msh-Device-Model": deviceModel,
"X-Msh-Device-Id": deviceId,
},
body: params,
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kimi Coding token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kimi Coding token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
tokenType: tokens.token_type,
scope: tokens.scope,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kimi Coding token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for Claude OAuth tokens
*/
export async function refreshClaudeOAuthToken(refreshToken, log) {
try {
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.claude.clientId,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for Google providers (Gemini, Antigravity)
*/
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
* Specialized refresh for Qwen OAuth tokens
*/
export async function refreshQwenToken(refreshToken, log) {
const endpoint = OAUTH_ENDPOINTS.qwen.token;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.qwen.clientId,
}),
});
if (response.status === 200) {
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} else {
const errorText = await response.text().catch(() => "");
// Detect unrecoverable invalid_request (expired/revoked refresh token or bad client_id)
let errorCode = null;
try {
const parsed = JSON.parse(errorText);
errorCode = parsed?.error;
} catch {
// not JSON, ignore
}
if (errorCode === "invalid_request") {
log?.error?.(
"TOKEN_REFRESH",
"Qwen refresh token is invalid or expired. Re-authentication required.",
{
status: response.status,
}
);
return { error: "invalid_request" };
}
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
status: response.status,
error: errorText,
});
}
} catch (error) {
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
error: error.message,
});
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
return null;
}
/**
* Specialized refresh for Codex (OpenAI) OAuth tokens.
* OpenAI uses rotating (one-time-use) refresh tokens.
* Returns { error: 'refresh_token_reused' } when the token has already been consumed,
* so callers can stop retrying and request re-authentication.
*/
export async function refreshCodexToken(refreshToken, log) {
try {
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.codex.clientId,
scope: "openid profile email offline_access",
}),
});
if (!response.ok) {
const errorText = await response.text();
// Detect unrecoverable "refresh_token_reused" error from OpenAI
// This means the token was already consumed and a new one was issued.
// Retrying with the same token will never succeed.
let errorCode = null;
try {
const parsed = JSON.parse(errorText);
errorCode = parsed?.error?.code;
} catch {
// not JSON, ignore
}
if (errorCode === "refresh_token_reused") {
log?.error?.(
"TOKEN_REFRESH",
"Codex refresh token already used (rotating token consumed). Re-authentication required.",
{
status: response.status,
}
);
return { error: "refresh_token_reused" };
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
*/
export async function refreshKiroToken(refreshToken, providerSpecificData, log) {
try {
const authMethod = providerSpecificData?.authMethod;
const clientId = providerSpecificData?.clientId;
const clientSecret = providerSpecificData?.clientSecret;
const region = providerSpecificData?.region;
// AWS SSO OIDC (Builder ID or IDC)
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
if (clientId && clientSecret) {
const isIDC = authMethod === "idc";
const endpoint =
isIDC && region
? `https://oidc.${region}.amazonaws.com/token`
: "https://oidc.us-east-1.amazonaws.com/token";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientId,
clientSecret: clientSecret,
refreshToken: refreshToken,
grantType: "refresh_token",
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
};
}
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
const response = await fetch(PROVIDERS.kiro.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
refreshToken: refreshToken,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kiro token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for iFlow OAuth tokens
*/
export async function refreshIflowToken(refreshToken, log) {
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.iflow.clientId,
client_secret: PROVIDERS.iflow.clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
* Specialized refresh for GitHub Copilot OAuth tokens
*/
export async function refreshGitHubToken(refreshToken, log) {
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.github.clientId,
client_secret: PROVIDERS.github.clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
* Refresh GitHub Copilot token using GitHub access token
*/
export async function refreshCopilotToken(githubAccessToken, log) {
try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: {
Authorization: `token ${githubAccessToken}`,
"User-Agent": "GithubCopilot/1.0",
"Editor-Version": "vscode/1.100.0",
"Editor-Plugin-Version": "copilot/1.300.0",
Accept: "application/json",
},
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
status: response.status,
error: errorText,
});
return null;
}
const data = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
hasToken: !!data.token,
expiresAt: data.expires_at,
});
return {
token: data.token,
expiresAt: data.expires_at,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
error: error.message,
});
return null;
}
}
/**
* Get access token for a specific provider (internal, does the actual work)
*/
async function _getAccessTokenInternal(provider, credentials, log) {
switch (provider) {
case "gemini":
case "gemini-cli":
case "antigravity":
return await refreshGoogleToken(
credentials.refreshToken,
PROVIDERS[provider].clientId,
PROVIDERS[provider].clientSecret,
log
);
case "claude":
return await refreshClaudeOAuthToken(credentials.refreshToken, log);
case "codex":
return await refreshCodexToken(credentials.refreshToken, log);
case "qwen":
return await refreshQwenToken(credentials.refreshToken, log);
case "iflow":
return await refreshIflowToken(credentials.refreshToken, log);
case "github":
return await refreshGitHubToken(credentials.refreshToken, log);
case "kiro":
return await refreshKiroToken(
credentials.refreshToken,
credentials.providerSpecificData,
log
);
case "cline":
return await refreshClineToken(credentials.refreshToken, log);
case "kimi-coding":
return await refreshKimiCodingToken(credentials.refreshToken, log);
default:
// Fallback to generic OAuth refresh for unknown providers
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
}
}
/**
* Whether a provider has a supported refresh path in this service.
*/
export function supportsTokenRefresh(provider) {
const explicitlySupported = new Set([
"gemini",
"gemini-cli",
"antigravity",
"claude",
"codex",
"qwen",
"iflow",
"github",
"kiro",
"cline",
"kimi-coding",
]);
if (explicitlySupported.has(provider)) return true;
const config = PROVIDERS[provider];
return !!(config?.refreshUrl || config?.tokenUrl);
}
/**
* Check if a refresh result indicates an unrecoverable error
* (e.g. the refresh token was already consumed and cannot be reused).
* Callers should stop retrying and request re-authentication.
*/
export function isUnrecoverableRefreshError(result) {
return (
result &&
typeof result === "object" &&
(result.error === "refresh_token_reused" || result.error === "invalid_request")
);
}
/**
* Get access token for a specific provider (with deduplication).
* If a refresh is already in-flight for the same provider+token,
* subsequent calls share the existing promise instead of making
* parallel OAuth requests.
*/
export async function getAccessToken(provider, credentials, log) {
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
return null;
}
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
// If a refresh is already in-flight, reuse it
if (refreshPromiseCache.has(cacheKey)) {
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
return refreshPromiseCache.get(cacheKey);
}
// Start a new refresh and cache the promise
const refreshPromise = _getAccessTokenInternal(provider, credentials, log).finally(() => {
refreshPromiseCache.delete(cacheKey);
});
refreshPromiseCache.set(cacheKey, refreshPromise);
return refreshPromise;
}
/**
* Refresh token by provider type (alias for getAccessToken)
* @deprecated Since v0.2.70 — use getAccessToken() directly.
* Still exported because open-sse/index.js and src/sse wrapper use it.
* Will be removed in a future major version.
*/
export const refreshTokenByProvider = getAccessToken;
/**
* Format credentials for provider
*/
export function formatProviderCredentials(provider, credentials, log) {
const config = PROVIDERS[provider];
if (!config) {
log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`);
return null;
}
switch (provider) {
case "gemini":
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
projectId: credentials.projectId,
};
case "claude":
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
};
case "codex":
case "qwen":
case "iflow":
case "openai":
case "openrouter":
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
};
case "antigravity":
case "gemini-cli":
return {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
};
default:
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
};
}
}
/**
* Get all access tokens for a user
*/
export async function getAllAccessTokens(userInfo, log) {
const results = {};
if (userInfo.connections && Array.isArray(userInfo.connections)) {
for (const connection of userInfo.connections) {
if (connection.isActive && connection.provider) {
const token = await getAccessToken(
connection.provider,
{
refreshToken: connection.refreshToken,
},
log
);
if (token) {
results[connection.provider] = token;
}
}
}
}
return results;
}
/**
* Refresh token with retry and exponential backoff
* Retries on failure with increasing delay: 1s, 2s, 3s...
*
* Includes:
* - Per-provider circuit breaker (5 consecutive failures → 30min pause)
* - 30s timeout per refresh attempt to prevent hanging connections
*
* @param {function} refreshFn - Async function that returns token or null
* @param {number} maxRetries - Max retry attempts (default 3)
* @param {object} log - Logger instance (optional)
* @param {string} provider - Provider ID for circuit breaker tracking (optional)
* @returns {Promise<object|null>} Token result or null if all retries fail
*/
// ─── Circuit Breaker State ──────────────────────────────────────────────────
const _circuitBreaker: Record<string, { failures: number; blockedUntil: number }> = {};
const CIRCUIT_BREAKER_THRESHOLD = 5; // consecutive failures before tripping
const CIRCUIT_BREAKER_COOLDOWN = 30 * 60 * 1000; // 30 minutes
const REFRESH_TIMEOUT_MS = 30_000; // 30s max per refresh attempt
interface CircuitBreakerStatusEntry {
failures: number;
blocked: boolean;
blockedUntil: string | null;
remainingMs: number;
}
interface RefreshLoggerLike {
error?: (scope: string, message: string) => void;
warn?: (scope: string, message: string) => void;
}
/**
* Check if a provider is circuit-breaker blocked.
*/
export function isProviderBlocked(provider: string): boolean {
const state = _circuitBreaker[provider];
if (!state) return false;
if (state.blockedUntil > Date.now()) return true;
// Cooldown expired — reset
delete _circuitBreaker[provider];
return false;
}
/**
* Get circuit breaker status for all providers (for diagnostics).
*/
export function getCircuitBreakerStatus(): Record<string, CircuitBreakerStatusEntry> {
const result: Record<string, CircuitBreakerStatusEntry> = {};
for (const [provider, state] of Object.entries(_circuitBreaker)) {
result[provider] = {
failures: state.failures,
blocked: state.blockedUntil > Date.now(),
blockedUntil:
state.blockedUntil > Date.now() ? new Date(state.blockedUntil).toISOString() : null,
remainingMs: Math.max(0, state.blockedUntil - Date.now()),
};
}
return result;
}
/**
* Record a successful refresh — resets circuit breaker for provider.
*/
function recordSuccess(provider: string) {
if (_circuitBreaker[provider]) {
delete _circuitBreaker[provider];
}
}
/**
* Record a failed refresh — increments circuit breaker counter.
*/
function recordFailure(provider: string, log: RefreshLoggerLike | null = null) {
if (!_circuitBreaker[provider]) {
_circuitBreaker[provider] = { failures: 0, blockedUntil: 0 };
}
_circuitBreaker[provider].failures++;
if (_circuitBreaker[provider].failures >= CIRCUIT_BREAKER_THRESHOLD) {
_circuitBreaker[provider].blockedUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
log?.error?.(
"TOKEN_REFRESH",
`🔴 Circuit breaker tripped for ${provider}: ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. ` +
`Blocked for ${CIRCUIT_BREAKER_COOLDOWN / 60000}min. Provider needs re-authentication.`
);
}
}
/**
* Execute a function with a timeout.
*/
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
return Promise.race([
fn(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
]);
}
export async function refreshWithRetry(
refreshFn,
maxRetries = 3,
log = null,
provider = "unknown"
) {
// Circuit breaker check
if (isProviderBlocked(provider)) {
log?.warn?.("TOKEN_REFRESH", `⚡ Circuit breaker active for ${provider}, skipping refresh`);
return null;
}
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
const delay = attempt * 1000;
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
}
try {
const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS);
if (result) {
recordSuccess(provider);
return result;
}
} catch (error) {
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
}
}
// All retries exhausted — record failure for circuit breaker
recordFailure(provider, log);
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed for ${provider}`);
return null;
}