fix(token-refresh): exempt transient errors from exponential backoff (#9242)

* fix(token-refresh): exempt transient errors from exponential backoff

A refresh that failed on a network timeout was treated exactly like one
that failed on a revoked token: the streak incremented and the circuit
backed off exponentially, up to four hours. A brief upstream blip could
therefore park a healthy account for the rest of the day.

Transient failures now take a flat two-minute retry window instead of
advancing the streak. Classification checks structured signals first
(err.name for AbortError/TimeoutError, then err.code and err.cause.code)
and only falls back to matching the message text, so it does not depend
on upstream wording. Everything else keeps the existing exponential path.

Two properties worth preserving on sight:

  - A transient failure never shortens a longer permanent backoff. The
    new window is only adopted when the existing one is not already
    further out.
  - testStatus is preserved on both paths, so a connection whose access
    token is still valid keeps serving requests while its refresh
    retries.

Only a successful refresh clears the circuit. A successful request does
not, because requests do not refresh tokens.

* chore(quality): rebaseline file-size for tokenHealthCheck.ts

src/lib/tokenHealthCheck.ts lands at 1021 lines, above the 1000 cap. The
file consolidates token-refresh health checking that was previously split
across auth.ts and tokenRefresh.ts, and the refresh circuit state machine
does not divide cleanly, so splitting it to satisfy the cap would cost
more than it buys.

Scoped to this file only. Baseline entries for files this branch does not
touch are left at their upstream values.
This commit is contained in:
Bob.Hou
2026-08-04 13:33:54 -04:00
committed by GitHub
parent 45d375aa0b
commit b6bcc491bc
3 changed files with 362 additions and 47 deletions

View File

@@ -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,

View File

@@ -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<typeof updateProviderConnection>[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();

View File

@@ -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<string, unknown> } = {
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);
});
});