mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
* test: resolve typescript strictness complaints in unit tests * Update Claude Code obfuscation to version 2.1.114 (#1403) * fix(cloud-code): scope thinking stripping to executor boundaries (#1401) * fix(cloud-code): scope thinking stripping to executors * fix(cloud-code): guard antigravity normalized body * Update Claude Code obfuscation to version 2.1.114 - Update Claude Code version from 2.1.87 to 2.1.114 - Update X-Stainless-Package-Version from 0.80.0 to 0.81.0 - Add new beta flags: redact-thinking-2026-02-12, advisor-tool-2026-03-01, advanced-tool-use-2025-11-20 - Add missing headers: anthropic-version, anthropic-dangerous-direct-browser-access, x-app, X-Stainless-Timeout - Add all X-Stainless-* headers (Arch, Lang, OS, Runtime, Runtime-Version, Retry-Count) - Fix accept-encoding header: identity -> gzip, deflate, br, zstd - Add connection: keep-alive header - Update tool name mapping: add lsp, apply_patch, websearch These changes ensure that requests from OpenCode through Omniroute are indistinguishable from genuine Claude Code 2.1.114 requests, allowing proper authentication with Anthropic's API without triggering extra credits errors. * fix: resolve CodeQL password hash alert and TruffleHog CI failure --------- Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> * fix(claude-code): scope obfuscation to cli clients and fix tests * docs(workflows): enforce PR merge instead of manual close * docs(changelog): update 3.6.9 notes with missing PR 1403 and fixes * docs(workflows): update generate-release to use full changelog for PR body * fix(tsc): silence baseUrl deprecation warnings for TS 5.5+ * fix(chatcore): apply proactive compression before provider translation (#1406) Integrated into release/v3.6.9 * docs(changelog): add PR 1406 * Makes text visible in dark-mode (#1409) Integrated into release/v3.6.9 * docs(changelog): add PR 1409 * chore: save local work * chore(release): sync version references to 3.6.9 * fix(codex): prevent proactive token refresh consumption and strip background parameter * ci: shard long-running suites and relax timeouts * ci: allow manual CI dispatch for release branches * feat(skills): provider-aware marketplace UX, scored AUTO injection, and memory pipeline hardening (#1411) * fix/400 for GeminiCLI(add "ref" in GEMINI_UNSUPPORTED_SCHEMA_KEYS) * feat(cc-compatible): align request shape with Claude CLI * fix(cc-compatible): add Claude CLI system skeleton for OpenAI input * preserve reasoning when translating chat to responses (#1414) Integrated into release/v3.6.9 * fix(skills): optimize AUTO scoring and include Responses input context (#1418) Integrated into release/v3.6.9 * chore: fix TS errors and update review-prs workflow * fix(api): stop sending unsupported Gemini and Codex parameters Prevent Gemini request translation from injecting default thoughtSignature values that the upstream API strictly validates and rejects. Only preserve real signatures resolved from prior upstream responses, and strip additionalProperties from Gemini function schemas to avoid 400 "Unknown name" errors. Also remove fallback-injected session_id and conversation_id fields before sending Codex requests, and restore compatibility with the legacy OUTBOUND_SSRF_GUARD_ENABLED flag when determining whether private provider URLs are allowed. Updates the Gemini translator and regression tests for issue #1410 and related 400 error cases. * fix(core): stabilization fixes for token refresh, usage translation, and testing - Update Codex token refresh detection logic - Mark provider connections invalid on unrecoverable refresh error - Fix Claude usage translation under-reporting cached tokens - Update test expectations - Update CHANGELOG.md for v3.6.9 * fix(auth): reload fresh token state and unify expiry persistence Refresh checks now re-read the latest stored provider connection before attempting rotation so they do not use stale refresh tokens captured by an earlier sweep. Token updates also persist both expiresAt and tokenExpiresAt across the health check, usage-limit refresh path, and SSE refresh flow. This keeps known token expiry metadata in sync and avoids interval-based refreshes for connections whose tokens are still valid well into the future. * fix: resolve SSRF environment static evaluation bug (#1427) Fix import aliases and strict TS typings for tests and ACP agents. * test: resolve remaining strict type errors in test files * test: fix provider service assertion for anthropic-compatible header * fix(codex): respect openaiStoreEnabled setting during native passthrough (#1432) * fix(codex): fix token refresh unrecoverable detection for expired tokens * fix(ci): restore release v3.6.9 build and flaky tests * fix(cc-compatible): trim default OpenAI system skeleton (#1433) Integrated into release/v3.6.9 * fix: prevent masked API keys from being written to CLI tool configs (#1435) * feat: mark Qwen provider as deprecated and add deprecation warning to CLI tool (#1437) * docs(changelog): comprehensive v3.6.9 update with all 59 commits since v3.6.8 * test(ci): align qwen guide settings assertions * fix(security): resolve CodeQL alert 163 for incomplete URL sanitization in Qwen CLI settings --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <74762779+nikolay-popov-ideogram@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Tim Massey <tim-massey@users.noreply.github.com> Co-authored-by: Paijo <oyi77@users.noreply.github.com> Co-authored-by: dail45 <dail45@yandex.ru> Co-authored-by: R.D. <rogerproself@gmail.com>
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
// @ts-nocheck
|
|
/**
|
|
* Proactive Token Health Check Scheduler
|
|
*
|
|
* Background job that periodically refreshes OAuth tokens before they expire.
|
|
* Each connection can configure its own `healthCheckInterval` (minutes).
|
|
* Default: 60 minutes. 0 = disabled.
|
|
*
|
|
* The scheduler runs a lightweight sweep every TICK_MS (60 s).
|
|
* For each eligible connection it calls the provider-specific refresh function,
|
|
* updates the DB, and logs the result.
|
|
*/
|
|
|
|
import {
|
|
getProviderConnections,
|
|
getProviderConnectionById,
|
|
updateProviderConnection,
|
|
getSettings,
|
|
resolveProxyForConnection,
|
|
} from "@/lib/localDb";
|
|
import {
|
|
getAccessToken,
|
|
supportsTokenRefresh,
|
|
isUnrecoverableRefreshError,
|
|
} from "@omniroute/open-sse/services/tokenRefresh.ts";
|
|
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds
|
|
const DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60; // default per-connection interval
|
|
const EXPIRED_RETRY_MAX = 3; // max retry attempts for expired connections before giving up
|
|
const EXPIRED_RETRY_BACKOFF_MIN = 5; // backoff between expired retries (minutes)
|
|
const LOG_PREFIX = "[HealthCheck]";
|
|
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
|
|
function isBuildProcess(): boolean {
|
|
return typeof process !== "undefined" && process.env.NEXT_PHASE === "phase-production-build";
|
|
}
|
|
|
|
function isAutomatedTestProcess(): boolean {
|
|
return (
|
|
typeof process !== "undefined" &&
|
|
(process.env.NODE_ENV === "test" ||
|
|
process.env.VITEST !== undefined ||
|
|
process.argv.some((arg) => arg.includes("test")))
|
|
);
|
|
}
|
|
|
|
function getConnectionLogLabel(conn: { name?: string; email?: string; id?: string }): string {
|
|
return pickMaskedDisplayValue([conn.name, conn.email], conn.id || "-");
|
|
}
|
|
|
|
export function extractResolvedProxyConfig(resolvedProxy: unknown) {
|
|
if (
|
|
resolvedProxy &&
|
|
typeof resolvedProxy === "object" &&
|
|
!Array.isArray(resolvedProxy) &&
|
|
"proxy" in resolvedProxy
|
|
) {
|
|
return (resolvedProxy as { proxy?: unknown }).proxy ?? null;
|
|
}
|
|
|
|
return resolvedProxy ?? null;
|
|
}
|
|
|
|
function getEffectiveTokenExpiryIso(conn: any): string | null {
|
|
if (!conn || typeof conn !== "object") return null;
|
|
return conn.tokenExpiresAt || conn.expiresAt || null;
|
|
}
|
|
|
|
function getEffectiveTokenExpiryMs(conn: any): number {
|
|
const effectiveExpiry = getEffectiveTokenExpiryIso(conn);
|
|
if (!effectiveExpiry) return 0;
|
|
const expiryMs = new Date(effectiveExpiry).getTime();
|
|
return Number.isFinite(expiryMs) ? expiryMs : 0;
|
|
}
|
|
|
|
export function buildRefreshFailureUpdate(conn: any, now: string) {
|
|
const wasExpired = conn.testStatus === "expired";
|
|
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
|
|
|
|
return {
|
|
lastHealthCheckAt: now,
|
|
// A failed background refresh should not evict otherwise healthy accounts
|
|
// from request routing. Keep non-expired connections active and only persist
|
|
// the refresh error metadata for observability.
|
|
testStatus: wasExpired ? "expired" : "active",
|
|
lastError: "Health check: token refresh failed",
|
|
lastErrorAt: now,
|
|
lastErrorType: "token_refresh_failed",
|
|
lastErrorSource: "oauth",
|
|
errorCode: "refresh_failed",
|
|
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
|
|
};
|
|
}
|
|
|
|
function isEnvFlagEnabled(name: string): boolean {
|
|
const value = process.env[name];
|
|
if (!value) return false;
|
|
return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
|
|
}
|
|
|
|
function isHealthCheckDisabled(): boolean {
|
|
return (
|
|
isEnvFlagEnabled("OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK") ||
|
|
isBuildProcess() ||
|
|
isAutomatedTestProcess()
|
|
);
|
|
}
|
|
|
|
// ── Logging helper ───────────────────────────────────────────────────────────
|
|
let cachedHideLogs: boolean | null = null;
|
|
let cacheTimestamp = 0;
|
|
let pendingHideLogs: Promise<boolean> | null = null;
|
|
const CACHE_TTL = 30_000; // Cache settings for 30 seconds
|
|
|
|
async function shouldHideLogs(): Promise<boolean> {
|
|
if (
|
|
isEnvFlagEnabled("OMNIROUTE_HIDE_HEALTHCHECK_LOGS") ||
|
|
isBuildProcess() ||
|
|
isAutomatedTestProcess()
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// Return cached value if valid
|
|
if (cachedHideLogs !== null && now - cacheTimestamp < CACHE_TTL) {
|
|
return cachedHideLogs;
|
|
}
|
|
|
|
// Return pending promise if a query is already in progress (request coalescing)
|
|
if (pendingHideLogs !== null) {
|
|
return pendingHideLogs;
|
|
}
|
|
|
|
// Create new promise for DB query
|
|
pendingHideLogs = (async () => {
|
|
try {
|
|
const settings = await getSettings();
|
|
cachedHideLogs = settings.hideHealthCheckLogs === true;
|
|
cacheTimestamp = now;
|
|
return cachedHideLogs;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
pendingHideLogs = null;
|
|
}
|
|
})();
|
|
|
|
return pendingHideLogs;
|
|
}
|
|
|
|
function log(message: string, ...args: any[]) {
|
|
shouldHideLogs().then((hide) => {
|
|
if (!hide) console.log(message, ...args);
|
|
});
|
|
}
|
|
|
|
function logWarn(message: string, ...args: any[]) {
|
|
shouldHideLogs().then((hide) => {
|
|
if (!hide) console.warn(message, ...args);
|
|
});
|
|
}
|
|
|
|
function logError(message: string, ...args: any[]) {
|
|
shouldHideLogs().then((hide) => {
|
|
if (!hide) console.error(message, ...args);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear the cached hideLogs setting (call when settings are updated).
|
|
*/
|
|
export function clearHealthCheckLogCache() {
|
|
cachedHideLogs = null;
|
|
cacheTimestamp = 0;
|
|
}
|
|
|
|
// ── Singleton guard (globalThis survives HMR re-evaluation) ─────────────────
|
|
|
|
declare global {
|
|
var __omnirouteTokenHC:
|
|
| { initialized: boolean; interval: ReturnType<typeof setInterval> | null }
|
|
| undefined;
|
|
}
|
|
|
|
function getHCState() {
|
|
if (!globalThis.__omnirouteTokenHC) {
|
|
globalThis.__omnirouteTokenHC = { initialized: false, interval: null };
|
|
}
|
|
return globalThis.__omnirouteTokenHC;
|
|
}
|
|
|
|
/**
|
|
* Start the health-check scheduler (idempotent).
|
|
*/
|
|
export function initTokenHealthCheck() {
|
|
const state = getHCState();
|
|
if (state.initialized || isHealthCheckDisabled()) return;
|
|
state.initialized = true;
|
|
|
|
log(`${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)`);
|
|
|
|
const timer = setTimeout(() => {
|
|
sweep();
|
|
state.interval = setInterval(sweep, TICK_MS);
|
|
if (state.interval && typeof state.interval === "object" && "unref" in state.interval) {
|
|
(state.interval as { unref?: () => void }).unref?.();
|
|
}
|
|
}, 10_000);
|
|
if (timer && typeof timer === "object" && "unref" in timer) {
|
|
(timer as { unref?: () => void }).unref?.();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the scheduler (useful for tests / hot-reload).
|
|
*/
|
|
export function stopTokenHealthCheck() {
|
|
const state = getHCState();
|
|
if (state.interval) {
|
|
clearInterval(state.interval);
|
|
state.interval = null;
|
|
}
|
|
state.initialized = false;
|
|
}
|
|
|
|
// ── Core sweep ───────────────────────────────────────────────────────────────
|
|
async function sweep() {
|
|
try {
|
|
const connections = await getProviderConnections({ authType: "oauth" });
|
|
|
|
if (!connections || connections.length === 0) return;
|
|
|
|
const staggerMs = parseInt(process.env.HEALTHCHECK_STAGGER_MS || "3000", 10);
|
|
|
|
for (let i = 0; i < connections.length; i++) {
|
|
const conn = connections[i];
|
|
try {
|
|
await checkConnection(conn);
|
|
} catch (err) {
|
|
// Per-connection isolation: one failure never blocks others
|
|
logError(`${LOG_PREFIX} Error checking ${conn.name || conn.id}:`, err.message);
|
|
}
|
|
|
|
// Stagger delay between checks to prevent bursting (Issue #1220)
|
|
if (staggerMs > 0 && i < connections.length - 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, staggerMs));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logError(`${LOG_PREFIX} Sweep error:`, err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check a single connection and refresh if due.
|
|
*/
|
|
export async function checkConnection(conn) {
|
|
if (!conn?.id) return;
|
|
|
|
const latestConnection = (await getProviderConnectionById(conn.id)) || conn;
|
|
conn = latestConnection;
|
|
|
|
// Determine interval (0 = disabled)
|
|
const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN;
|
|
if (intervalMin <= 0) return;
|
|
if (!conn.isActive) return;
|
|
if (!conn.refreshToken || typeof conn.refreshToken !== "string") return;
|
|
|
|
// Retry expired connections with exponential backoff up to EXPIRED_RETRY_MAX times.
|
|
if (conn.testStatus === "expired") {
|
|
const retryCount = conn.expiredRetryCount ?? 0;
|
|
if (retryCount >= EXPIRED_RETRY_MAX) return;
|
|
|
|
const lastRetry = conn.expiredRetryAt ? new Date(conn.expiredRetryAt).getTime() : 0;
|
|
const backoffMs = EXPIRED_RETRY_BACKOFF_MIN * 60 * 1000 * Math.pow(2, retryCount);
|
|
if (Date.now() - lastRetry < backoffMs) return;
|
|
|
|
log(
|
|
`${LOG_PREFIX} Retrying expired ${conn.provider}/${getConnectionLogLabel(conn)} (attempt ${retryCount + 1}/${EXPIRED_RETRY_MAX})`
|
|
);
|
|
}
|
|
|
|
if (!supportsTokenRefresh(conn.provider)) {
|
|
const now = new Date().toISOString();
|
|
await updateProviderConnection(conn.id, { lastHealthCheckAt: now });
|
|
log(
|
|
`${LOG_PREFIX} Skipping ${conn.provider}/${getConnectionLogLabel(conn)} (refresh unsupported)`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const intervalMs = intervalMin * 60 * 1000;
|
|
const lastCheck = conn.lastHealthCheckAt ? new Date(conn.lastHealthCheckAt).getTime() : 0;
|
|
|
|
// Prefer expiry-driven refresh when the provider returns a concrete expiry timestamp.
|
|
// Rotating-token providers such as Codex should not be refreshed on a fixed hourly
|
|
// cadence while the access token is still valid for days.
|
|
const TOKEN_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes
|
|
const tokenExpiresAt = getEffectiveTokenExpiryMs(conn);
|
|
const hasKnownExpiry = tokenExpiresAt > 0;
|
|
const isAboutToExpire = hasKnownExpiry && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER;
|
|
const shouldRefreshByInterval = !hasKnownExpiry && Date.now() - lastCheck >= intervalMs;
|
|
|
|
if (!isAboutToExpire && !shouldRefreshByInterval) return;
|
|
|
|
const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`;
|
|
log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`);
|
|
|
|
const attemptedRefreshToken = conn.refreshToken;
|
|
const attemptedAccessToken = conn.accessToken || null;
|
|
const credentials = {
|
|
refreshToken: attemptedRefreshToken,
|
|
accessToken: attemptedAccessToken,
|
|
expiresAt: getEffectiveTokenExpiryIso(conn),
|
|
providerSpecificData: conn.providerSpecificData,
|
|
};
|
|
|
|
const hideLogs = await shouldHideLogs();
|
|
const proxyResolution = await resolveProxyForConnection(conn.id);
|
|
const proxyConfig = extractResolvedProxyConfig(proxyResolution);
|
|
const result = await getAccessToken(
|
|
conn.provider,
|
|
credentials,
|
|
{
|
|
info: (tag, msg) => {
|
|
if (!hideLogs) console.log(`${LOG_PREFIX} [${tag}] ${msg}`);
|
|
},
|
|
warn: (tag, msg) => {
|
|
if (!hideLogs) console.warn(`${LOG_PREFIX} [${tag}] ${msg}`);
|
|
},
|
|
error: (tag, msg, extra) => {
|
|
if (!hideLogs) console.error(`${LOG_PREFIX} [${tag}] ${msg}`, extra || "");
|
|
},
|
|
},
|
|
proxyConfig
|
|
);
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
// ─── Handle unrecoverable errors (e.g. refresh_token_reused) ───────────
|
|
// OpenAI Codex uses rotating one-time-use refresh tokens.
|
|
// Once used, the old token is permanently invalidated.
|
|
// Retrying will never succeed → deactivate and stop the loop.
|
|
if (isUnrecoverableRefreshError(result)) {
|
|
const currentConnection = await getProviderConnectionById(conn.id);
|
|
const credentialsChangedSinceSweep =
|
|
!!currentConnection &&
|
|
(currentConnection.refreshToken !== attemptedRefreshToken ||
|
|
(currentConnection.accessToken || null) !== attemptedAccessToken);
|
|
|
|
if (credentialsChangedSinceSweep) {
|
|
await updateProviderConnection(conn.id, {
|
|
lastHealthCheckAt: now,
|
|
});
|
|
logWarn(
|
|
`${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} changed during refresh; skipping stale deactivation`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const accessTokenStillValid =
|
|
getEffectiveTokenExpiryMs(currentConnection || conn) > Date.now() + TOKEN_EXPIRY_BUFFER;
|
|
|
|
if (accessTokenStillValid) {
|
|
await updateProviderConnection(conn.id, {
|
|
lastHealthCheckAt: now,
|
|
testStatus: "active",
|
|
lastError: `Health check refresh failed (${result.error}). Re-authenticate before the current access token expires.`,
|
|
lastErrorAt: now,
|
|
lastErrorType: result.error,
|
|
lastErrorSource: "oauth",
|
|
errorCode: result.error,
|
|
});
|
|
logWarn(
|
|
`${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} refresh token is invalid (${result.error}), but the current access token is still valid; keeping connection active`
|
|
);
|
|
return;
|
|
}
|
|
|
|
await updateProviderConnection(conn.id, {
|
|
lastHealthCheckAt: now,
|
|
testStatus: "expired",
|
|
lastError: `Refresh token consumed (${result.error}). Please re-authenticate this account.`,
|
|
lastErrorAt: now,
|
|
lastErrorType: result.error,
|
|
lastErrorSource: "oauth",
|
|
errorCode: result.error,
|
|
isActive: false,
|
|
refreshToken: null,
|
|
});
|
|
logError(
|
|
`${LOG_PREFIX} ✗ ${conn.provider}/${getConnectionLogLabel(conn)} — ` +
|
|
`Refresh token is permanently invalid (${result.error}). ` +
|
|
`Connection deactivated. Re-authenticate to restore.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (result && result.accessToken) {
|
|
const updateData: any = {
|
|
accessToken: result.accessToken,
|
|
lastHealthCheckAt: now,
|
|
testStatus: "active",
|
|
lastError: null,
|
|
lastErrorAt: null,
|
|
lastErrorType: null,
|
|
lastErrorSource: null,
|
|
errorCode: null,
|
|
expiredRetryCount: null,
|
|
expiredRetryAt: null,
|
|
};
|
|
|
|
if (result.refreshToken) {
|
|
updateData.refreshToken = result.refreshToken;
|
|
}
|
|
|
|
if (result.expiresIn) {
|
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
|
|
updateData.expiresAt = expiresAt;
|
|
updateData.tokenExpiresAt = expiresAt;
|
|
} else if (result.expiresAt) {
|
|
updateData.expiresAt = result.expiresAt;
|
|
updateData.tokenExpiresAt = result.expiresAt;
|
|
}
|
|
|
|
if (result.providerSpecificData) {
|
|
updateData.providerSpecificData = {
|
|
...(conn.providerSpecificData || {}),
|
|
...result.providerSpecificData,
|
|
};
|
|
}
|
|
|
|
await updateProviderConnection(conn.id, updateData);
|
|
log(`${LOG_PREFIX} ✓ ${conn.provider}/${getConnectionLogLabel(conn)} refreshed`);
|
|
} else {
|
|
const updateData = buildRefreshFailureUpdate(conn, now);
|
|
await updateProviderConnection(conn.id, updateData);
|
|
logWarn(
|
|
`${LOG_PREFIX} ✗ ${conn.provider}/${getConnectionLogLabel(conn)} refresh failed` +
|
|
(conn.testStatus === "expired"
|
|
? ` (${updateData.expiredRetryCount}/${EXPIRED_RETRY_MAX} expired retries used)`
|
|
: "")
|
|
);
|
|
}
|
|
}
|
|
|
|
// Auto-start when imported
|
|
initTokenHealthCheck();
|
|
|
|
export default initTokenHealthCheck;
|