diff --git a/lib/__tests__/net-utils.test.mjs b/lib/__tests__/net-utils.test.mjs new file mode 100644 index 0000000..07f15b0 --- /dev/null +++ b/lib/__tests__/net-utils.test.mjs @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { hasDnsResultOrderOverride, isWSL2 } from '../net-utils.mjs' + +vi.mock('node:fs', () => ({ + readFileSync: 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) + }) +}) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index ae05284..02e759b 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import { stripFences, resolveTarget, @@ -6,6 +6,7 @@ import { buildAuditPrompt, buildResolvePrompt, buildExportPrompt, + callAnthropic, } from '../prompts.mjs' describe('stripFences', () => { @@ -203,3 +204,119 @@ describe('buildExportPrompt', () => { expect(result.length).toBeGreaterThan(0) }) }) + +describe('callAnthropic', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('throws when apiKey is missing', async () => { + await expect(callAnthropic({ prompt: 'hello' })).rejects.toThrow( + 'ANTHROPIC_API_KEY is required', + ) + }) + + it('returns the text content from a successful response', async () => { + vi.stubGlobal('fetch', async () => ({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'ok' }] }), + })) + const result = await callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + expect(result).toBe('ok') + }) + + it('throws on HTTP error response without retrying', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: { message: 'Internal Server Error' } }), + }) + vi.stubGlobal('fetch', fetchMock) + await expect(callAnthropic({ apiKey: 'test-key', prompt: 'hello' })).rejects.toThrow( + 'Internal Server Error', + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('throws on non-retryable network error without retrying', async () => { + const nonRetryableError = Object.assign(new Error('DNS lookup failed'), { code: 'ENOTFOUND' }) + const fetchMock = vi.fn().mockRejectedValue(nonRetryableError) + vi.stubGlobal('fetch', fetchMock) + await expect(callAnthropic({ apiKey: 'test-key', prompt: 'hello' })).rejects.toThrow( + 'DNS lookup failed', + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + describe('retry behavior', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('retries on ETIMEDOUT and succeeds on second attempt', async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(Object.assign(new Error('etimedout'), { code: 'ETIMEDOUT' })) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'retried-ok' }] }), + }) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + await vi.advanceTimersByTimeAsync(500) + await expect(promise).resolves.toBe('retried-ok') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('retries on ECONNRESET (via cause.code) and succeeds on second attempt', async () => { + const wrapped = Object.assign(new Error('socket error'), { + cause: { code: 'ECONNRESET' }, + }) + const fetchMock = vi.fn() + .mockRejectedValueOnce(wrapped) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'recovered' }] }), + }) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + await vi.advanceTimersByTimeAsync(500) + await expect(promise).resolves.toBe('recovered') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('retries on undici aggregate error (cause.errors[].code) and succeeds', async () => { + const aggregate = Object.assign(new Error('undici error'), { + cause: { errors: [{ code: 'UND_ERR_SOCKET' }] }, + }) + const fetchMock = vi.fn() + .mockRejectedValueOnce(aggregate) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'undici-ok' }] }), + }) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + await vi.advanceTimersByTimeAsync(500) + await expect(promise).resolves.toBe('undici-ok') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('throws after exhausting all retries', async () => { + const err = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) + const fetchMock = vi.fn().mockRejectedValue(err) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + // Register the rejection handler before advancing timers to avoid an + // unhandled-rejection warning while the timers are firing. + const assertion = expect(promise).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(2000) + await assertion + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/lib/net-utils.mjs b/lib/net-utils.mjs new file mode 100644 index 0000000..3fd17c7 --- /dev/null +++ b/lib/net-utils.mjs @@ -0,0 +1,34 @@ +// Internal network/environment utilities for lib/prompts.mjs. +// This module may be shipped as an implementation detail, but it is not a +// supported public API; import it directly in tests rather than going through +// prompts.mjs. + +import { readFileSync } from 'node:fs' + +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 + } +} diff --git a/lib/prompts.mjs b/lib/prompts.mjs index ae69943..eedafcf 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -3,8 +3,52 @@ // and by the CLI in bin/mint-ds.mjs. Keep this file dependency-free so it runs // in plain Node without a build step. +import { setDefaultResultOrder, lookup as dnsLookup } from 'node:dns' +import { hasDnsResultOrderOverride, isWSL2 } from './net-utils.mjs' + +// On WSL2, NAT routing can resolve api.anthropic.com to an IPv6 address that +// isn't reachable, causing fetch() to hang until ETIMEDOUT (issue #19). +// Force IPv4-first only on WSL2 so that non-WSL hosts keep their default +// (verbatim) ordering. Honors any user-provided --dns-result-order override. +if (isWSL2() && !hasDnsResultOrderOverride()) { + /* v8 ignore next */ + setDefaultResultOrder('ipv4first') +} + const MODEL = 'claude-sonnet-4-20250514' const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages' +const ANTHROPIC_HOST = 'api.anthropic.com' +const DEBUG = process.env.MINT_DEBUG === '1' || process.env.MINT_DEBUG === 'true' + +/* v8 ignore next 3 */ +function debugLog(...args) { + if (DEBUG) process.stderr.write('[mint:debug] ' + args.join(' ') + '\n') +} + +/* v8 ignore start */ +function dnsLookupAll(host) { + return new Promise((resolve) => { + dnsLookup(host, { all: true, verbatim: false }, (err, addresses) => { + if (err) resolve({ error: err.code || err.message }) + else resolve({ addresses }) + }) + }) +} +/* v8 ignore end */ + +/* v8 ignore start */ +function describeError(err) { + const codes = [] + if (err?.code) codes.push(err.code) + if (err?.cause?.code) codes.push(`cause:${err.cause.code}`) + for (const inner of err?.cause?.errors || []) { + if (inner?.code) codes.push(`inner:${inner.code}`) + if (inner?.address) codes.push(`addr:${inner.address}`) + if (inner?.port) codes.push(`port:${inner.port}`) + } + return codes.join(' ') || err?.message || String(err) +} +/* v8 ignore end */ export function preprocessCss(raw) { return String(raw) @@ -388,30 +432,71 @@ export function stripFences(raw) { .trim() } -/* v8 ignore start */ +// Network errors that benefit from a retry under WSL2's flaky NAT (issue #19). +// HTTP errors (4xx/5xx) are NOT retried — they're surfaced as-is. +const RETRYABLE_NET_CODES = new Set([ + 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', + 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', +]) + +function isRetryableNetworkError(err) { + 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)) +} + export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { if (!apiKey) { throw new Error('ANTHROPIC_API_KEY is required') } - const res = await fetch(ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: MODEL, - max_tokens: maxTokens, - messages: [{ role: 'user', content: prompt }], - }), + const body = JSON.stringify({ + model: MODEL, + max_tokens: maxTokens, + messages: [{ role: 'user', content: prompt }], }) - const data = await res.json() - if (!res.ok) { - const msg = data?.error?.message || `Anthropic API error (${res.status})` - throw new Error(msg) + const backoffsMs = [500, 1500] + /* v8 ignore next 5 */ + if (DEBUG) { + const dns = await dnsLookupAll(ANTHROPIC_HOST) + debugLog(`dns ${ANTHROPIC_HOST} ->`, JSON.stringify(dns)) + debugLog(`payload bytes=${Buffer.byteLength(body)} maxTokens=${maxTokens}`) + } + for (let attempt = 0; attempt <= backoffsMs.length; attempt++) { + const startedAt = Date.now() + try { + debugLog(`attempt ${attempt + 1}/${backoffsMs.length + 1} -> POST ${ANTHROPIC_URL}`) + const res = await fetch(ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body, + }) + const data = await res.json() + if (!res.ok) { + const msg = data?.error?.message || `Anthropic API error (${res.status})` + throw new Error(msg) + } + const block = (data.content || []).find((b) => b && b.type === 'text') + return block ? block.text : '' + } catch (err) { + const elapsed = Date.now() - startedAt + /* v8 ignore next 3 */ + if (DEBUG) { + debugLog(`attempt ${attempt + 1} failed in ${elapsed}ms: ${describeError(err)}`) + } + if (attempt < backoffsMs.length && isRetryableNetworkError(err)) { + debugLog(`retrying in ${backoffsMs[attempt]}ms`) + await new Promise((r) => setTimeout(r, backoffsMs[attempt])) + continue + } + throw err + } } - const block = (data.content || []).find((b) => b && b.type === 'text') - return block ? block.text : '' } -/* v8 ignore end */