[BUG] Connection-level errors (ECONNRESET, EPIPE, ETIMEDOUT) are never retried — immediate failure with zero recovery

Resolved 💬 4 comments Opened Mar 21, 2026 by WYSIATI Closed May 25, 2026

Summary

Connection-level errors (ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED) are not retried by Claude Code's API call function. Only HTTP-level errors (429, 529) are retried. A single transient TCP reset kills the request immediately.

This is the #1 most reported issue category: #5674, #28557, #18100, #27789, #33428, #29591, #33355 (10+ open issues).

Root Cause Analysis

The main API call generator function (minified as JO6 in cli.js) sets maxRetries: 0 on the Anthropic SDK, disabling built-in retry. It implements custom retry logic, but the retry condition only matches APIError instances with HTTP status codes:

// Decompiled retry condition:
if (retryEnabled && error instanceof APIError && (error.status === 429 || isOverloaded(error))) {
  // retry with backoff — only for 429/529
}
// Connection errors (ECONNRESET etc.) are raw Error objects, NOT APIError instances
// They fall through to: throw new RetryError(error, context)  — zero retries

The Anthropic Python SDK properly retries all exceptions including connection errors (see _base_client.py lines 1052-1079). The TypeScript SDK also retries generic exceptions. But Claude Code bypasses this by setting maxRetries: 0 and only partially reimplementing the retry logic.

Evidence from minified source

// SDK retry disabled:
cC({maxRetries: 0, model: A})

// Retry constants found:
CF9 = 10      // max retries (for 429/529 only)
hF9 = 500     // initial retry delay (500ms)
SF9 = 3       // overload fallback threshold
BF9 = 20000   // retry delay cap (20s)

// Only retries on APIError with 429/529:
if (_ && J instanceof g4 && (J.status === 429 || D34(J)))

// D34 (isOverloaded) only checks status 529:
function D34(A) {
  if (!(A instanceof g4)) return false;
  return A.status === 529 || A.message?.includes('"type":"overloaded_error"');
}

Proposed Fix

Add a catch branch for non-APIError transient connection errors:

// In the main API call function, after the existing 429/529 retry block:
catch (error) {
  lastError = error;

  // EXISTING: retry on 429/529
  if (retryEnabled && error instanceof APIError && (error.status === 429 || isOverloaded(error))) {
    // ... existing retry logic ...
    continue;
  }

  // NEW: retry on transient connection errors
  if (retryEnabled && isTransientConnectionError(error) && attempt <= maxRetries) {
    const delay = calculateBackoff(attempt);
    log(`Connection error (${getErrorCode(error)}), retrying in ${delay}ms (attempt ${attempt}/${maxRetries})`);
    telemetry("tengu_api_connection_retry", {
      attempt, delayMs: delay, errorCode: getErrorCode(error), provider: getProvider()
    });
    await sleep(delay, signal);
    continue;
  }

  throw new RetryError(error, context);
}

function isTransientConnectionError(error: unknown): boolean {
  if (error instanceof APIError) return false;

  const code = (error as any)?.code || '';
  const message = (error as any)?.message || '';

  const TRANSIENT_CODES = new Set([
    'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT',
    'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH',
    'EOPNOTSUPP', 'UND_ERR_SOCKET',
  ]);

  if (TRANSIENT_CODES.has(code)) return true;
  if (message.includes('socket connection was closed unexpectedly')) return true; // Bun
  if (message === 'Connection error.') return true; // SDK wrapper
  return false;
}

Design decisions

  • Default 3 retries (configurable via existing CLAUDE_CODE_MAX_RETRIES)
  • Same backoff curve as existing: min(500ms * 2^attempt, 32s) with 25% jitter
  • Respects abort signal — Escape/Ctrl+C cancels retry sleep
  • Telemetrytengu_api_connection_retry event for monitoring
  • Does NOT retry non-transient errors — auth failures, invalid requests, etc.

Testing Plan

Unit Tests

| Test | Input | Expected |
|------|-------|----------|
| isTransientConnectionError → true for ECONNRESET | err.code = 'ECONNRESET' | true |
| isTransientConnectionError → true for EPIPE | err.code = 'EPIPE' | true |
| isTransientConnectionError → true for Bun socket msg | 'socket connection was closed unexpectedly' | true |
| isTransientConnectionError → false for APIError 400 | new APIError(400, ...) | false |
| isTransientConnectionError → false for auth error | new Error('Invalid API key') | false |
| Backoff: attempt 1 | calculateBackoff(1) | ~500ms |
| Backoff: attempt 3 | calculateBackoff(3) | ~4000ms |
| Backoff: cap at 32s | calculateBackoff(10) | ~32000ms |

Integration Tests

| Test | Setup | Expected |
|------|-------|----------|
| Retry succeeds after 1 ECONNRESET | Mock: fail once, succeed | Request completes, 1 retry logged |
| Retry succeeds after 2 failures | Mock: fail twice, succeed | Request completes, 2 retries logged |
| Exhausted retries | Mock: all 4 calls fail | RetryError after attempt 4 |
| Mixed: ECONNRESET then 429 | Mock: ECONNRESET → 429 → success | Both retry paths exercised |
| Abort signal cancels sleep | Mock: ECONNRESET + abort during sleep | Aborted, no further retries |
| CLAUDE_CODE_MAX_RETRIES=0 disables | Env var set | No retries, immediate failure |
| Streaming: ECONNRESET before first event | Mock: connection fails | Retry, stream restarted |
| Telemetry events emitted | Mock: 2 failures | 2 tengu_api_connection_retry events |

E2E Tests

| Test | Steps | Expected |
|------|-------|----------|
| Network disconnect during idle | Disable Wi-Fi, re-enable within 30s | Retry message shown, response completes |
| VPN reconnect | VPN drops and reconnects | Retry shown, succeeds |
| User-visible messaging | Trigger ECONNRESET | Shows: API Error (Connection error.) · Retrying in 1s... (attempt 1/3) · (ECONNRESET) |

Impact

This single fix would resolve or significantly mitigate the most common user complaint: "ECONNRESET kills my session." The fix is minimal (~30 lines of new code), uses the existing backoff infrastructure, and carries no risk to the 429/529 retry paths.

Related Issues

  • #5674 — Persistent ECONNRESET Errors on macOS Network Connections
  • #28557 — ECONNRESET issues
  • #18100 — ECONNRESET - Unable to establish connection
  • #27789 — Unable to connect to API (ECONNRESET) v2.1.50
  • #33428 — ECONNRESET Errors with pro subscription on Mac
  • #29591 — Cowork ECONNRESET on macOS
  • #33355 — Unable to connect to API (ConnectionRefused)
  • #32397 — Allow configuring API connection max retry attempts

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗