mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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.
421 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|