[BUG] Connection-level errors (ECONNRESET, EPIPE, ETIMEDOUT) are never retried — immediate failure with zero recovery
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
- Telemetry —
tengu_api_connection_retryevent 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
5 Comments
Excellent root cause analysis — the
maxRetries: 0+ partial retry reimplementation is the core issue.Partial workaround: PostToolUseFailure hook to auto-retry
You can't retry API calls from a hook, but you can detect connection-related failures and alert:
Better workaround: network-level retry proxy
For production autonomous workflows, run Claude Code behind a retry proxy:
The real fix needs to happen in Claude Code's API layer — the Anthropic SDK already has correct retry logic for connection errors, but
maxRetries: 0disables it. Even changing tomaxRetries: 3would solve the vast majority of transient failures.dying for this for airplane wifi!
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
What happened to this here? Is this still planned? Has this been resolved?
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.