DEV Community

Vin Lookup
Vin Lookup

Posted on

Using AbortSignal Correctly Across VIN Decode Retries

A free VIN decode that calls NHTSA DecodeVinValues (or your thin proxy) almost always retries: transient 502s, network blips, Retry-After windows. Retries are fine. The trap is treating AbortSignal as decoration on the first attempt only -- so a user who cancelled, navigated away, or typed a new VIN still burns two more retries, or worse, a late retry commits Make/Model for the wrong VIN.

This post is about threading AbortSignal correctly across retry attempts: abort means stop, each attempt must observe the same parent signal, and waits between retries must be abortable too.

The failure mode

Typical buggy loop:

  1. Attempt 1: fetch(url, { signal }) fails with a retryable HTTP error.
  2. You await sleep(500) with no link to the signal.
  3. Attempt 2 starts even though the user already aborted.
  4. Or you create a new AbortController per attempt and never wire the parent's abort into it.
  5. Or you catch AbortError and retry it like a 503.

Result: wasted NHTSA budget, zombie responses after unmount, and UX that ignores cancel.

Cancellation on navigation is covered elsewhere. Here the focus is retries that respect one shared signal from start to finish.

One parent signal, every attempt

Pass a single AbortSignal into the retry helper. Every fetch and every backoff sleep must observe it. Do not retry when signal.aborted is true. Do not treat AbortError as retryable.

export type DecodeRow = Record<string, string>;

export type RetryPolicy = {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  isRetryable: (err: unknown, status?: number) => boolean;
};

const DEFAULT_POLICY: RetryPolicy = {
  maxAttempts: 3,
  baseDelayMs: 300,
  maxDelayMs: 4_000,
  isRetryable: (err, status) => {
    if (err instanceof DOMException && err.name === "AbortError") return false;
    if (status === 429 || status === 502 || status === 503 || status === 504) {
      return true;
    }
    return false;
  },
};

function sleep(ms: number, signal: AbortSignal): Promise<void> {
  if (signal.aborted) {
    return Promise.reject(new DOMException("Aborted", "AbortError"));
  }
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    const onAbort = () => {
      clearTimeout(timer);
      signal.removeEventListener("abort", onAbort);
      reject(new DOMException("Aborted", "AbortError"));
    };
    signal.addEventListener("abort", onAbort, { once: true });
  });
}

export async function decodeVinWithRetries(
  vin: string,
  signal: AbortSignal,
  policy: RetryPolicy = DEFAULT_POLICY,
): Promise<DecodeRow> {
  const url =
    "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/" +
    encodeURIComponent(vin) +
    "?format=json";

  let lastError: unknown;

  for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
    if (signal.aborted) {
      throw new DOMException("Aborted", "AbortError");
    }

    try {
      const res = await fetch(url, { signal });
      if (!res.ok) {
        const err = new Error(`vPIC HTTP ${res.status}`);
        if (
          attempt < policy.maxAttempts &&
          policy.isRetryable(err, res.status)
        ) {
          const delay = Math.min(
            policy.maxDelayMs,
            policy.baseDelayMs * 2 ** (attempt - 1),
          );
          await sleep(delay, signal);
          continue;
        }
        throw err;
      }
      const body = (await res.json()) as { Results?: DecodeRow[] };
      const row = body.Results?.[0];
      if (!row) throw new Error("vPIC returned no Results row");
      return row;
    } catch (err) {
      lastError = err;
      if (err instanceof DOMException && err.name === "AbortError") throw err;
      if (attempt >= policy.maxAttempts || !policy.isRetryable(err)) throw err;
      const delay = Math.min(
        policy.maxDelayMs,
        policy.baseDelayMs * 2 ** (attempt - 1),
      );
      await sleep(delay, signal);
    }
  }

  throw lastError instanceof Error
    ? lastError
    : new Error("Decode failed after retries");
}
Enter fullscreen mode Exit fullscreen mode

Key rules encoded above:

  1. Abort is terminal -- never classify AbortError as retryable.
  2. Sleep is abortable -- cancel wakes the waiter immediately instead of finishing the backoff.
  3. Same signal on every fetch -- no orphan controllers that outlive the parent.

Do not mint a fresh signal per retry (unless you link it)

Sometimes you want a per-attempt timeout and a parent cancel. That is fine if you compose signals: abort the child when the parent aborts or when the attempt budget expires. Never replace the parent with an unlinked child.

export function attemptSignal(
  parent: AbortSignal,
  timeoutMs: number,
): { signal: AbortSignal; dispose: () => void } {
  const ctrl = new AbortController();
  const onParent = () => ctrl.abort(parent.reason);
  if (parent.aborted) {
    ctrl.abort(parent.reason);
  } else {
    parent.addEventListener("abort", onParent, { once: true });
  }
  const timer = setTimeout(() => {
    ctrl.abort(new DOMException("Attempt timed out", "AbortError"));
  }, timeoutMs);
  return {
    signal: ctrl.signal,
    dispose: () => {
      clearTimeout(timer);
      parent.removeEventListener("abort", onParent);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Use attemptSignal inside the loop, pass signal to fetch, and always dispose() in a finally. If the parent aborted, the child is already aborted -- do not start the next attempt.

UX and logging

When abort wins mid-retry:

  • Do not show a red "decode failed" toast; treat cancel as silence or a soft idle state.
  • Log outcome: "aborted" separately from outcome: "exhausted_retries" so dashboards do not look like NHTSA is down when users simply left.
  • Never count aborted attempts as successful cache fills.

Small tests that lock the contract

import assert from "node:assert/strict";

// Pseudo: wire a mock fetch that fails once, then assert abort during sleep
// prevents a second fetch.
async function example() {
  const ctrl = new AbortController();
  let fetches = 0;
  const fakeFetch = async () => {
    fetches += 1;
    throw Object.assign(new Error("vPIC HTTP 503"), { status: 503 });
  };

  // In production tests, inject fakeFetch; here we only show the abort race.
  setTimeout(() => ctrl.abort(), 50);
  await assert.rejects(
    () => sleep(5_000, ctrl.signal),
    (e: unknown) => e instanceof DOMException && e.name === "AbortError",
  );
  assert.equal(fetches, 0);
}
Enter fullscreen mode Exit fullscreen mode

Add a unit test that marks AbortError as not retryable in isRetryable. Add an integration test that aborts during backoff and asserts fetches === 1 after a first failure.

Takeaway

Retries without a shared AbortSignal fight the user. Thread one parent signal through every attempt and every sleep, refuse to retry AbortError, and compose per-attempt timeouts only when linked to that parent. Your free VIN decode stays polite to NHTSA and honest to the person who already moved on.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)