From 955dd7d8fc72c7ae1268abfb9ee5449db66a6df6 Mon Sep 17 00:00:00 2001 From: Nadia Nujovich Date: Fri, 17 Jul 2026 09:40:22 +0200 Subject: [PATCH] fix: retry LLM calls and force IPv4 DNS on WSL2 (#19) On WSL2, NAT routing can resolve remote LLM API hosts to an unreachable IPv6 address, making fetch() hang until ETIMEDOUT. Add lib/net-utils.mjs with a guarded ipv4first workaround (applied at CLI startup) and a shared fetchWithRetry helper that retries only transient network errors. Route the anthropic, ollama and openrouter clients through it. --- bin/mint-ds.mjs | 3 + lib/__tests__/net-utils.test.mjs | 283 +++++++++++++++++++ lib/llm_providers/anthropic/client-impl.mjs | 3 +- lib/llm_providers/ollama/client-impl.mjs | 3 +- lib/llm_providers/openrouter/client-impl.mjs | 3 +- lib/net-utils.mjs | 102 +++++++ package.json | 1 + 7 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/net-utils.test.mjs create mode 100644 lib/net-utils.mjs diff --git a/bin/mint-ds.mjs b/bin/mint-ds.mjs index a35b05b..3b20263 100755 --- a/bin/mint-ds.mjs +++ b/bin/mint-ds.mjs @@ -23,6 +23,7 @@ import { convertTokensToDTCG, serializeDTCG } from '../lib/dtcg-exporter.mjs' import { formatLintSummary } from '../lib/audit-summary.mjs' import { checkCompat } from '../lib/css-compat-data.mjs' import { lintCss, lintGapDecorationAdoption } from '../lib/css-lint-rules.mjs' +import { applyWsl2DnsWorkaround } from '../lib/net-utils.mjs' import { DEFAULT_TOKENS_FILE, loadConfig, @@ -669,6 +670,8 @@ async function cmdDiff(argv) { } async function main() { + // Mitigate WSL2 IPv6 fetch hangs to remote LLM APIs (issue #19). + applyWsl2DnsWorkaround() const argv = process.argv.slice(2) if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') { printHelp() diff --git a/lib/__tests__/net-utils.test.mjs b/lib/__tests__/net-utils.test.mjs new file mode 100644 index 0000000..f1d6989 --- /dev/null +++ b/lib/__tests__/net-utils.test.mjs @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { setDefaultResultOrder } from 'node:dns' +import { + hasDnsResultOrderOverride, + isWSL2, + isRetryableNetworkError, + fetchWithRetry, + applyWsl2DnsWorkaround, +} from '../net-utils.mjs' + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), +})) + +vi.mock('node:dns', () => ({ + setDefaultResultOrder: vi.fn(), +})) + +describe('hasDnsResultOrderOverride', () => { + let originalExecArgv + + beforeEach(() => { + originalExecArgv = process.execArgv + process.execArgv = [] + vi.stubEnv('NODE_OPTIONS', '') + }) + + afterEach(() => { + process.execArgv = originalExecArgv + vi.unstubAllEnvs() + }) + + it('returns true when NODE_OPTIONS includes --dns-result-order', () => { + vi.stubEnv('NODE_OPTIONS', '--dns-result-order=verbatim') + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns true when execArgv contains --dns-result-order', () => { + process.execArgv = ['--dns-result-order'] + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns true when execArgv contains --dns-result-order=value', () => { + process.execArgv = ['--dns-result-order=verbatim'] + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns false when neither NODE_OPTIONS nor execArgv signal an override', () => { + expect(hasDnsResultOrderOverride()).toBe(false) + }) +}) + +describe('isWSL2', () => { + afterEach(() => { + vi.mocked(readFileSync).mockReset() + }) + + it('returns true when /proc/sys/kernel/osrelease contains wsl2', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) + return '5.15.153.1-microsoft-standard-WSL2' + throw new Error('ENOENT') + }) + expect(isWSL2()).toBe(true) + }) + + it('returns true when /proc/version matches microsoft.*wsl2 pattern', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux version 5.15.0-microsoft-standard-WSL2 (gcc)' + }) + expect(isWSL2()).toBe(true) + }) + + it('returns true when /proc/version matches wsl2.*microsoft pattern', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux WSL2-microsoft kernel' + }) + expect(isWSL2()).toBe(true) + }) + + it('returns false when /proc/version contains microsoft but not wsl2', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux microsoft version' + }) + expect(isWSL2()).toBe(false) + }) + + it('returns false when /proc/version has no microsoft or wsl2', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux 5.15.0 Ubuntu' + }) + expect(isWSL2()).toBe(false) + }) + + it('returns false when both reads throw', () => { + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('ENOENT') + }) + expect(isWSL2()).toBe(false) + }) +}) + +describe('isRetryableNetworkError', () => { + it.each([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'EAI_AGAIN', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + ])('returns true for top-level code %s', (code) => { + expect( + isRetryableNetworkError(Object.assign(new Error('x'), { code })) + ).toBe(true) + }) + + it('returns true when the code is on err.cause', () => { + const err = new Error('fetch failed') + err.cause = Object.assign(new Error('inner'), { code: 'ECONNRESET' }) + expect(isRetryableNetworkError(err)).toBe(true) + }) + + it('returns true when a retryable code is nested in cause.errors (undici aggregate)', () => { + const err = new Error('fetch failed') + err.cause = { errors: [{ code: 'EAI_AGAIN' }, { code: 'ETIMEDOUT' }] } + expect(isRetryableNetworkError(err)).toBe(true) + }) + + it('returns false for a plain error with no network code', () => { + expect( + isRetryableNetworkError(new Error('Anthropic API error (500)')) + ).toBe(false) + }) + + it('returns false for a non-retryable code', () => { + expect( + isRetryableNetworkError( + Object.assign(new Error('x'), { code: 'ENOTFOUND' }) + ) + ).toBe(false) + }) + + it('returns false for null/undefined', () => { + expect(isRetryableNetworkError(null)).toBe(false) + expect(isRetryableNetworkError(undefined)).toBe(false) + }) +}) + +describe('fetchWithRetry', () => { + const noSleep = () => Promise.resolve() + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns the response on the first successful attempt', async () => { + const response = { ok: true, status: 200 } + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response) + + const result = await fetchWithRetry( + 'https://x', + { method: 'POST' }, + { sleep: noSleep } + ) + + expect(result).toBe(response) + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith('https://x', { method: 'POST' }) + }) + + it('retries transient network errors then succeeds', async () => { + const response = { ok: true, status: 200 } + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValueOnce( + Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) + ) + .mockRejectedValueOnce( + Object.assign(new Error('boom'), { code: 'ECONNRESET' }) + ) + .mockResolvedValue(response) + + const result = await fetchWithRetry( + 'https://x', + {}, + { backoffsMs: [0, 0], sleep: noSleep } + ) + + expect(result).toBe(response) + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) + + it('does not retry a non-retryable error', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue( + Object.assign(new Error('nope'), { code: 'ENOTFOUND' }) + ) + + await expect( + fetchWithRetry('https://x', {}, { backoffsMs: [0, 0], sleep: noSleep }) + ).rejects.toThrow('nope') + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it('gives up and rethrows after exhausting retries', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue( + Object.assign(new Error('still down'), { code: 'ETIMEDOUT' }) + ) + + await expect( + fetchWithRetry('https://x', {}, { backoffsMs: [0, 0], sleep: noSleep }) + ).rejects.toThrow('still down') + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) + + it('invokes onRetry for each retry with attempt index and delay', async () => { + vi.spyOn(globalThis, 'fetch') + .mockRejectedValueOnce( + Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) + ) + .mockResolvedValue({ ok: true }) + const onRetry = vi.fn() + + await fetchWithRetry( + 'https://x', + {}, + { backoffsMs: [7, 9], sleep: noSleep, onRetry } + ) + + expect(onRetry).toHaveBeenCalledTimes(1) + expect(onRetry).toHaveBeenCalledWith(0, expect.any(Error), 7) + }) +}) + +describe('applyWsl2DnsWorkaround', () => { + beforeEach(() => { + process.execArgv = [] + vi.stubEnv('NODE_OPTIONS', '') + }) + + afterEach(() => { + vi.mocked(readFileSync).mockReset() + vi.mocked(setDefaultResultOrder).mockReset() + vi.unstubAllEnvs() + }) + + it('forces ipv4first when on WSL2 without an override', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) + return '5.15-microsoft-standard-WSL2' + throw new Error('ENOENT') + }) + + expect(applyWsl2DnsWorkaround()).toBe(true) + expect(setDefaultResultOrder).toHaveBeenCalledWith('ipv4first') + }) + + it('does nothing when not on WSL2', () => { + vi.mocked(readFileSync).mockImplementation(() => 'Linux Ubuntu') + + expect(applyWsl2DnsWorkaround()).toBe(false) + expect(setDefaultResultOrder).not.toHaveBeenCalled() + }) + + it('does not override an explicit user --dns-result-order', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) return 'microsoft-standard-WSL2' + throw new Error('ENOENT') + }) + vi.stubEnv('NODE_OPTIONS', '--dns-result-order=verbatim') + + expect(applyWsl2DnsWorkaround()).toBe(false) + expect(setDefaultResultOrder).not.toHaveBeenCalled() + }) +}) diff --git a/lib/llm_providers/anthropic/client-impl.mjs b/lib/llm_providers/anthropic/client-impl.mjs index c28a401..d3dd34c 100644 --- a/lib/llm_providers/anthropic/client-impl.mjs +++ b/lib/llm_providers/anthropic/client-impl.mjs @@ -1,4 +1,5 @@ import { LlmClient } from '../base.mjs' +import { fetchWithRetry } from '../../net-utils.mjs' export class AnthropicLlmClient extends LlmClient { constructor({ apiKey, modelName, url, version }) { @@ -23,7 +24,7 @@ export class AnthropicLlmClient extends LlmClient { } if (system) body.system = system - const res = await fetch(this.url, { + const res = await fetchWithRetry(this.url, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/lib/llm_providers/ollama/client-impl.mjs b/lib/llm_providers/ollama/client-impl.mjs index 82fe7b0..f95a431 100644 --- a/lib/llm_providers/ollama/client-impl.mjs +++ b/lib/llm_providers/ollama/client-impl.mjs @@ -1,4 +1,5 @@ import { LlmClient } from '../base.mjs' +import { fetchWithRetry } from '../../net-utils.mjs' export class OllamaLlmClient extends LlmClient { constructor({ apiKey, modelName, url }) { @@ -28,7 +29,7 @@ export class OllamaLlmClient extends LlmClient { const headers = { 'Content-Type': 'application/json' } if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}` - const res = await fetch(this.url, { + const res = await fetchWithRetry(this.url, { method: 'POST', headers, body: JSON.stringify(body), diff --git a/lib/llm_providers/openrouter/client-impl.mjs b/lib/llm_providers/openrouter/client-impl.mjs index 6e157a5..5c4cd7c 100644 --- a/lib/llm_providers/openrouter/client-impl.mjs +++ b/lib/llm_providers/openrouter/client-impl.mjs @@ -1,4 +1,5 @@ import { LlmClient } from '../base.mjs' +import { fetchWithRetry } from '../../net-utils.mjs' export class OpenRouterLlmClient extends LlmClient { constructor({ apiKey, modelName, url }) { @@ -26,7 +27,7 @@ export class OpenRouterLlmClient extends LlmClient { messages, } - const res = await fetch(this.url, { + const res = await fetchWithRetry(this.url, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/lib/net-utils.mjs b/lib/net-utils.mjs new file mode 100644 index 0000000..01690b0 --- /dev/null +++ b/lib/net-utils.mjs @@ -0,0 +1,102 @@ +// Network/environment utilities shared by the LLM provider clients. +// Kept dependency-free so it runs in plain Node without a build step. +// +// Background (issue #19): on WSL2, NAT routing can resolve a host to an IPv6 +// address that isn't reachable, making fetch() hang until ETIMEDOUT. We force +// IPv4-first DNS resolution only on WSL2, and retry transient network failures. + +import { readFileSync } from 'node:fs' +import { setDefaultResultOrder } from 'node:dns' + +// Network error codes that benefit from a retry under WSL2's flaky NAT. +// HTTP errors (4xx/5xx) are NOT retried — callers surface those as-is. +const RETRYABLE_NET_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'EAI_AGAIN', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', +]) + +const DEFAULT_BACKOFFS_MS = [500, 1500] + +function defaultSleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function hasDnsResultOrderOverride() { + if (process.env.NODE_OPTIONS?.includes('--dns-result-order')) return true + + for (let i = 0; i < process.execArgv.length; i++) { + const arg = process.execArgv[i] + if (arg === '--dns-result-order') return true + if (arg?.startsWith('--dns-result-order=')) return true + } + + return false +} + +// Returns true only when running inside WSL2 (Windows Subsystem for Linux v2). +// Generic WSL env vars such as WSL_DISTRO_NAME / WSL_INTEROP are not enough to +// distinguish WSL1 from WSL2, so check kernel metadata for a WSL2-specific +// marker instead. +export function isWSL2() { + try { + if (/wsl2/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'))) + return true + } catch {} + + try { + return /microsoft.*wsl2|wsl2.*microsoft/i.test( + readFileSync('/proc/version', 'utf8') + ) + } catch { + return false + } +} + +// Force IPv4-first DNS only on WSL2 so that other hosts keep their default +// (verbatim) ordering. Honors any user-provided --dns-result-order override. +// Returns true when the workaround was applied. Safe to call once at startup. +export function applyWsl2DnsWorkaround() { + if (isWSL2() && !hasDnsResultOrderOverride()) { + setDefaultResultOrder('ipv4first') + return true + } + return false +} + +export function isRetryableNetworkError(err) { + if (!err) return false + const codes = [] + if (err.code) codes.push(err.code) + if (err.cause?.code) codes.push(err.cause.code) + for (const inner of err.cause?.errors || []) { + if (inner?.code) codes.push(inner.code) + } + return codes.some((c) => RETRYABLE_NET_CODES.has(c)) +} + +// fetch() wrapper that retries only transient network failures (never HTTP +// responses). Returns the Response so callers keep their own status handling. +export async function fetchWithRetry(url, init, options = {}) { + const { + backoffsMs = DEFAULT_BACKOFFS_MS, + sleep = defaultSleep, + onRetry, + } = options + + for (let attempt = 0; attempt <= backoffsMs.length; attempt++) { + try { + return await fetch(url, init) + } catch (err) { + if (attempt < backoffsMs.length && isRetryableNetworkError(err)) { + if (onRetry) onRetry(attempt, err, backoffsMs[attempt]) + await sleep(backoffsMs[attempt]) + continue + } + throw err + } + } +} diff --git a/package.json b/package.json index c2bfdfd..951daff 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "lib/css-lint-rules.mjs", "lib/token-diff.mjs", "lib/mint-config.mjs", + "lib/net-utils.mjs", "lib/llm_providers/**/*.mjs", "README.md" ],