mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
* chore(token-refresh): extract rotation/cas/circuit-breaker refresh logic into tokenRefresh/* leaves * test(oauth): follow isUnrecoverableRefreshError to tokenRefresh/shared.ts cad2c7285 moved isUnrecoverableRefreshError out of tokenRefresh.ts into tokenRefresh/shared.ts. This suite asserts on source *text* (it regex-matches the function body to prove the unrecoverable sentinel is returned), so the move made it fail to find the definition — the only red test across the 23 tokenRefresh-related suites. Repoint the read() at the file that now defines the body. The public surface is unchanged: tokenRefresh.ts still re-exports the symbol, verified by import. * docs(changelog): add fragment for this PR * docs(auth): correct the #7338 attribution wording in the tokenRefresh header The header claimed credit for KooshaPari's #7338 was "preserved via co-authorship on the extraction commits", but none of the commits carries a Co-authored-by trailer -- and adding one would be inaccurate, since this is an independent implementation against the current tip rather than a reuse of that diff. The by-name credit for proposing the split stays; only the false claim about the mechanism is removed.
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
// @ts-nocheck
|
|
//
|
|
// Per-provider circuit breaker + refreshWithRetry — extracted from
|
|
// open-sse/services/tokenRefresh.ts. See ../shared.ts for provenance notes.
|
|
//
|
|
// refreshWithRetry wraps a refresh attempt with exponential backoff, a 30s
|
|
// per-attempt timeout, and a per-provider circuit breaker (5 consecutive
|
|
// failures → 30min pause). Unrecoverable refresh errors (invalid_grant,
|
|
// refresh_token_reused, …) short-circuit retries so the HealthCheck can
|
|
// deactivate the account instead of looping every 60s.
|
|
import type { RefreshLogger } from "./shared.ts";
|
|
import { isUnrecoverableRefreshError } from "./shared.ts";
|
|
|
|
// ─── 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) 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 await new Promise<T | null>((resolve, reject) => {
|
|
const timer = setTimeout(() => resolve(null), timeoutMs);
|
|
if (typeof timer === "object" && "unref" in timer) {
|
|
(timer as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
fn().then(
|
|
(result) => {
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
},
|
|
(error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export async function refreshWithRetry(
|
|
refreshFn,
|
|
maxRetries = 3,
|
|
log: RefreshLogger = 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 (isUnrecoverableRefreshError(result)) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
`Unrecoverable refresh error for ${provider}: ${result.error} — skipping retries`
|
|
);
|
|
return result;
|
|
}
|
|
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;
|
|
}
|