GitNexus/gitnexus/test/unit/hf-env.test.ts
Gergő Magyar 152a0506c9
feat: shared resilient-fetch (retries + circuit breaker) (#1448)
* feat: shared resilient-fetch (retries + circuit breaker)

Add a small, runtime-agnostic resilience layer in gitnexus-shared and
migrate every backend HTTP outbound call (CLI, MCP, wiki LLM, web → backend)
through it.

Helpers (gitnexus-shared/src/integrations/):

- retry.ts            — withRetry(fn, opts) with caller-supplied
                        retryability classification and full-jitter
                        exponential backoff.
- circuit-breaker.ts  — closed/open/half-open per-process breaker with
                        injectable clock, plus a keyed registry so
                        callers targeting the same endpoint share state.
- resilient-fetch.ts  — composed wrapper: retries 5xx + 429 + retryable
                        network throws, treats AbortSignal.timeout()
                        and 4xx (other than 429) as terminal, honors
                        Retry-After (capped at 30s), throws
                        CircuitOpenError when the breaker opens.

Migrations (no behaviour regression — all existing tests pass):

- gitnexus/src/core/embeddings/http-client.ts (covers analyze + MCP
  query path) — replaces inline linear-backoff retry.
- gitnexus/src/core/wiki/llm-client.ts — preserves Azure content-filter
  branch; resilientFetch handles 5xx/429.
- gitnexus-web/src/services/backend-client.ts (fetchWithTimeout helper)
  — small retry budget (2 attempts, 250–1500 ms) so a dead local
  backend still fails fast for the user.
- gitnexus-web/src/core/llm/settings-service.ts (OpenRouter model list).

Deliberately not migrated:

- gitnexus-web/src/services/backend-client.ts streamJob() — Server-Sent
  Events stream; the existing reconnect-with-Last-Event-ID logic is
  not unary-fetch shaped.
- gitnexus-web/src/components/SettingsPanel.tsx checkOllamaStatus() —
  one-shot health probe; retrying delays the "Ollama not running"
  error rather than improving UX.

41 new helper tests cover backoff math, breaker state transitions,
Retry-After parsing (delta-seconds + HTTP-date), 401/422 terminal
classification, and breaker fail-fast on three exhausted retry batches.

* fix(review): apply autofix feedback

Address Claude's two MEDIUM blocking findings on PR #1448 plus the
CodeQL SSRF false-positive flag.

- backend-client `fetchWithTimeout` now uses `AbortSignal.timeout()`
  merged with the caller's signal via `AbortSignal.any()`. Timer-fired
  aborts surface as `DOMException(name='TimeoutError')` so
  resilientFetch routes them through the terminal-network branch
  (no retry, no breaker hit), instead of incrementing the breaker
  for user-side network slowness.
- Method-aware retry budget in `fetchWithTimeout`: idempotent verbs
  (GET/HEAD/OPTIONS) keep the 2-attempt budget; POST/PATCH/PUT/DELETE
  default to single-attempt so a 5xx on `startAnalyze` cannot start
  a duplicate job. New `forceRetry` parameter for callers that
  know-idempotent mutations (e.g. DELETE of a known-deleted resource).
- `resilient-fetch.ts` carries a documented suppression for CodeQL
  js/server-side-request-forgery on the inner fetch call. Every
  concrete caller passes a hardcoded URL constant or a value from
  configuration (env vars, saved settings); user request input never
  flows into the URL parameter.
- New test file `backend-client-retry.test.ts` covers all three
  paths: GET retries on 503, POST does not retry, timeout does not
  increment the breaker.

* fix(resilient-fetch): address Codex adversarial findings

Closes the three blocking issues from Codex's review on PR #1448.

U1 — Add `recordNeutral()` to CircuitBreaker.
  Third outcome path that's an explicit no-op for state and the
  consecutive-failure counter. Distinct from `recordSuccess` (closes
  the breaker) and `recordFailure` (may open it). Used for outcomes
  that are neither evidence of backend health nor evidence of
  backend failure.

U2 — Route terminal-client / terminal-network through `recordNeutral`.
  Previously a 401 or local timeout called `recordSuccess`, which
  reset `consecutiveFailures` to 0. A 5xx → 401 → 5xx → 401 → 5xx
  sequence would NEVER trip the breaker because each 4xx in between
  erased the running count. Also classify external `AbortError` as
  terminal-network (was retryable-network), so caller-driven
  cancellation no longer retries against an already-aborted signal
  or counts toward breaker failures on exhaustion.

U3 — Per-origin breaker key in web `fetchWithTimeout`.
  Was hardcoded to `'web-backend'` even though `_backendUrl` is
  mutable via `setBackendUrl`. Switching backend URLs after a
  circuit tripped on host-A would strand the user during the full
  cooldown. Key is now `web-backend:<origin>`, so each backend URL
  gets its own breaker state.

Tests: +5 recordNeutral, +4 resilient-fetch (interleaved 4xx/5xx,
external AbortError, prior-state preservation), +1 web switch-backend
regression. All 70 gitnexus integration tests + 15 web tests green.

* fix(resilient-fetch): tolerate header-less fetch mocks on 429

`classifyOutcome` called `resp.headers.get('Retry-After')` directly,
which crashed when a test stubs `fetch` with a plain object like
`{ ok: false, status: 429 }` (no `headers` field). Real `Response`
always has Headers, so this surfaces only in test setups, but the
helper has no business assuming caller-side correctness on this — the
defensive guard is cheap and a missing `Retry-After` falls through to
exponential-backoff retry like any 429 without the header.

Surfaced by `gitnexus/test/unit/http-embedder.test.ts > retries on
rate limit`, which the embeddings migration exercises against a
plain-object 429 stub. Locked in with a new
`classifies 429 from a header-less fetch mock without throwing` case.

* fix(review): apply autofix feedback

Closes findings from the third multi-agent review pass on PR #1448.

#1 (P1) callLLM had no per-attempt timeout
  Wiki LLM calls passed no `signal` to resilientFetch; each of three
  retry attempts could hang indefinitely on a frozen TCP connection.
  Add `signal: AbortSignal.timeout(60_000)` so the per-attempt budget
  matches what http-client.ts and backend-client.ts already provide.

#2 (P2) drop dead `lastRetryableResp` post-loop fallback
  Variable was set in one switch arm but only read in unreachable code
  after the loop. The retry loop always returns/throws on every
  iteration. Keep only the defensive `throw` so TypeScript's
  control-flow analysis still sees `Promise<Response>` as the return.

#5 (P2) gate test-only exports behind a subpath
  `__resetBreakerRegistry__` and `classifyOutcome` were reachable from
  the main `gitnexus-shared` barrel — production code calling
  `__resetBreakerRegistry__` from a tool implementation would silently
  nuke every circuit breaker process-wide. Move to a new
  `gitnexus-shared/test-helpers` subpath export. Production callers
  see the cleaner public API; tests import via the explicit
  `gitnexus-shared/test-helpers` path.

#6 (P2) exhaustiveness guard on Outcome switch
  Add a `default: const _: never = outcome` arm so a future sixth
  `Outcome.kind` won't compile silently — it'll surface at the switch
  site rather than fall through to a retry/no-retry default.

#9 (P3) document cumulative wall-clock budget
  Add a "Cumulative wall-clock budget" paragraph to resilientFetch's
  JSDoc explaining the worst-case total wait (`maxAttempts × (per-attempt
  timeout + capDelayMs)` ≈ 60s with defaults) and pointing callers at
  outer `AbortSignal.timeout()` when they want a tighter bound.

Deferred to follow-up PRs (per review's Auto-resolve recommendation):
  - #3 idempotency knob to shared API (forceRetry into ResilientFetchOptions)
  - #4 publish.ts migration to resilientFetch
  - #7 parseRetryAfter past-HTTP-date / negative-seconds asymmetry
  - #8 recordNeutral counter time-decay (documented breaker semantic)

* fix(circuit-breaker): gate half-open to a single in-flight probe

Closes the Codex adversarial-review finding on PR #1448 that flagged a
recovery-time thundering herd: when cooldown expired, every concurrent
caller transitioned the breaker to half-open and probed the still-
recovering dependency in lockstep, defeating the breaker's "fail fast"
promise.

U1 — probe-permit gate in CircuitBreaker.check()
  Added a `probeInFlight: boolean` field. After cooldown expires, the
  first `check()` admits the probe and consumes the permit; subsequent
  callers throw `CircuitOpenError` with a configurable
  `halfOpenRetryAfterMs` (default 1000ms) until the probe resolves.

  Critical design point: `recordNeutral` now RELEASES the permit but
  does NOT transition state. Without that split, a single `TimeoutError`
  from per-attempt `AbortSignal.timeout` (which routes through neutral
  classification) would permanently park the breaker in half-open. By
  separating permit-release from state-resolution, we keep the
  "neutral doesn't claim health" semantic without creating that wedge.

  Other changes:
  - `halfOpenRetryAfterMs` is now a constructor option for consumers
    with long-running protected ops (LLM streaming, large uploads).
  - `getState()` is documented as a pure read; the implicit
    Open -> Half-Open transition lives in `check()` only, so tests
    that inspect state never inadvertently consume a probe permit.
  - `isProbeInFlight()` test-only accessor for assertion clarity.
  - JSDoc on `check()` records the JS event-loop atomicity dependency
    and the load-bearing `try/finally` pairing invariant.

U2 — End-to-end concurrency regression through resilientFetch
  Three new scenarios in resilient-fetch.test.ts (26 -> 29):
  - 3 concurrent calls + probe gets 200 -> 1 hits fetch, 2 throw
    CircuitOpenError, breaker closes.
  - 3 concurrent calls + probe gets 503 -> ResilientFetchExhaustedError
    on probe; concurrent callers see halfOpenRetryAfterMs (1000ms);
    fresh caller after probe resolves sees the FULL new cooldown
    (10000ms), not the probe-in-flight default.
  - Probe cancelled mid-flight via AbortError -> permit released,
    state stays half-open, next caller becomes the new probe and
    succeeds.

Plus 9 new circuit-breaker unit tests (16 -> 25) covering the permit
gate, recordNeutral-releases-permit semantic, fresh-cooldown distinction,
default vs configurable halfOpenRetryAfterMs, getState() purity, and
the three-probes-via-neutrals chain.

Total integration test count: 70 -> 82. All 106 gitnexus + 15 web
tests pass; both packages typecheck.

Maintainer decisions (deferred per plan 003 Open Questions):
  - Plan 002's deferral judgement was reversed on Codex's argument
    without new measurement / incident data. The reversal is defensible
    on principle (Hystrix / Resilience4j alignment) but lacks workload-
    driven evidence.
  - Probe-blocked callers throw silently (no log / event hook). R4's
    "no new public API" prevents adding observability; loosen if a
    debug log on probe-blocked is wanted.

* refactor(embeddings): replace bespoke HF breaker with shared CircuitBreaker

Deleted the local `HfDownloadCircuitBreaker` class and the manual
retry loop in `withHfDownloadRetry`. Both are now backed by the
shared `gitnexus-shared` primitives:

- `hfDownloadCircuit` is `new CircuitBreaker({ failureThreshold,
  cooldownMs, key: 'hf-download' })` — same state machine as before
  PLUS the single-permit half-open gate that prevents recovery-time
  stampedes when CLI + MCP embedders concurrently re-load the model.
- `withHfDownloadRetry` delegates the loop to `withRetry` from the
  shared package. Per-attempt timeout (`withDownloadTimeout`),
  network-vs-non-network classification, circuit recording, and the
  `onRetry` callback wire through `withRetry`'s `isRetryable`
  callback.

Behaviour preserved:
- Pre-flight `CIRCUIT_OPEN_TAG` rejection when the breaker is open.
- Mid-loop `CIRCUIT_OPEN_TAG` "opened after N consecutive failures"
  when a network error trips the threshold.
- Non-network errors (e.g. CUDA unavailable) bypass retry and go
  through `recordNeutral` instead of resetting the breaker's
  failure-count progress.
- `onRetry(attempt+1, max, err)` fires only when there's a next
  attempt, matching the prior semantic.

Generic CircuitBreaker gained two inspection accessors:
- `getOpenedAt(): number | null`
- `getCooldownMs(): number`
Used by `withHfDownloadRetry` to compute `secsUntilReset` without
consuming a probe permit (which `check()` would do).

Test consolidation: the 7 bespoke `HfDownloadCircuitBreaker`
state-machine tests in hf-env.test.ts were 1:1 duplicates of
existing tests in `circuit-breaker.test.ts` and were deleted.
Remaining 42 hf-env tests all pass; full integration sweep (148
gitnexus + 15 web) green.
2026-05-09 15:18:09 +01:00

421 lines
16 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import os from 'node:os';
import { join } from 'node:path';
import { CircuitBreaker } from 'gitnexus-shared';
import {
applyHfEnvOverrides,
isNetworkFetchError,
isHfDownloadFailure,
isHfCircuitOpenError,
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);
});
});
// CircuitBreaker state-machine tests live in
// `gitnexus/test/unit/integrations/circuit-breaker.test.ts` — that suite
// already covers the closed/open/half-open transitions, recordSuccess/
// recordFailure semantics, half-open probe gating, and configurable
// thresholds. No need to duplicate here; this file's remaining tests
// focus on HF-specific composition (withHfDownloadRetry, env-var
// overrides, error classification).
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 CircuitBreaker();
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 CircuitBreaker();
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 CircuitBreaker({ failureThreshold: 99 });
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 CircuitBreaker();
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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 2, cooldownMs: 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.getState()).toBe('open');
});
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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 5 });
cb.recordFailure();
cb.recordFailure(); // 2 failures, circuit still closed
await withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 });
expect(cb.getState()).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 CircuitBreaker({ failureThreshold: 99_999 });
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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 99_999 });
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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 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 CircuitBreaker({ failureThreshold: 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);
});
});