← Debugging Guides

阅读中文版本

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.

intermediatedebuggingnodejsfetchretry

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.