Skip to content

fix: mitigate WSL2 fetch ETIMEDOUTs to Anthropic API (#19)#23

Draft
nujovich wants to merge 20 commits into
mainfrom
claude/fetch-issues-wsl-hPGUs
Draft

fix: mitigate WSL2 fetch ETIMEDOUTs to Anthropic API (#19)#23
nujovich wants to merge 20 commits into
mainfrom
claude/fetch-issues-wsl-hPGUs

Conversation

@nujovich
Copy link
Copy Markdown
Owner

@nujovich nujovich commented May 7, 2026

Summary

  • Force IPv4-first DNS resolution at module load to avoid WSL2's NAT routing fetch through an unreachable IPv6 address (root cause of Timeout errors on WSL2 in audit, resolve, and export steps (ETIMEDOUT from fetch to Anthropic API) #19).
  • Retry callAnthropic up to twice (500ms / 1500ms backoff) on transient network errors only (ETIMEDOUT, ECONNRESET, undici socket/connect errors); HTTP responses are still surfaced as-is.
  • Add opt-in MINT_DEBUG=1 diagnostics: DNS lookup result, payload size, per-attempt timing, and decoded undici error chains, to narrow down any residual failures.

Refs #19

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • Dependency update

How to test

  1. On WSL2 with a valid ANTHROPIC_API_KEY, run npm run dev and exercise auditresolveexport end-to-end. The resolve step (larger payload) should no longer hang with ETIMEDOUT.
  2. Run again with MINT_DEBUG=1 npm run dev and confirm [mint:debug] lines show the DNS lookup returning family:4 first and a successful first attempt in normal latency (~10–20s for resolve/export).
  3. Sanity-check on native Windows / Linux that nothing regresses (still single attempt, no debug noise without the env var).

Checklist

  • npx tsc --noEmit passes with no errors
  • Tested with real CSS input through the full audit → tokens → export flow
  • UI is responsive on mobile (tested at 375px width) — N/A, no UI changes
  • No new Spanish strings introduced — all user-facing text is English
  • No hardcoded colors or values that should be CSS variables

Generated by Claude Code

claude added 3 commits May 7, 2026 15:24
WSL2's NAT frequently resolves api.anthropic.com to an IPv6 address that
isn't routable from the guest, causing fetch() to hang until ETIMEDOUT
on the larger payloads of the resolve and export steps (issue #19).

Calling dns.setDefaultResultOrder('ipv4first') at module load applies the
fix to both the CLI and the Next.js API routes without any user-facing
configuration. The override is skipped if the user already set
--dns-result-order via NODE_OPTIONS.

Refs #19
Even with ipv4first DNS, the first fetch under WSL2 occasionally fails
with ETIMEDOUT before subsequent attempts succeed (observed on resolve).
Retry up to twice with 500ms / 1500ms backoff, but only for network-level
errors (ETIMEDOUT, ECONNRESET, undici socket/connect errors, etc.).
HTTP responses are still surfaced as-is to avoid masking real API errors.

Refs #19
To narrow down the residual ETIMEDOUTs on WSL2 (issue #19), opt-in
debug logging via MINT_DEBUG=1 prints:
- DNS lookup result for api.anthropic.com (addresses + family)
- Payload size and maxTokens per call
- Per-attempt POST start, elapsed ms on failure, and decoded error
  codes (including undici cause chains: ETIMEDOUT, address, port, etc.)
- Retry scheduling

No behavior change when MINT_DEBUG is unset.

Refs #19
@nujovich
Copy link
Copy Markdown
Owner Author

nujovich commented May 7, 2026

PR remains in draft until we find more stable sessions

@nujovich nujovich added bug Something isn't working research labels May 7, 2026
@nujovich nujovich requested a review from Copilot May 7, 2026 16:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Mitigates WSL2-specific fetch() timeouts when calling the Anthropic API by adjusting DNS resolution defaults, adding targeted retries for transient network failures, and introducing optional debug diagnostics for network troubleshooting.

Changes:

  • Force IPv4-first DNS result ordering at module load (unless NODE_OPTIONS already includes a --dns-result-order setting).
  • Add retry/backoff behavior to callAnthropic for selected transient network error codes.
  • Add opt-in MINT_DEBUG logging for DNS resolution, payload size, and per-attempt timing/error details.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/prompts.mjs Outdated
Comment on lines +8 to +12
// WSL2's NAT often resolves api.anthropic.com to an IPv6 address that isn't
// routable, causing fetch() to hang until ETIMEDOUT (issue #19). Forcing IPv4
// first avoids the hang on WSL2 and is a no-op on hosts where IPv6 works.
// Honors NODE_OPTIONS=--dns-result-order=... if the user already set it.
if (!process.env.NODE_OPTIONS?.includes('--dns-result-order')) {
Comment thread lib/prompts.mjs Outdated
Comment on lines +6 to +12
import { setDefaultResultOrder, lookup as dnsLookup } from 'node:dns'

// WSL2's NAT often resolves api.anthropic.com to an IPv6 address that isn't
// routable, causing fetch() to hang until ETIMEDOUT (issue #19). Forcing IPv4
// first avoids the hang on WSL2 and is a no-op on hosts where IPv6 works.
// Honors NODE_OPTIONS=--dns-result-order=... if the user already set it.
if (!process.env.NODE_OPTIONS?.includes('--dns-result-order')) {
Comment thread lib/prompts.mjs Outdated
Comment on lines +454 to +493
const backoffsMs = [500, 1500]
let lastErr
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) {
lastErr = err
const elapsed = Date.now() - startedAt
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 : ''
throw lastErr
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

lib/prompts.mjs:466

  • The PR description mentions retrying callAnthropic with backoff on transient network errors, but callAnthropic still performs a single fetch() and the new isRetryableNetworkError/RETRYABLE_NET_CODES helpers are unused. Either implement the retry loop as described (ensuring only network failures are retried) or remove the unused retry scaffolding.
// 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 data = await res.json()
  if (!res.ok) {
    const msg = data?.error?.message || `Anthropic API error (${res.status})`
    throw new Error(msg)
  }

Comment thread lib/prompts.mjs Outdated
Comment thread lib/prompts.mjs
Comment thread lib/prompts.mjs
Comment on lines 445 to 447
export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) {
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY is required')
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot restore "/ v8 ignore start */" marker

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already restored in b3af586 — both /* v8 ignore start */ (line 467) and /* v8 ignore end */ (line 504) are now in place and balanced around the network I/O block.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.

Comment thread lib/prompts.mjs Outdated
Comment on lines +20 to +24
// WSL2's NAT often resolves api.anthropic.com to an IPv6 address that isn't
// routable, causing fetch() to hang until ETIMEDOUT (issue #19). Forcing IPv4
// first avoids the hang on WSL2 and is a no-op on hosts where IPv6 works.
// Honors user-provided --dns-result-order from NODE_OPTIONS or CLI args.
if (!hasDnsResultOrderOverride()) {
Comment thread lib/prompts.mjs Outdated
Comment thread lib/prompts.mjs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
nujovich added 4 commits May 11, 2026 17:16
- Updated vitest import to include vi and afterEach for test setup/teardown
- Added callAnthropic to the prompts.mjs imports
- All 35 existing tests pass

This prepares for Task 3: Adding callAnthropic tests
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

Comment thread lib/prompts.mjs Outdated
Comment on lines +9 to +32
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 when running inside WSL2 (Windows Subsystem for Linux v2).
// WSL2 always sets WSL_DISTRO_NAME; WSL_INTEROP is a fallback for edge cases.
// /proc/version contains "microsoft" on WSL2 kernels as a belt-and-suspenders
// check if neither env var is present.
export function isWSL2() {
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true
try {
return /microsoft/i.test(readFileSync('/proc/version', 'utf8'))
} catch {
return false
}
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot move hasDnsResultOrderOverride and isWSL2 into a separated internal module that tests can import

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c49aa24. Both helpers are now in lib/net-utils.mjs (not in files / public API). lib/prompts.mjs imports them without re-exporting. Tests moved to lib/__tests__/net-utils.test.mjs with cases updated to match the current kernel-file-based isWSL2 implementation — all 47 tests pass with 100% function coverage.

Comment thread lib/prompts.mjs Outdated
Comment thread lib/prompts.mjs Outdated
Comment on lines +25 to +31
export function isWSL2() {
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true
try {
return /microsoft/i.test(readFileSync('/proc/version', 'utf8'))
} catch {
return false
}
Comment thread lib/prompts.mjs
Comment thread lib/prompts.mjs
Comment thread lib/prompts.mjs Outdated
Comment on lines +489 to +528
/* v8 ignore start */
if (DEBUG) {
const dns = await dnsLookupAll(ANTHROPIC_HOST)
debugLog(`dns ${ANTHROPIC_HOST} ->`, JSON.stringify(dns))
debugLog(`payload bytes=${Buffer.byteLength(body)} maxTokens=${maxTokens}`)
}
const block = (data.content || []).find((b) => b && b.type === 'text')
return block ? block.text : ''
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
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
}
}
/* v8 ignore end */
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5b3fe10. The broad /* v8 ignore start/end */ blocks around both isRetryableNetworkError and the retry loop body are removed. Now only the two truly untestable if (DEBUG) branches are marked with targeted /* v8 ignore next 5 */ and /* v8 ignore next 3 */. The retry loop, error checking, isRetryableNetworkError, and setTimeout delay are all now covered by the new tests.

nujovich and others added 2 commits May 11, 2026 19:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread lib/prompts.mjs
Comment thread lib/net-utils.mjs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working research

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants