Fetch AbortError Swallowed the Real Error in Retries
Prevent retry logic from treating the intentional AbortError from a timeout controller as a network failure, and preserve the original error cause.
Symptoms
- Retries log AbortError instead of the underlying timeout or response error
- An operation retries several times before surfacing a generic failure
- Error monitoring shows AbortError but no root cause
Possible causes
- The catch block treats any thrown error, including AbortError, as retryable
- A timeout controller aborts the request and the abort reason is never read
- The original error is replaced instead of being chained as cause
Diagnosis steps
Distinguish abort from real failure
if (err.name === 'AbortError') { /* intentional */ } else { retry(err) }Interpretation
An AbortError from your own controller is expected on timeout; it should map to a timeout error, not a retry loop.
Read the abort reason set by AbortController.abort
controller.abort(new Error('timeout after 5s'))Interpretation
Passing a reason makes the timeout explicit and lets callers report it accurately.
Root cause
AbortController.abort() rejects the fetch promise with a DOMException named AbortError. Retry logic that inspects only message text cannot tell an intentional abort from a transport failure, so it either retries uselessly or reports the wrong error.
Fix
Catch by error name: treat AbortError as a terminal timeout, retry only on network and 5xx errors, and chain the original error as cause so monitoring keeps the real signal.
Risk note
Blindly retrying non-idempotent POSTs can duplicate side effects; retry only idempotent operations or use an idempotency key.
Verify the fix
Force a timeout and confirm the log shows a timeout error, not AbortError; force a 500 and confirm exactly the configured number of retries occur.
Symptoms
The error “Fetch AbortError Swallowed the Real Error in Retries” points at a specific failure mode. This guide documents the symptoms you can observe, the commands that isolate the cause, the root cause, the fix, and the checks that prove the fix works.
Observed symptoms include:
- Retries log AbortError instead of the underlying timeout or response error
- An operation retries several times before surfacing a generic failure
- Error monitoring shows AbortError but no root cause
Reproduce and Diagnose
Reproduce the failure in isolation before changing any code. Work through the checks below in order; each one narrows the search space.
Step 1: Distinguish abort from real failure
if (err.name === 'AbortError') { /* intentional */ } else { retry(err) }
An AbortError from your own controller is expected on timeout; it should map to a timeout error, not a retry loop.
Step 2: Read the abort reason set by AbortController.abort
controller.abort(new Error('timeout after 5s'))
Passing a reason makes the timeout explicit and lets callers report it accurately.
Likely contributors, in order of frequency:
- The catch block treats any thrown error, including AbortError, as retryable
- A timeout controller aborts the request and the abort reason is never read
- The original error is replaced instead of being chained as cause
Root Cause
AbortController.abort() rejects the fetch promise with a DOMException named AbortError. Retry logic that inspects only message text cannot tell an intentional abort from a transport failure, so it either retries uselessly or reports the wrong error.
The Fix
Catch by error name: treat AbortError as a terminal timeout, retry only on network and 5xx errors, and chain the original error as cause so monitoring keeps the real signal.
Watch out: Blindly retrying non-idempotent POSTs can duplicate side effects; retry only idempotent operations or use an idempotency key.
async function fetchWithTimeout(url, ms = 5000) {
const c = new AbortController();
const timer = setTimeout(() => c.abort(new Error('request timed out')), ms);
try {
return await fetch(url, { signal: c.signal });
} finally {
clearTimeout(timer);
}
}
async function attempt(url, tries = 3) {
for (let i = 1; i <= tries; i++) {
try {
const res = await fetchWithTimeout(url);
if (res.ok || res.status < 500) return res;
throw new Error(`HTTP ${res.status}`);
} catch (err) {
if (err.name === 'AbortError' || i === tries) throw err;
await new Promise((r) => setTimeout(r, i * 200));
}
}
}
Verify and Prevent
Force a timeout and confirm the log shows a timeout error, not AbortError; force a 500 and confirm exactly the configured number of retries occur.
Once verified, add a regression test that reproduces the original symptom so the failure cannot silently return.
This class of failure is usually environmental rather than pure logic, so capture the exact command, the environment variables, and the working directory in your bug report. Reproduce it in the smallest possible script, then widen the scope only once the minimal case is understood. A short written checklist for this failure mode saves hours the next time it appears.