DEV Community

Vin Lookup
Vin Lookup

Posted on

Honoring Retry-After and Upstream Hints When NHTSA Slows Down

Backoff with jitter is necessary for a free VIN decode that calls NHTSA vPIC. It is not sufficient. When upstream (or your proxy) says wait, ignoring that hint and sleeping a random few hundred milliseconds turns a soft slowdown into a hard block for everyone sharing the public pipe.

This post covers honoring Retry-After in TypeScript: parse the header, cap waits, surface honest UX, and refuse to stampede DecodeVinValues when the server already said when to return.

Why jitter alone is not enough

Full-jitter exponential backoff spreads load when many clients fail at once. It does not mean "pick any delay you like after a 429." HTTP already has a signal for that:

  • Retry-After: 120 (seconds)
  • Retry-After: Wed, 07 Oct 2026 12:00:00 GMT (HTTP-date)

Your proxy may also return a JSON body with retryAfterMs or a Retry-After it computed while shedding load. Treat those as first-class inputs to the wait planner, not as optional telemetry.

If you ignore them:

  1. You retry early and burn quota again
  2. You train your circuit breaker on noise you caused
  3. Users see flapping success/failure while you argue with a public API clock

Parse, clamp, then sleep

Never trust a raw header blindly -- but do prefer it over your local guess when present and sane.

export type RetryHint = {
  waitMs: number;
  source: "retry-after-seconds" | "retry-after-http-date" | "body" | "backoff";
};

const MAX_WAIT_MS = 60_000;
const MIN_WAIT_MS = 250;

export function parseRetryAfterHeader(
  value: string | null,
  nowMs: number = Date.now(),
): RetryHint | null {
  if (!value) return null;
  const trimmed = value.trim();
  if (/^\d+$/.test(trimmed)) {
    const sec = Number(trimmed);
    if (!Number.isFinite(sec) || sec < 0) return null;
    return {
      waitMs: clamp(sec * 1000, MIN_WAIT_MS, MAX_WAIT_MS),
      source: "retry-after-seconds",
    };
  }
  const when = Date.parse(trimmed);
  if (Number.isNaN(when)) return null;
  return {
    waitMs: clamp(when - nowMs, MIN_WAIT_MS, MAX_WAIT_MS),
    source: "retry-after-http-date",
  };
}

function clamp(n: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, n));
}

export function planWait(opts: {
  status: number;
  retryAfter: string | null;
  bodyRetryMs?: number | null;
  attempt: number;
  baseMs?: number;
  maxMs?: number;
}): RetryHint {
  const fromHeader = parseRetryAfterHeader(opts.retryAfter);
  if (fromHeader && (opts.status === 429 || opts.status === 503)) {
    return fromHeader;
  }
  if (
    opts.bodyRetryMs != null &&
    Number.isFinite(opts.bodyRetryMs) &&
    opts.bodyRetryMs > 0
  ) {
    return {
      waitMs: clamp(opts.bodyRetryMs, MIN_WAIT_MS, MAX_WAIT_MS),
      source: "body",
    };
  }
  const base = opts.baseMs ?? 400;
  const max = opts.maxMs ?? MAX_WAIT_MS;
  const cap = Math.min(max, base * 2 ** Math.max(0, opts.attempt - 1));
  const jitter = Math.floor(Math.random() * (cap + 1));
  return { waitMs: clamp(jitter, MIN_WAIT_MS, max), source: "backoff" };
}
Enter fullscreen mode Exit fullscreen mode

Priority is deliberate: honor explicit upstream wait for 429/503, then body hints from your proxy, then local jittered backoff for timeouts and 502/504 without a header.

Wire it into fetch, not into the UI guess

Keep one function that performs the decode attempt and applies the plan. Do not let React components invent their own sleep.

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

export async function fetchDecodeWithHints(
  url: string,
  init: RequestInit,
  maxAttempts = 4,
): Promise<Response> {
  let lastError: unknown;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const res = await fetch(url, init);
      if (res.ok) return res;

      const retryable =
        res.status === 429 ||
        res.status === 502 ||
        res.status === 503 ||
        res.status === 504;

      if (!retryable || attempt === maxAttempts) return res;

      let bodyRetryMs: number | null = null;
      const ct = res.headers.get("content-type") ?? "";
      if (ct.includes("application/json")) {
        try {
          const body = (await res.clone().json()) as { retryAfterMs?: number };
          bodyRetryMs =
            typeof body.retryAfterMs === "number" ? body.retryAfterMs : null;
        } catch {
          bodyRetryMs = null;
        }
      }

      const hint = planWait({
        status: res.status,
        retryAfter: res.headers.get("retry-after"),
        bodyRetryMs,
        attempt,
      });

      // Optional: emit metric { status, hint.source, hint.waitMs }
      await sleep(hint.waitMs);
    } catch (err) {
      lastError = err;
      if (attempt === maxAttempts) throw err;
      const hint = planWait({
        status: 0,
        retryAfter: null,
        attempt,
      });
      await sleep(hint.waitMs);
    }
  }
  throw lastError ?? new Error("decode retries exhausted");
}
Enter fullscreen mode Exit fullscreen mode

Clone the body before JSON parse so callers can still read an error payload. Log hint.source so dashboards show header obedience vs jitter fallback.

Product rules when the wait is long

Honoring a 45-second Retry-After behind a spinner that implies "almost done" is a UX lie. Cap automatic waits (for example 60s), fail soft when over the cap, optionally countdown from waitMs, and keep single-flight so you do not open parallel retries for the same VIN while sleeping.

export type DecodeUserError = {
  code: "upstream_throttle" | "upstream_unavailable" | "exhausted";
  message: string;
  retryAfterMs?: number;
};

export function userErrorFromHint(
  status: number,
  hint: RetryHint,
  overCap: boolean,
): DecodeUserError {
  if (overCap || hint.waitMs >= MAX_WAIT_MS) {
    return {
      code: "upstream_throttle",
      message:
        "Vehicle data service asked us to slow down. Please try again in a minute.",
      retryAfterMs: hint.waitMs,
    };
  }
  return {
    code: status === 429 ? "upstream_throttle" : "upstream_unavailable",
    message: "Temporary upstream slowdown. Retrying with their timing...",
    retryAfterMs: hint.waitMs,
  };
}
Enter fullscreen mode Exit fullscreen mode

What not to retry even with a header

A Retry-After on a misclassified 400 does not make bad input transient. Keep local validation out of the retry loop. Do not retry HTTP 200 with sparse fields. If your edge returns 429 because you rate-limited the client, still honor your own header -- one protocol for everyone.

Takeaway

When NHTSA or your proxy slows down, Retry-After and body wait hints are part of the API contract. Parse them, clamp them, prefer them over local jitter for 429/503, and tell users the truth when the wait exceeds what a spinner can hide. A free VIN decode stays a good citizen of public data when it waits on purpose -- not when it guesses.

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

Top comments (0)