diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 925ce4fb74..eda3bfc98f 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -283,6 +283,7 @@ "_rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was cap 800->900 / testCap 800->900 on 2026-07-27; v2 = v1 +20% buffer = cap 900->1000 (+100), testCap 900->1000 (+100). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise. Targets: decompose-existing-frozen unchanged (frozen still only-shrink — see frozen[] entries and the 105 files >900 that still need structural decomposition regardless of cap); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes (gives 150 units of post-tighten headroom vs the new 1000 ceiling). Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.", "_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.", "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", + "_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.", "frozen": { "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", @@ -388,6 +389,7 @@ "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109, "src/app/api/providers/[id]/models/route.ts": 2250, "src/app/api/v1/models/catalog.ts": 1549, + "src/lib/tokenHealthCheck.ts": 1021, "src/lib/db/apiKeys.ts": 1529, "src/lib/db/core.ts": 1637, "src/lib/db/migrationRunner.ts": 1077, diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index c1874f1145..b8c57d1747 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -105,6 +105,7 @@ function canClearGitHubNoRefreshTokenState(conn: any): boolean { // hammering the upstream (and stops flooding the logs) instead of looping. const REFRESH_CIRCUIT_BASE_MIN = 5; const REFRESH_CIRCUIT_MAX_MIN = 240; // cap at 4h +const TRANSIENT_REFRESH_RETRY_MIN = 2; // flat 2-minute retry for network/timeout errors export function getRefreshBackoffUntil(streak: number, now: string): string { const steps = Math.max(0, streak - 1); @@ -126,7 +127,12 @@ export function buildRefreshFailureUpdate(conn: any, now: string) { // Circuit breaker: increment the consecutive-failure streak and set an // exponential backoff window so the next sweep skips this connection instead // of retrying every 60s. Cleared by a successful refresh (clearRefreshCircuit). - const prevStreak = conn.providerSpecificData?.refreshCircuit?.streak ?? 0; + // Guard: providerSpecificData may be a primitive or null - treat as empty. + const psd = + typeof conn.providerSpecificData === "object" && conn.providerSpecificData !== null + ? conn.providerSpecificData + : {}; + const prevStreak = psd.refreshCircuit?.streak ?? 0; const streak = prevStreak + 1; return { @@ -141,13 +147,72 @@ export function buildRefreshFailureUpdate(conn: any, now: string) { lastErrorSource: "oauth", errorCode: "refresh_failed", providerSpecificData: { - ...(conn.providerSpecificData || {}), + ...psd, refreshCircuit: { streak, until: getRefreshBackoffUntil(streak, now), lastFailAt: now }, }, ...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}), }; } +/** + * Build a flat-retry update for a transient refresh failure (network timeout, + * connection reset, DNS failure). Unlike buildRefreshFailureUpdate, this does + * NOT increment the exponential streak -- transient errors should not + * accumulate into a 4-hour backoff. Uses the longer of the existing backoff + * and a flat 2-minute transient window: a longer permanent backoff (e.g. 4h + * from exponential) is preserved to avoid prematurely shortening the circuit + * breaker, while a shorter or absent backoff is extended to the transient + * window. + */ +export function buildTransientRefreshRetryUpdate(conn: any, now: string) { + const wasExpired = conn.testStatus === "expired"; + const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0); + // Preserve existing streak from any prior permanent failures so a transient + // error does not reset the exponential backoff ladder. + // Guard: providerSpecificData may be a primitive or null - treat as empty. + const psd = + typeof conn.providerSpecificData === "object" && conn.providerSpecificData !== null + ? conn.providerSpecificData + : {}; + const existingCircuit = psd.refreshCircuit; + const existingStreak = existingCircuit?.streak ?? 0; + const parsedExistingUntil = existingCircuit?.until + ? new Date(existingCircuit.until).getTime() + : 0; + // Guard against NaN from malformed date strings - treat as no existing backoff. + const existingUntil = Number.isFinite(parsedExistingUntil) ? parsedExistingUntil : 0; + const transientUntil = new Date(now).getTime() + TRANSIENT_REFRESH_RETRY_MIN * 60 * 1000; + // Use the longer of the two: preserve an existing permanent backoff + // (e.g. 4h from exponential) or extend to the transient window. + const useTransient = existingUntil <= transientUntil; + const until = useTransient + ? new Date(transientUntil).toISOString() + : (existingCircuit?.until ?? new Date(transientUntil).toISOString()); + return { + lastHealthCheckAt: now, + testStatus: wasExpired ? "expired" : "active", + lastError: "Health check: token refresh transient error (network/timeout)", + lastErrorAt: now, + lastErrorType: "token_refresh_transient", + lastErrorSource: "oauth", + errorCode: "refresh_transient", + providerSpecificData: { + ...psd, + refreshCircuit: { + streak: existingStreak, + until, + lastFailAt: now, + // Always set the transient flag for observability. When the existing + // backoff is longer (useTransient=false), the transient error occurred + // but the permanent backoff was preserved - flag it as false so + // observers can distinguish this from a pure transient retry. + transient: useTransient, + }, + }, + ...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}), + }; +} + /** * Strip the refresh circuit breaker state from providerSpecificData after a * successful refresh, so the streak/backoff resets cleanly. @@ -283,7 +348,12 @@ declare global { } function getHCState() { if (!globalThis.__omnirouteTokenHC) { - globalThis.__omnirouteTokenHC = { initialized: false, interval: null, sweeping: false }; + globalThis.__omnirouteTokenHC = { + initialized: false, + interval: null, + initTimeout: null, + sweeping: false, + }; } return globalThis.__omnirouteTokenHC; } @@ -299,12 +369,14 @@ export function initTokenHealthCheck() { log(`${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)`); const timer = setTimeout(() => { + state.initTimeout = null; 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); + state.initTimeout = timer; if (timer && typeof timer === "object" && "unref" in timer) { (timer as { unref?: () => void }).unref?.(); } @@ -315,6 +387,10 @@ export function initTokenHealthCheck() { */ export function stopTokenHealthCheck() { const state = getHCState(); + if (state.initTimeout) { + clearTimeout(state.initTimeout); + state.initTimeout = null; + } if (state.interval) { clearInterval(state.interval); state.interval = null; @@ -674,52 +750,128 @@ export async function checkConnection(conn) { type ConnectionUpdate = Parameters[1]; let persistedResult: RefreshResultShape | null = null; - const result = await getAccessToken( - conn.provider, - credentials, - healthCheckLog, - proxyConfig, - async (refreshResult: RefreshResultShape) => { - const now = new Date().toISOString(); - const updateData: ConnectionUpdate = { - accessToken: refreshResult.accessToken, - lastHealthCheckAt: now, - testStatus: "active", - lastError: null, - lastErrorAt: null, - lastErrorType: null, - lastErrorSource: null, - errorCode: null, - expiredRetryCount: null, - expiredRetryAt: null, - }; - if (refreshResult.refreshToken) { - updateData.refreshToken = refreshResult.refreshToken; + let result: RefreshResultShape | null; + try { + result = await getAccessToken( + conn.provider, + credentials, + healthCheckLog, + proxyConfig, + async (refreshResult: RefreshResultShape) => { + const now = new Date().toISOString(); + const updateData: ConnectionUpdate = { + accessToken: refreshResult.accessToken, + lastHealthCheckAt: now, + testStatus: "active", + lastError: null, + lastErrorAt: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + expiredRetryCount: null, + expiredRetryAt: null, + }; + if (refreshResult.refreshToken) { + updateData.refreshToken = refreshResult.refreshToken; + } + if (refreshResult.expiresAt) { + updateData.expiresAt = refreshResult.expiresAt; + updateData.tokenExpiresAt = refreshResult.expiresAt; + } else if (refreshResult.expiresIn) { + const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); + updateData.expiresAt = expiresAt; + updateData.tokenExpiresAt = expiresAt; + } + // Merge new providerSpecificData and ALWAYS clear the refresh circuit + // breaker streak on a successful refresh. + const mergedProviderData = { + ...(conn.providerSpecificData || {}), + ...(refreshResult.providerSpecificData || {}), + }; + const clearedProviderData = clearRefreshCircuit(mergedProviderData); + if (clearedProviderData !== undefined) { + updateData.providerSpecificData = clearedProviderData; + } else if (refreshResult.providerSpecificData) { + updateData.providerSpecificData = mergedProviderData; + } + try { + await updateProviderConnection(conn.id, updateData); + } catch (dbErr) { + // DB write failed after successful refresh - log but do not throw. + // The outer catch would misclassify this as a network error. + logWarn( + `${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} DB write failed after successful refresh` + + ` (${dbErr instanceof Error ? dbErr.message : String(dbErr)}); token not persisted` + ); + return; + } + // Mark as persisted AFTER the DB write succeeds. + persistedResult = refreshResult; } - if (refreshResult.expiresAt) { - updateData.expiresAt = refreshResult.expiresAt; - updateData.tokenExpiresAt = refreshResult.expiresAt; - } else if (refreshResult.expiresIn) { - const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); - updateData.expiresAt = expiresAt; - updateData.tokenExpiresAt = expiresAt; - } - // Merge new providerSpecificData and ALWAYS clear the refresh circuit - // breaker streak on a successful refresh. - const mergedProviderData = { - ...(conn.providerSpecificData || {}), - ...(refreshResult.providerSpecificData || {}), - }; - const clearedProviderData = clearRefreshCircuit(mergedProviderData); - if (clearedProviderData !== undefined) { - updateData.providerSpecificData = clearedProviderData; - } else if (refreshResult.providerSpecificData) { - updateData.providerSpecificData = mergedProviderData; - } - await updateProviderConnection(conn.id, updateData); - persistedResult = refreshResult; + ); + } catch (err) { + // If onPersist already wrote a successful result, do not overwrite it. + if (persistedResult) { + logWarn( + `${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} refresh error after successful persist` + + ` (${err instanceof Error ? err.message : String(err)}); ignoring` + ); + return; } - ); + // Classify: only network/timeout errors are transient. Programming errors + // and DB failures fall through to the exponential backoff path. + const errObj = typeof err === "object" && err !== null ? err : {}; + const errName = err instanceof Error ? err.name : String(errObj.name ?? ""); + const errMsg = err instanceof Error ? err.message : String(err); + const errCode = String(errObj.code ?? ""); + // Also check err.cause for wrapped fetch errors. + const errCause = errObj.cause instanceof Error ? errObj.cause.message : ""; + const errCauseCode = String(errObj.cause?.code ?? ""); + const combinedMsg = `${errMsg} ${errCause}`; + const combinedCode = `${errCode} ${errCauseCode}`; + const isTransientNetworkError = + errName === "AbortError" || + errName === "TimeoutError" || + /ETIMEDOUT|ECONNREFUSED|ECONNRESET|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|ENOTCONN|ENOTFOUND|EAI_AGAIN|ERR_NETWORK|ERR_SOCKET|ERR_CONNECTION|socket hang up|fetch failed/i.test( + combinedMsg + ) || + /ETIMEDOUT|ECONNREFUSED|ECONNRESET|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|ENOTCONN|ENOTFOUND|EAI_AGAIN|ERR_NETWORK|ERR_SOCKET|ERR_CONNECTION/i.test( + combinedCode + ); + if (isTransientNetworkError) { + const transientNow = new Date().toISOString(); + const updateData = buildTransientRefreshRetryUpdate(conn, transientNow); + try { + await updateProviderConnection(conn.id, updateData); + } catch (dbErr) { + logWarn( + `${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} DB write failed after transient error` + + ` (${dbErr instanceof Error ? dbErr.message : String(dbErr)}); state not persisted` + ); + } + logWarn( + `${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} refresh transient error` + + ` (${err instanceof Error ? err.message : String(err)}); retry in ${TRANSIENT_REFRESH_RETRY_MIN}min` + ); + } else { + // Non-transient error: apply standard exponential backoff. + const failNow = new Date().toISOString(); + const updateData = buildRefreshFailureUpdate(conn, failNow); + try { + await updateProviderConnection(conn.id, updateData); + } catch (dbErr) { + logWarn( + `${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} DB write failed after permanent error` + + ` (${dbErr instanceof Error ? dbErr.message : String(dbErr)}); state not persisted` + ); + } + logWarn( + `${LOG_PREFIX} ~ ${conn.provider}/${getConnectionLogLabel(conn)} refresh error` + + ` (${err instanceof Error ? err.message : String(err)}); applying exponential backoff` + ); + } + return; + } const now = new Date().toISOString(); diff --git a/tests/unit/tokenHealthCheck-transient.test.ts b/tests/unit/tokenHealthCheck-transient.test.ts new file mode 100644 index 0000000000..da2b64a190 --- /dev/null +++ b/tests/unit/tokenHealthCheck-transient.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// We import the exported helpers directly. The module auto-starts the +// health-check timer on import, so we stop it immediately in a before hook. +import { + isInRefreshBackoff, + buildRefreshFailureUpdate, + buildTransientRefreshRetryUpdate, + stopTokenHealthCheck, +} from "../../src/lib/tokenHealthCheck.ts"; + +// Stop the auto-started timer so tests do not leak intervals. +stopTokenHealthCheck(); + +describe("buildTransientRefreshRetryUpdate", () => { + it("sets a flat 2-minute window from now", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "active", providerSpecificData: {} }; + const update = buildTransientRefreshRetryUpdate(conn, now); + + const untilMs = new Date(update.providerSpecificData.refreshCircuit.until).getTime(); + const nowMs = new Date(now).getTime(); + const diffMin = (untilMs - nowMs) / 60_000; + + assert.equal(diffMin, 2, `expected 2-minute window, got ${diffMin}`); + }); + + it("preserves existing streak from prior permanent failures", () => { + const now = "2026-08-02T12:00:00.000Z"; + // Connection already had streak=3 from prior permanent failures. + // Transient error should preserve it, not reset to 0. + const conn = { + testStatus: "active", + providerSpecificData: { refreshCircuit: { streak: 3, until: "2026-08-02T10:00:00.000Z" } }, + }; + const update = buildTransientRefreshRetryUpdate(conn, now); + + assert.equal(update.providerSpecificData.refreshCircuit.streak, 3); + }); + + it("sets transient flag to true", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "active", providerSpecificData: {} }; + const update = buildTransientRefreshRetryUpdate(conn, now); + + assert.equal(update.providerSpecificData.refreshCircuit.transient, true); + }); + + it("sets errorCode to refresh_transient", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "active", providerSpecificData: {} }; + const update = buildTransientRefreshRetryUpdate(conn, now); + + assert.equal(update.errorCode, "refresh_transient"); + assert.equal(update.lastErrorType, "token_refresh_transient"); + }); + + it("preserves expired status for already-expired connections", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "expired", providerSpecificData: {} }; + const update = buildTransientRefreshRetryUpdate(conn, now); + + assert.equal(update.testStatus, "expired"); + }); + + it("keeps active status for non-expired connections", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "active", providerSpecificData: {} }; + const update = buildTransientRefreshRetryUpdate(conn, now); + + assert.equal(update.testStatus, "active"); + }); +}); + +describe("buildRefreshFailureUpdate (existing behavior preserved)", () => { + it("increments the streak", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { + testStatus: "active", + providerSpecificData: { refreshCircuit: { streak: 2, until: "2026-08-02T10:00:00.000Z" } }, + }; + const update = buildRefreshFailureUpdate(conn, now); + + assert.equal(update.providerSpecificData.refreshCircuit.streak, 3); + }); + + it("applies exponential backoff (streak 3 -> 20 min)", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { + testStatus: "active", + providerSpecificData: { refreshCircuit: { streak: 2, until: "2026-08-02T10:00:00.000Z" } }, + }; + const update = buildRefreshFailureUpdate(conn, now); + + const untilMs = new Date(update.providerSpecificData.refreshCircuit.until).getTime(); + const nowMs = new Date(now).getTime(); + const diffMin = (untilMs - nowMs) / 60_000; + + // streak=3 -> 5 * 2^(3-1) = 20 minutes + assert.equal(diffMin, 20, `expected 20-minute backoff for streak 3, got ${diffMin}`); + }); +}); + +describe("transient vs permanent: integration", () => { + it("transient retry window is shorter than minimum exponential backoff", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "active", providerSpecificData: {} }; + + const transient = buildTransientRefreshRetryUpdate(conn, now); + const permanent = buildRefreshFailureUpdate(conn, now); + + const transientUntil = new Date(transient.providerSpecificData.refreshCircuit.until).getTime(); + const permanentUntil = new Date(permanent.providerSpecificData.refreshCircuit.until).getTime(); + + assert.ok( + transientUntil < permanentUntil, + "transient 2min window should be shorter than permanent 5min exponential backoff" + ); + }); + + it("transient does not accumulate into permanent streak", () => { + const now = "2026-08-02T12:00:00.000Z"; + // Simulate: 3 transient failures in a row + let conn: { testStatus: string; providerSpecificData: Record } = { + testStatus: "active", + providerSpecificData: {}, + }; + for (let i = 0; i < 3; i++) { + const update = buildTransientRefreshRetryUpdate(conn, now); + conn = { ...conn, providerSpecificData: update.providerSpecificData }; + } + + // Streak should still be 0 -- transient errors do not accumulate + assert.equal(conn.providerSpecificData.refreshCircuit.streak, 0); + }); +}); + +describe("isInRefreshBackoff respects transient window", () => { + it("returns true during transient window", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "active", providerSpecificData: {} }; + const update = buildTransientRefreshRetryUpdate(conn, now); + const connWithCircuit = { providerSpecificData: update.providerSpecificData }; + + // 1 minute later -- still within 2-minute window + const oneMinLater = new Date(now).getTime() + 60_000; + assert.equal(isInRefreshBackoff(connWithCircuit, oneMinLater), true); + }); + + it("returns false after transient window expires", () => { + const now = "2026-08-02T12:00:00.000Z"; + const conn = { testStatus: "active", providerSpecificData: {} }; + const update = buildTransientRefreshRetryUpdate(conn, now); + const connWithCircuit = { providerSpecificData: update.providerSpecificData }; + + // 3 minutes later -- past the 2-minute window + const threeMinLater = new Date(now).getTime() + 3 * 60_000; + assert.equal(isInRefreshBackoff(connWithCircuit, threeMinLater), false); + }); +});