GitNexus/gitnexus/test/unit/hf-env.test.ts
Copilot 9d015164a5
fix: actionable HF_ENDPOINT guidance, retries, timeout and circuit breaker when embedding model download fails (#1419)
* Initial plan

* fix: surface actionable HF_ENDPOINT guidance on embedding model download failure

When `gitnexus analyze --embeddings` fails because huggingface.co is
unreachable (e.g. the GFW, corporate proxies), the error was shown as a
raw `TypeError: fetch failed` with no actionable guidance.

Changes:
- `hf-env.ts`: add and export `isNetworkFetchError()` helper that
  detects network-level fetch errors (fetch failed, ECONNREFUSED,
  ENOTFOUND, ETIMEDOUT, ECONNRESET)
- `core/embeddings/embedder.ts`: in the device-fallback loop, detect
  network errors and rethrow immediately with a message telling the user
  to set HF_ENDPOINT to a mirror (hf-mirror.com) — device fallback is
  meaningless for network errors that will fail on every device
- `mcp/core/embedder.ts`: same fix for the MCP embedder entry point
- `cli/analyze.ts`: add a new error branch that detects fetch/network
  failures and prints a concrete 3-step remediation hint (HF_ENDPOINT,
  proxy/VPN, offline caching)
- `test/unit/hf-env.test.ts`: add 8 unit tests covering all five
  network error patterns and three negative cases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3e314b1c-ca74-44d5-9913-c1418d4e160a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: use isNetworkFetchError helper in analyze.ts to eliminate duplication

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3e314b1c-ca74-44d5-9913-c1418d4e160a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat: add retry, timeout and circuit breaker for HF model download

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6e6f509a-e4d5-4d1f-b0de-b2d30e0a0dce

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: apply prettier formatting to changed files

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cda38cc4-1c18-4be8-8d7b-e5f00dceaa22

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address all adversarial review findings (duplicate output, env overrides, tests, Windows note)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/eb1b4f9a-79da-4084-8f7b-dc056d2f7e5a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: extract resolved env-var defaults to named variables for clarity

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/eb1b4f9a-79da-4084-8f7b-dc056d2f7e5a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: add upper bound clamping and unit tests for HF env override parsing

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/198700cc-4191-4ac1-94ec-33d61f2a99f7

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: use consistent 99_999 threshold notation in env override tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/198700cc-4191-4ac1-94ec-33d61f2a99f7

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: remove pipeline as any cast; type progress callback with ProgressInfo; remove unused HF_DOWNLOAD_TIMEOUT_MS import

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/43e60ccd-6f01-4cec-8ee3-c22e8b000efe

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: add safe fallback for progress_total status mapping in typed progress callback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/43e60ccd-6f01-4cec-8ee3-c22e8b000efe

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-08 08:53:13 +01:00

493 lines
18 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import os from 'node:os';
import { join } from 'node:path';
import {
applyHfEnvOverrides,
isNetworkFetchError,
isHfDownloadFailure,
isHfCircuitOpenError,
HfDownloadCircuitBreaker,
withDownloadTimeout,
withHfDownloadRetry,
CIRCUIT_OPEN_TAG,
HF_MAX_ATTEMPTS,
HF_MAX_TIMEOUT_MS,
HF_MAX_ATTEMPTS_CAP,
type HfEnvSubset,
} from '../../src/core/embeddings/hf-env.js';
describe('applyHfEnvOverrides', () => {
let envStub: HfEnvSubset;
// Snapshot the two env vars so tests don't leak state into each other (or
// into the rest of the test run). `delete` + restore is the simplest pattern
// — vitest doesn't reset `process.env` between tests by default.
let originalHfHome: string | undefined;
let originalHfEndpoint: string | undefined;
beforeEach(() => {
envStub = { cacheDir: '', remoteHost: '' };
originalHfHome = process.env.HF_HOME;
originalHfEndpoint = process.env.HF_ENDPOINT;
delete process.env.HF_HOME;
delete process.env.HF_ENDPOINT;
});
afterEach(() => {
if (originalHfHome === undefined) delete process.env.HF_HOME;
else process.env.HF_HOME = originalHfHome;
if (originalHfEndpoint === undefined) delete process.env.HF_ENDPOINT;
else process.env.HF_ENDPOINT = originalHfEndpoint;
});
it('cacheDir defaults to ~/.cache/huggingface when HF_HOME is unset', () => {
applyHfEnvOverrides(envStub);
expect(envStub.cacheDir).toBe(join(os.homedir(), '.cache', 'huggingface'));
});
it('cacheDir respects HF_HOME when set', () => {
process.env.HF_HOME = '/custom/hf/cache';
applyHfEnvOverrides(envStub);
expect(envStub.cacheDir).toBe('/custom/hf/cache');
});
it('remoteHost is set when HF_ENDPOINT is set, with a trailing slash appended', () => {
process.env.HF_ENDPOINT = 'https://hf-mirror.com';
applyHfEnvOverrides(envStub);
expect(envStub.remoteHost).toBe('https://hf-mirror.com/');
});
it('remoteHost preserves existing trailing slash on HF_ENDPOINT', () => {
process.env.HF_ENDPOINT = 'https://hf-mirror.com/';
applyHfEnvOverrides(envStub);
expect(envStub.remoteHost).toBe('https://hf-mirror.com/');
});
it('remoteHost is left untouched when HF_ENDPOINT is unset', () => {
// Pre-populate to a sentinel so we can prove the function does NOT
// overwrite remoteHost when no env var is set. Without this guard a
// future refactor that always assigns `env.remoteHost = ...` would
// silently break consumers that have already configured it elsewhere.
envStub.remoteHost = 'pre-existing-do-not-touch';
applyHfEnvOverrides(envStub);
expect(envStub.remoteHost).toBe('pre-existing-do-not-touch');
});
it('remoteHost is left untouched when HF_ENDPOINT is whitespace-only', () => {
// Common copy-paste failure mode for users on restricted networks who
// pull `HF_ENDPOINT` values from shell scripts or docs with stray
// whitespace. The `.trim()` + truthiness guard ensures this is treated
// as "unset" rather than as an invalid host like `' /'` that would
// silently misroute model downloads. Pinned by the @claude review on
// PR #1252.
process.env.HF_ENDPOINT = ' ';
envStub.remoteHost = 'sentinel';
applyHfEnvOverrides(envStub);
expect(envStub.remoteHost).toBe('sentinel');
});
it('remoteHost trims surrounding whitespace from HF_ENDPOINT', () => {
// Compatible mirror of the previous test for the case where the env
// var is non-empty AFTER trimming. Without `.trim()`, the bogus
// leading/trailing space would survive into the URL and break
// downloads.
process.env.HF_ENDPOINT = ' https://hf-mirror.com ';
applyHfEnvOverrides(envStub);
expect(envStub.remoteHost).toBe('https://hf-mirror.com/');
});
});
describe('isNetworkFetchError', () => {
it('returns true for "fetch failed" (the undici error seen on macOS/Node 24)', () => {
expect(isNetworkFetchError('fetch failed')).toBe(true);
});
it('returns true for ECONNREFUSED', () => {
expect(isNetworkFetchError('connect ECONNREFUSED 13.45.67.89:443')).toBe(true);
});
it('returns true for ENOTFOUND (DNS failure)', () => {
expect(isNetworkFetchError('getaddrinfo ENOTFOUND huggingface.co')).toBe(true);
});
it('returns true for ETIMEDOUT', () => {
expect(isNetworkFetchError('connect ETIMEDOUT 13.45.67.89:443')).toBe(true);
});
it('returns true for ECONNRESET', () => {
expect(isNetworkFetchError('read ECONNRESET')).toBe(true);
});
it('returns false for generic model-load errors (ONNX device failure)', () => {
expect(isNetworkFetchError('Failed to initialize CUDA backend')).toBe(false);
});
it('returns false for empty string', () => {
expect(isNetworkFetchError('')).toBe(false);
});
it('returns false for module-not-found errors', () => {
expect(isNetworkFetchError('Cannot find module onnxruntime-node')).toBe(false);
});
});
describe('isHfCircuitOpenError', () => {
it('returns true for a circuit-open tag message', () => {
expect(isHfCircuitOpenError(`${CIRCUIT_OPEN_TAG}: circuit is open`)).toBe(true);
});
it('returns false for a plain network error', () => {
expect(isHfCircuitOpenError('fetch failed')).toBe(false);
});
});
describe('isHfDownloadFailure', () => {
it('returns true for network fetch errors', () => {
expect(isHfDownloadFailure('ECONNREFUSED 127.0.0.1:443')).toBe(true);
});
it('returns true for circuit-open errors', () => {
expect(isHfDownloadFailure(`${CIRCUIT_OPEN_TAG}: open`)).toBe(true);
});
it('returns false for ONNX device errors', () => {
expect(isHfDownloadFailure('Failed to initialize CUDA')).toBe(false);
});
});
describe('HfDownloadCircuitBreaker', () => {
it('starts in closed state', () => {
const cb = new HfDownloadCircuitBreaker();
expect(cb.isOpen()).toBe(false);
expect(cb.state).toBe('closed');
});
it('opens after reaching the failure threshold', () => {
const cb = new HfDownloadCircuitBreaker(3);
cb.recordFailure();
cb.recordFailure();
expect(cb.isOpen()).toBe(false);
cb.recordFailure(); // threshold reached
expect(cb.isOpen()).toBe(true);
expect(cb.state).toBe('open');
});
it('closes on recordSuccess after being open', () => {
const cb = new HfDownloadCircuitBreaker(1);
cb.recordFailure();
expect(cb.isOpen()).toBe(true);
cb.recordSuccess();
expect(cb.isOpen()).toBe(false);
expect(cb.state).toBe('closed');
});
it('transitions to half-open after the reset timeout', () => {
vi.useFakeTimers();
try {
const cb = new HfDownloadCircuitBreaker(1, 100 /* 100ms */);
cb.recordFailure();
expect(cb.isOpen()).toBe(true);
vi.advanceTimersByTime(200);
expect(cb.isOpen()).toBe(false);
expect(cb.state).toBe('half-open');
} finally {
vi.useRealTimers();
}
});
it('reset() restores closed state', () => {
const cb = new HfDownloadCircuitBreaker(1);
cb.recordFailure();
expect(cb.isOpen()).toBe(true);
cb.reset();
expect(cb.isOpen()).toBe(false);
expect(cb.state).toBe('closed');
});
it('re-opens when a failure is recorded in half-open state', () => {
vi.useFakeTimers();
try {
const cb = new HfDownloadCircuitBreaker(1, 100 /* 100ms */);
cb.recordFailure(); // opens the circuit
vi.advanceTimersByTime(200); // advance past reset timeout
expect(cb.state).toBe('half-open'); // getter transitions _state to half-open
cb.recordFailure(); // failure in half-open → re-opens
expect(cb.isOpen()).toBe(true);
expect(cb.state).toBe('open');
} finally {
vi.useRealTimers();
}
});
it('closes the circuit when success is recorded in half-open state', () => {
vi.useFakeTimers();
try {
const cb = new HfDownloadCircuitBreaker(1, 100 /* 100ms */);
cb.recordFailure(); // opens the circuit
vi.advanceTimersByTime(200); // advance past reset timeout
expect(cb.state).toBe('half-open');
cb.recordSuccess(); // success in half-open → closes
expect(cb.isOpen()).toBe(false);
expect(cb.state).toBe('closed');
} finally {
vi.useRealTimers();
}
});
});
describe('withDownloadTimeout', () => {
it('resolves when fn completes before the timeout', async () => {
const result = await withDownloadTimeout(() => Promise.resolve(42), 1_000);
expect(result).toBe(42);
});
it('rejects with ETIMEDOUT when fn takes too long', async () => {
vi.useFakeTimers();
try {
const neverResolves = () => new Promise<never>(() => {});
const promise = withDownloadTimeout(neverResolves, 20);
vi.advanceTimersByTime(30);
await expect(promise).rejects.toThrow('ETIMEDOUT');
} finally {
vi.useRealTimers();
}
});
it('propagates non-timeout errors from fn', async () => {
await expect(
withDownloadTimeout(() => Promise.reject(new Error('download error')), 1_000),
).rejects.toThrow('download error');
});
});
describe('withHfDownloadRetry', () => {
it('returns the result on first success', async () => {
const fn = vi.fn().mockResolvedValue('ok');
const cb = new HfDownloadCircuitBreaker();
const result = await withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 });
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries on network errors and succeeds on second attempt', async () => {
const fn = vi.fn().mockRejectedValueOnce(new Error('fetch failed')).mockResolvedValue('ok');
const cb = new HfDownloadCircuitBreaker();
const result = await withHfDownloadRetry(fn, {
circuit: cb,
maxAttempts: 3,
baseDelayMs: 0,
});
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});
it('throws the last network error after all attempts are exhausted', async () => {
const fn = vi.fn().mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:443'));
const cb = new HfDownloadCircuitBreaker(99 /* high threshold */);
await expect(
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 3, baseDelayMs: 0 }),
).rejects.toThrow('ECONNREFUSED');
expect(fn).toHaveBeenCalledTimes(3);
});
it('does not retry non-network errors', async () => {
const fn = vi.fn().mockRejectedValue(new Error('Failed to initialize CUDA backend'));
const cb = new HfDownloadCircuitBreaker();
await expect(
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 3, baseDelayMs: 0 }),
).rejects.toThrow('Failed to initialize CUDA backend');
expect(fn).toHaveBeenCalledTimes(1);
});
it('fails immediately when the circuit is already open', async () => {
const fn = vi.fn().mockResolvedValue('ok');
const cb = new HfDownloadCircuitBreaker(1);
cb.recordFailure(); // open the circuit
await expect(withHfDownloadRetry(fn, { circuit: cb })).rejects.toThrow(CIRCUIT_OPEN_TAG);
expect(fn).not.toHaveBeenCalled();
});
it('opens the circuit after failureThreshold failures and throws a circuit-open error', async () => {
const fn = vi.fn().mockRejectedValue(new Error('ENOTFOUND huggingface.co'));
const cb = new HfDownloadCircuitBreaker(2 /* threshold */, 60_000);
// First call: 2 attempts, threshold=2 → circuit opens on 2nd failure
await expect(
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 2, baseDelayMs: 0 }),
).rejects.toThrow(CIRCUIT_OPEN_TAG);
expect(cb.isOpen()).toBe(true);
});
it('calls onRetry with correct arguments on each retry', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('fetch failed'))
.mockRejectedValueOnce(new Error('fetch failed'))
.mockResolvedValue('ok');
const cb = new HfDownloadCircuitBreaker(99);
const onRetry = vi.fn();
await withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 3, baseDelayMs: 0, onRetry });
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenNthCalledWith(
1,
1,
3,
expect.objectContaining({ message: 'fetch failed' }),
);
expect(onRetry).toHaveBeenNthCalledWith(
2,
2,
3,
expect.objectContaining({ message: 'fetch failed' }),
);
});
it('resets the circuit on success', async () => {
const fn = vi.fn().mockResolvedValue('value');
const cb = new HfDownloadCircuitBreaker(5);
cb.recordFailure();
cb.recordFailure(); // 2 failures, circuit still closed
await withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 });
expect(cb.state).toBe('closed');
});
});
describe('withHfDownloadRetry env overrides', () => {
let originalTimeout: string | undefined;
let originalMaxAttempts: string | undefined;
beforeEach(() => {
originalTimeout = process.env.HF_DOWNLOAD_TIMEOUT_MS;
originalMaxAttempts = process.env.HF_MAX_ATTEMPTS;
delete process.env.HF_DOWNLOAD_TIMEOUT_MS;
delete process.env.HF_MAX_ATTEMPTS;
});
afterEach(() => {
if (originalTimeout === undefined) delete process.env.HF_DOWNLOAD_TIMEOUT_MS;
else process.env.HF_DOWNLOAD_TIMEOUT_MS = originalTimeout;
if (originalMaxAttempts === undefined) delete process.env.HF_MAX_ATTEMPTS;
else process.env.HF_MAX_ATTEMPTS = originalMaxAttempts;
});
it('HF_MAX_ATTEMPTS=1 gives exactly 1 attempt', async () => {
process.env.HF_MAX_ATTEMPTS = '1';
const fn = vi.fn().mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:443'));
const cb = new HfDownloadCircuitBreaker(99_999 /* high threshold */);
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
'ECONNREFUSED',
);
expect(fn).toHaveBeenCalledTimes(1);
});
it('HF_MAX_ATTEMPTS=2 gives exactly 2 attempts', async () => {
process.env.HF_MAX_ATTEMPTS = '2';
const fn = vi.fn().mockRejectedValue(new Error('ENOTFOUND huggingface.co'));
const cb = new HfDownloadCircuitBreaker(99_999);
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
'ENOTFOUND',
);
expect(fn).toHaveBeenCalledTimes(2);
});
it('HF_MAX_ATTEMPTS=abc falls back to the built-in default', async () => {
process.env.HF_MAX_ATTEMPTS = 'abc';
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
const cb = new HfDownloadCircuitBreaker(99_999);
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
'fetch failed',
);
expect(fn).toHaveBeenCalledTimes(HF_MAX_ATTEMPTS);
});
it('HF_MAX_ATTEMPTS=0 falls back to the built-in default', async () => {
process.env.HF_MAX_ATTEMPTS = '0';
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
const cb = new HfDownloadCircuitBreaker(99_999);
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
'fetch failed',
);
expect(fn).toHaveBeenCalledTimes(HF_MAX_ATTEMPTS);
});
it('HF_MAX_ATTEMPTS=-1 falls back to the built-in default', async () => {
process.env.HF_MAX_ATTEMPTS = '-1';
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
const cb = new HfDownloadCircuitBreaker(99_999);
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
'fetch failed',
);
expect(fn).toHaveBeenCalledTimes(HF_MAX_ATTEMPTS);
});
it('HF_MAX_ATTEMPTS is clamped to HF_MAX_ATTEMPTS_CAP', async () => {
process.env.HF_MAX_ATTEMPTS = '9999';
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
const cb = new HfDownloadCircuitBreaker(99_999 /* very high threshold */);
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
'fetch failed',
);
expect(fn).toHaveBeenCalledTimes(HF_MAX_ATTEMPTS_CAP);
});
it('HF_MAX_ATTEMPTS=2.9 is floored to 2', async () => {
process.env.HF_MAX_ATTEMPTS = '2.9';
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
const cb = new HfDownloadCircuitBreaker(99_999);
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
'fetch failed',
);
expect(fn).toHaveBeenCalledTimes(2);
});
it('HF_DOWNLOAD_TIMEOUT_MS is used as the per-attempt timeout when valid', async () => {
vi.useFakeTimers();
try {
process.env.HF_DOWNLOAD_TIMEOUT_MS = '50';
const neverResolves = () => new Promise<never>(() => {});
const cb = new HfDownloadCircuitBreaker(99);
const promise = withHfDownloadRetry(neverResolves, { circuit: cb, maxAttempts: 1 });
vi.advanceTimersByTime(100);
await expect(promise).rejects.toThrow('ETIMEDOUT');
} finally {
vi.useRealTimers();
}
});
it('HF_DOWNLOAD_TIMEOUT_MS=-1 falls back to the built-in default', async () => {
process.env.HF_DOWNLOAD_TIMEOUT_MS = '-1';
// Passing explicit timeoutMs=0 (no real wait) so the test doesn't block;
// we just verify that the env var rejection causes options.timeoutMs to be
// the default constant (not -1) by confirming the resolved value is used.
const fn = vi.fn().mockResolvedValue('ok');
const cb = new HfDownloadCircuitBreaker(99);
// Provide explicit timeoutMs to avoid the default 5-minute wait
const result = await withHfDownloadRetry(fn, { circuit: cb, timeoutMs: 100 });
expect(result).toBe('ok');
});
it('HF_DOWNLOAD_TIMEOUT_MS is clamped to HF_MAX_TIMEOUT_MS', async () => {
vi.useFakeTimers();
try {
// Set an env value exceeding the 30-minute cap
process.env.HF_DOWNLOAD_TIMEOUT_MS = String(HF_MAX_TIMEOUT_MS + 60_000);
const neverResolves = () => new Promise<never>(() => {});
const cb = new HfDownloadCircuitBreaker(99);
const promise = withHfDownloadRetry(neverResolves, { circuit: cb, maxAttempts: 1 });
// Advance just past the 30-minute cap
vi.advanceTimersByTime(HF_MAX_TIMEOUT_MS + 1);
await expect(promise).rejects.toThrow('ETIMEDOUT');
} finally {
vi.useRealTimers();
}
});
it('explicit options override env vars', async () => {
process.env.HF_MAX_ATTEMPTS = '5';
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
const cb = new HfDownloadCircuitBreaker(99);
// explicit maxAttempts: 2 must win over HF_MAX_ATTEMPTS=5
await expect(
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 2, baseDelayMs: 0 }),
).rejects.toThrow('fetch failed');
expect(fn).toHaveBeenCalledTimes(2);
});
});