diff --git a/changelog.d/fixes/18507-version-lookup-retry-window.md b/changelog.d/fixes/18507-version-lookup-retry-window.md new file mode 100644 index 000000000000..3f61bb16cf35 --- /dev/null +++ b/changelog.d/fixes/18507-version-lookup-retry-window.md @@ -0,0 +1 @@ +- **fix(providers):** Stop the Copilot, Claude Code, Gemini, Qwen, CodeBuddy, Kimi and Zed client-version getters from repeating their npm/GitHub lookup on every call when the registry answers the pinned version (their cache only kept newer versions), and stop all of them, OpenCode included, from retrying a failed lookup on every request: an answer now counts as checked for the existing 6-hour TTL, and a failure is retried after 15 minutes ([#18507](https://github.com/diegosouzapw/OmniRoute/pull/18507)) diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index f865be0aaf9c..540a07a73855 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -1,6 +1,9 @@ import { getAntigravityContentHeaders } from "../services/antigravityHeaders.ts"; import type { AntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; -import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "@/shared/utils/backgroundRefresh"; // GitHub Copilot request identity. Ported to match the GitHub Copilot CLI // (`copilot` npm package) wire identity that Hermes captured live, NOT the @@ -24,6 +27,9 @@ type CopilotFetchLike = typeof fetch; let copilotVersionCache: { fetchedAt: number; version: string } | null = null; let copilotVersionInFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache only keeps versions newer than the pin), the retry window after a failure. +let copilotNextRefreshAt = 0; function getSafeCopilotEnvValue(name: string, pattern: RegExp): string | null { const raw = typeof process === "undefined" ? undefined : process.env?.[name]; @@ -103,6 +109,9 @@ export function resolveGitHubCopilotCliVersion( if (resolved && compareCopilotDottedTriple(resolved, GITHUB_COPILOT_CLI_VERSION) > 0) { copilotVersionCache = { fetchedAt: Date.now(), version: resolved }; } + copilotNextRefreshAt = + Date.now() + + (resolved ? GITHUB_COPILOT_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickCopilotVersionAtLeastPin(resolved ?? readFreshCopilotVersionCache()); })(); @@ -115,6 +124,7 @@ export function resolveGitHubCopilotCliVersion( /** Test seam: drop the registry cache so the next resolve hits the network. */ export function resetGitHubCopilotCliVersionCache(): void { + copilotNextRefreshAt = 0; copilotVersionCache = null; copilotVersionInFlight = null; } @@ -130,7 +140,8 @@ export function getGitHubCopilotCliVersion(): string { canStartBackgroundRefresh() && !process.env.NODE_TEST_CONTEXT && !readFreshCopilotVersionCache() && - !copilotVersionInFlight + !copilotVersionInFlight && + Date.now() >= copilotNextRefreshAt ) void resolveGitHubCopilotCliVersion(); return pickCopilotVersionAtLeastPin(readFreshCopilotVersionCache()); @@ -242,6 +253,9 @@ let qwenVersionCache: VersionCache | null = null; let qwenVersionInFlight: Promise | null = null; let codeBuddyVersionCache: VersionCache | null = null; let codeBuddyVersionInFlight: Promise | null = null; +// Earliest time each getter may start another background lookup (see refreshNpmDottedTriple). +let qwenNextRefreshAt = 0; +let codeBuddyNextRefreshAt = 0; function parseDottedTriple(value: unknown): string | null { if (typeof value !== "string") return null; @@ -288,6 +302,8 @@ function refreshNpmDottedTriple(options: { setCache: (next: VersionCache) => void; readInFlight: () => Promise | null; setInFlight: (next: Promise | null) => void; + /** Records when a getter may start the next background lookup. */ + setNextRefreshAt: (at: number) => void; fetchImpl: NpmFetchLike; }): Promise { const fresh = readFreshVersionCache(options.readCache(), options.ttlMs); @@ -317,6 +333,11 @@ function refreshNpmDottedTriple(options: { clearTimeout(timeoutId); } + // Any answer counts as checked for the TTL — the cache only keeps newer-than-pin versions, + // so an answer equal to the pin would otherwise be re-fetched on every call. + options.setNextRefreshAt( + Date.now() + (resolved ? options.ttlMs : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS) + ); if (resolved && compareDottedTriple(resolved, options.pin) > 0) { options.setCache({ fetchedAt: Date.now(), version: resolved }); return resolved; @@ -335,7 +356,7 @@ function refreshNpmDottedTriple(options: { /** Sync hot path. Cache if newer than the pin, else the pin. Never waits. */ export function getQwenCliVersion(): string { - if (shouldAutoRefreshCliVersion() && !qwenVersionInFlight) { + if (shouldAutoRefreshCliVersion() && !qwenVersionInFlight && Date.now() >= qwenNextRefreshAt) { const fresh = readFreshVersionCache(qwenVersionCache, QWEN_CLI_VERSION_CACHE_TTL_MS); if (!fresh || compareDottedTriple(fresh, QWEN_CLI_VERSION) <= 0) { void refreshQwenCliVersion(); @@ -361,11 +382,15 @@ export function refreshQwenCliVersion(fetchImpl: NpmFetchLike = fetch): Promise< setInFlight: (next) => { qwenVersionInFlight = next; }, + setNextRefreshAt: (at) => { + qwenNextRefreshAt = at; + }, fetchImpl, }); } export function resetQwenCliVersionCache(): void { + qwenNextRefreshAt = 0; qwenVersionCache = null; qwenVersionInFlight = null; } @@ -376,7 +401,11 @@ function formatCodeBuddyCnUserAgent(version: string): string { /** Sync hot path. Both numbers move together, or the captured pin stays. */ export function getCodeBuddyCnUserAgent(): string { - if (shouldAutoRefreshCliVersion() && !codeBuddyVersionInFlight) { + if ( + shouldAutoRefreshCliVersion() && + !codeBuddyVersionInFlight && + Date.now() >= codeBuddyNextRefreshAt + ) { const fresh = readFreshVersionCache(codeBuddyVersionCache, CODEBUDDY_CN_VERSION_CACHE_TTL_MS); if (!fresh) void refreshCodeBuddyCnUserAgent(); } @@ -401,11 +430,15 @@ export function refreshCodeBuddyCnUserAgent(fetchImpl: NpmFetchLike = fetch): Pr setInFlight: (next) => { codeBuddyVersionInFlight = next; }, + setNextRefreshAt: (at) => { + codeBuddyNextRefreshAt = at; + }, fetchImpl, }).then((version) => formatCodeBuddyCnUserAgent(version)); } export function resetCodeBuddyCnVersionCache(): void { + codeBuddyNextRefreshAt = 0; codeBuddyVersionCache = null; codeBuddyVersionInFlight = null; } diff --git a/open-sse/config/providers/registry/kimi/coding/runtime.ts b/open-sse/config/providers/registry/kimi/coding/runtime.ts index cfec5a7f4970..bc58ba5f4885 100644 --- a/open-sse/config/providers/registry/kimi/coding/runtime.ts +++ b/open-sse/config/providers/registry/kimi/coding/runtime.ts @@ -1,4 +1,7 @@ -import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "@/shared/utils/backgroundRefresh"; export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1"; export const KIMI_CODING_MODELS_URL = `${KIMI_CODING_BASE_URL}/models`; @@ -81,6 +84,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; let fetchImpl: FetchLike = fetch; function parseDottedTriple(value: unknown): string | null { @@ -129,7 +135,8 @@ export function getKimiCodeCliVersion(): string { canStartBackgroundRefresh() && !process.env.NODE_TEST_CONTEXT && !readFreshCache() && - !inFlight + !inFlight && + Date.now() >= nextBackgroundRefreshAt ) { void refreshKimiCodeCliVersion(); } @@ -178,6 +185,9 @@ export function refreshKimiCodeCliVersion(): Promise { cachedVersion = resolved; cachedAt = Date.now(); } + nextBackgroundRefreshAt = + Date.now() + + (resolved ? KIMI_CODE_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickAtLeastPin(resolved ?? readFreshCache()); })(); @@ -190,6 +200,7 @@ export function refreshKimiCodeCliVersion(): Promise { /** Test seam: drop the registry cache so the next refresh hits the network. */ export function resetKimiCodeCliVersionCache(): void { + nextBackgroundRefreshAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/open-sse/executors/zedClientVersion.ts b/open-sse/executors/zedClientVersion.ts index bf2ffaae620e..c7e4e43923ac 100644 --- a/open-sse/executors/zedClientVersion.ts +++ b/open-sse/executors/zedClientVersion.ts @@ -7,7 +7,10 @@ * executor config.appVersion still wins over both. */ -import { canStartBackgroundRefresh } from "@/shared/utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "@/shared/utils/backgroundRefresh"; export const ZED_CLIENT_VERSION = "0.200.0"; @@ -21,6 +24,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; function parseReleaseTag(tagName: unknown): string | null { if (typeof tagName !== "string") return null; @@ -60,7 +66,8 @@ export function getZedClientVersion(): string { canStartBackgroundRefresh() && !process.env.NODE_TEST_CONTEXT && !readFreshCache() && - !inFlight + !inFlight && + Date.now() >= nextBackgroundRefreshAt ) { void refreshZedClientVersion(); } @@ -104,6 +111,9 @@ export function refreshZedClientVersion(fetchImpl: FetchLike = fetch): Promise | null = null; +// A failed lookup caches nothing and this refresh runs on every request, so a failure is +// retried only after the shared retry window. +let retryAfterFailureAt = 0; export function isOpencodeCliVersion(value: unknown): value is string { return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value.trim()); @@ -57,7 +62,11 @@ export function refreshOpencodeCliVersion(): Promise { return Promise.resolve(cachedVersion); } if (inFlight) return inFlight; + if (Date.now() < retryAfterFailureAt) { + return Promise.resolve(cachedVersion ?? OPENCODE_CLI_VERSION_FALLBACK); + } inFlight = (async () => { + let answered = false; try { const response = await globalThis.fetch(NPM_OPENCODE_LATEST_URL, { headers: { Accept: "application/json" }, @@ -68,10 +77,12 @@ export function refreshOpencodeCliVersion(): Promise { if (isOpencodeCliVersion(version)) { cachedVersion = version; cachedAt = Date.now(); + answered = true; } } catch { // Registry unreachable — stale cache / pin stay valid. } + retryAfterFailureAt = answered ? 0 : Date.now() + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS; return cachedVersion ?? OPENCODE_CLI_VERSION_FALLBACK; })(); const current = inFlight; @@ -83,6 +94,7 @@ export function refreshOpencodeCliVersion(): Promise { /** Test seam: clear the cache. */ export function resetOpencodeCliVersionCache(): void { + retryAfterFailureAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/src/shared/constants/claudeCodeClient.ts b/src/shared/constants/claudeCodeClient.ts index f1c06ea016e9..2fa49f4b9af1 100644 --- a/src/shared/constants/claudeCodeClient.ts +++ b/src/shared/constants/claudeCodeClient.ts @@ -12,7 +12,10 @@ * uses that version when it is newer than the pin. The dashboard override * (`cliVersions.ts`) wins over everything, then the env override. The lookup is fire-and-forget; a cold or failed fetch stays on the pin. */ -import { canStartBackgroundRefresh } from "../utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "../utils/backgroundRefresh"; import { getCliVersionOverride, type CliVersionSource, @@ -40,6 +43,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; function getSafeEnvValue(name: string, pattern: RegExp): string | null { const raw = typeof process === "undefined" ? undefined : process.env?.[name]; @@ -94,7 +100,12 @@ export function getClaudeCodeClientVersion(): string { getCliVersionOverride("claude") || getSafeEnvValue(CLAUDE_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN); if (override) return override; - if (shouldAutoRefreshClaudeCodeVersion() && !readFreshCache() && !inFlight) { + if ( + shouldAutoRefreshClaudeCodeVersion() && + !readFreshCache() && + !inFlight && + Date.now() >= nextBackgroundRefreshAt + ) { void refreshClaudeCodeClientVersion(); } return pickAtLeastPin(readFreshCache()); @@ -156,6 +167,9 @@ export function refreshClaudeCodeClientVersion(fetchImpl: FetchLike = fetch): Pr cachedVersion = resolved; cachedAt = Date.now(); } + nextBackgroundRefreshAt = + Date.now() + + (resolved ? CLAUDE_CODE_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickAtLeastPin(resolved ?? readFreshCache()); })(); @@ -168,6 +182,7 @@ export function refreshClaudeCodeClientVersion(fetchImpl: FetchLike = fetch): Pr /** Test seam: drop the registry cache so the next refresh hits the network. */ export function resetClaudeCodeClientVersionCache(): void { + nextBackgroundRefreshAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/src/shared/constants/geminiCliClient.ts b/src/shared/constants/geminiCliClient.ts index 161fa05f4ec8..033d86a28daa 100644 --- a/src/shared/constants/geminiCliClient.ts +++ b/src/shared/constants/geminiCliClient.ts @@ -8,7 +8,10 @@ * version when it is a dotted triple newer than the pin. The lookup is * fire-and-forget; a cold or failed fetch stays on the pin. */ -import { canStartBackgroundRefresh } from "../utils/backgroundRefresh"; +import { + BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS, + canStartBackgroundRefresh, +} from "../utils/backgroundRefresh"; export const GEMINI_CLI_CLIENT_VERSION = "0.1.0"; export const GEMINI_CLI_USER_AGENT_SUFFIX = "(linux; x64)"; @@ -24,6 +27,9 @@ type FetchLike = typeof fetch; let cachedVersion: string | null = null; let cachedAt = 0; let inFlight: Promise | null = null; +// Earliest time the getter may start another background lookup: one TTL after any answer +// (the cache below only keeps versions newer than the pin), the retry window after a failure. +let nextBackgroundRefreshAt = 0; function parseDottedTriple(value: unknown): string | null { if (typeof value !== "string") return null; @@ -64,7 +70,12 @@ function shouldAutoRefreshGeminiCliVersion(): boolean { * itself never waits on the network. */ export function getGeminiCliClientVersion(): string { - if (shouldAutoRefreshGeminiCliVersion() && !readFreshCache() && !inFlight) { + if ( + shouldAutoRefreshGeminiCliVersion() && + !readFreshCache() && + !inFlight && + Date.now() >= nextBackgroundRefreshAt + ) { void refreshGeminiCliClientVersion(); } return pickAtLeastPin(readFreshCache()); @@ -114,6 +125,9 @@ export function refreshGeminiCliClientVersion(fetchImpl: FetchLike = fetch): Pro cachedVersion = resolved; cachedAt = Date.now(); } + nextBackgroundRefreshAt = + Date.now() + + (resolved ? GEMINI_CLI_VERSION_CACHE_TTL_MS : BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS); return pickAtLeastPin(resolved ?? readFreshCache()); })(); @@ -126,6 +140,7 @@ export function refreshGeminiCliClientVersion(fetchImpl: FetchLike = fetch): Pro /** Test seam: drop the registry cache so the next refresh hits the network. */ export function resetGeminiCliClientVersionCache(): void { + nextBackgroundRefreshAt = 0; cachedVersion = null; cachedAt = 0; inFlight = null; diff --git a/src/shared/utils/backgroundRefresh.ts b/src/shared/utils/backgroundRefresh.ts index 5ee43487135c..af01834834d7 100644 --- a/src/shared/utils/backgroundRefresh.ts +++ b/src/shared/utils/backgroundRefresh.ts @@ -1,3 +1,11 @@ +/** + * How long a sync getter waits before re-trying a background version lookup that failed + * (network error, non-2xx, unparseable payload). A failure caches nothing, and the getters + * run per upstream request, so without a wait a down or rate-limited registry is asked on + * every request. A lookup that answered (newer or not) waits the module's own cache TTL. + */ +export const BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS = 15 * 60 * 1000; + /** * Whether this runtime may start a background network refresh (a client-version lookup * against GitHub or the npm registry). diff --git a/tests/unit/version-refresh-retry-window.test.ts b/tests/unit/version-refresh-retry-window.test.ts new file mode 100644 index 000000000000..b95fadfd5b60 --- /dev/null +++ b/tests/unit/version-refresh-retry-window.test.ts @@ -0,0 +1,192 @@ +/** + * Client-version getters must not re-run their background lookup on every call. + * + * The sync getters (`get…Version()` / `get…UserAgent()`) start a background npm/GitHub + * lookup whenever their cache is empty. The cache was only written when the registry + * reported a version *newer* than the pin, so when the registry answered the pin itself (the + * normal state right after a pin bump) or the lookup failed, nothing was recorded and the next + * call — the server builds these headers per upstream request — fetched again. OpenCode + * cached any answer but nothing on failure, and its refresh runs on every request. + * + * After the fix an answer that is not newer counts as checked for the module's TTL, and a + * failure is retried only after BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +type Mode = "pin" | "fail"; +let mode: Mode = "pin"; +const fetchCalls: string[] = []; +const realFetch = globalThis.fetch; + +// Installed before any import: some modules capture `fetch` at load time. +globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchCalls.push(url); + if (mode === "fail") return new Response("{}", { status: 503 }); + const pin = PINS.find(([pattern]) => pattern.test(url))?.[1]; + if (!pin) return new Response("{}", { status: 404 }); + return Response.json({ version: pin, tag_name: `v${pin}` }); +}) as typeof fetch; + +const profiles = await import("../../open-sse/config/providerHeaderProfiles.ts"); +const kimi = await import("../../open-sse/config/providers/registry/kimi/coding/runtime.ts"); +const zed = await import("../../open-sse/executors/zedClientVersion.ts"); +const opencodeHeaders = await import("../../open-sse/utils/opencodeHeaders.ts"); +const opencodeVersion = await import("../../open-sse/utils/opencodeCliVersion.ts"); +const claude = await import("../../src/shared/constants/claudeCodeClient.ts"); +const gemini = await import("../../src/shared/constants/geminiCliClient.ts"); +const { BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS } = + await import("../../src/shared/utils/backgroundRefresh.ts"); + +const CODEBUDDY_PIN = profiles.CODEBUDDY_CN_USER_AGENT.match(/(\d+\.\d+\.\d+)/)?.[1] ?? "0.0.0"; + +// What the stub answers in "pin" mode: exactly the pinned version, never a newer one. +const PINS: Array<[RegExp, string]> = [ + [/@github\/copilot/, profiles.GITHUB_COPILOT_CLI_VERSION], + [/@qwen-code/, profiles.QWEN_CLI_VERSION], + [/@tencent-ai\/codebuddy-code/, CODEBUDDY_PIN], + [/@moonshot-ai\/kimi-code/, kimi.KIMI_CODE_CLI_VERSION], + [/zed-industries\/zed/, zed.ZED_CLIENT_VERSION], + [/@anthropic-ai\/claude-code/, claude.CLAUDE_CODE_CLIENT_VERSION], + [/@google\/gemini-cli/, gemini.GEMINI_CLI_CLIENT_VERSION], + [/opencode-ai/, opencodeVersion.OPENCODE_CLI_VERSION_FALLBACK], +]; + +type Getter = { + name: string; + host: RegExp; + call: () => unknown; + reset: () => void; + /** OpenCode already caches any successful answer; only its failure path was unbounded. */ + pinAnswerAlreadyCached?: boolean; +}; + +const GETTERS: Getter[] = [ + { + name: "GitHub Copilot CLI", + host: /@github\/copilot/, + call: () => profiles.getGitHubCopilotCliVersion(), + reset: () => profiles.resetGitHubCopilotCliVersionCache(), + }, + { + name: "Qwen Code", + host: /@qwen-code/, + call: () => profiles.getQwenCliVersion(), + reset: () => profiles.resetQwenCliVersionCache(), + }, + { + name: "CodeBuddy CN", + host: /@tencent-ai\/codebuddy-code/, + call: () => profiles.getCodeBuddyCnUserAgent(), + reset: () => profiles.resetCodeBuddyCnVersionCache(), + }, + { + name: "Kimi Code CLI", + host: /@moonshot-ai\/kimi-code/, + call: () => kimi.getKimiCodeCliVersion(), + reset: () => kimi.resetKimiCodeCliVersionCache(), + }, + { + name: "Zed", + host: /zed-industries\/zed/, + call: () => zed.getZedClientVersion(), + reset: () => zed.resetZedClientVersionCache(), + }, + { + name: "Claude Code", + host: /@anthropic-ai\/claude-code/, + call: () => claude.getClaudeCodeClientVersion(), + reset: () => claude.resetClaudeCodeClientVersionCache(), + }, + { + name: "Gemini CLI", + host: /@google\/gemini-cli/, + call: () => gemini.getGeminiCliClientVersion(), + reset: () => gemini.resetGeminiCliClientVersionCache(), + }, + { + name: "OpenCode CLI", + host: /opencode-ai/, + call: () => opencodeHeaders.resolveOpencodeCliDefaults("opencode", false), + reset: () => opencodeVersion.resetOpencodeCliVersionCache(), + pinAnswerAlreadyCached: true, + }, +]; + +// The getters skip their refresh under a test runner; clear those markers while measuring. +const RUNNER_ENV = ["NODE_TEST_CONTEXT", "VITEST", "NODE_ENV"] as const; + +async function outsideTestRunner(fn: () => Promise): Promise { + const saved = RUNNER_ENV.map((key) => [key, process.env[key]] as const); + for (const key of RUNNER_ENV) delete process.env[key]; + try { + return await fn(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +/** Calls the getter `times` times, letting each started lookup reach fetch and settle. */ +async function fetchesFrom(getter: Getter, times = 1): Promise { + const before = fetchCalls.filter((url) => getter.host.test(url)).length; + for (let i = 0; i < times; i++) { + getter.call(); + await new Promise((resolve) => setTimeout(resolve, 15)); + } + return fetchCalls.filter((url) => getter.host.test(url)).length - before; +} + +async function withClockAdvancedBy(ms: number, fn: () => Promise): Promise { + const realNow = Date.now; + Date.now = () => realNow() + ms; + try { + return await fn(); + } finally { + Date.now = realNow; + } +} + +test.after(() => { + globalThis.fetch = realFetch; +}); + +for (const getter of GETTERS) { + if (!getter.pinAnswerAlreadyCached) { + test(`${getter.name}: a registry answer equal to the pin is not re-fetched on the next calls`, async () => { + getter.reset(); + mode = "pin"; + await outsideTestRunner(async () => { + assert.equal(await fetchesFrom(getter), 1, "the first call should look the version up"); + assert.equal( + await fetchesFrom(getter, 5), + 0, + `${getter.name} re-fetched although the registry already answered the pin` + ); + }); + getter.reset(); + }); + } + + test(`${getter.name}: a failed lookup waits for the retry window`, async () => { + getter.reset(); + mode = "fail"; + await outsideTestRunner(async () => { + assert.equal(await fetchesFrom(getter), 1, "the first call should look the version up"); + assert.equal( + await fetchesFrom(getter, 5), + 0, + `${getter.name} retried a failed lookup immediately` + ); + const later = await withClockAdvancedBy(BACKGROUND_REFRESH_RETRY_AFTER_FAILURE_MS + 1, () => + fetchesFrom(getter) + ); + assert.equal(later, 1, `${getter.name} should retry once the window has passed`); + }); + mode = "pin"; + getter.reset(); + }); +}