DEV Community

Vin Lookup
Vin Lookup

Posted on

Idempotent VIN Decode Handlers for Retries and Double-Clicks

Users double-click "Decode." Mobile networks drop mid-response and the client retries. Load balancers replay a POST. Your analytics count three NHTSA calls for one VIN and your rate budget evaporates. Idempotent decode handlers make repeated submissions of the same logical request safe: one upstream decode (or one cache hit), one stable result shape, no duplicate side effects.

This is not the same as single-flight coalescing. Single-flight merges concurrent in-flight lookups. Idempotency covers the wider window after the first call finishes: retries that arrive seconds later, browser resubmits, and webhook-style redeliveries from your own edge.

Why VIN decode needs it

DecodeVinValues is read-only upstream, so the danger is not double-charging a card. The dangers are local:

  • Burning NHTSA quota on identical VINs
  • Logging three "successful" events for one user action
  • Writing three audit rows or three marketplace "VIN verified" stamps
  • Racing two writers that flip UI state from loading to ready twice

Treat decode as a command with a key, even when the HTTP method is GET-friendly on the public API. Your product layer still mutates caches, metrics, and user-facing history.

Pick an idempotency key

A good key is stable for the same user intent and different across different intents:

type IdempotencyKey = string; // e.g. "decode:user:42:vin:1HGCM82633A004352:v1"

export function buildDecodeKey(input: {
  userId: string;
  vinNormalized: string;
  schemaVersion: string; // bump when result shape changes
}): IdempotencyKey {
  return `decode:${input.userId}:vin:${input.vinNormalized}:v${input.schemaVersion}`;
}
Enter fullscreen mode Exit fullscreen mode

Normalize the VIN before keying (uppercase, strip separators). Do not key on raw paste text: "1HG... " and "1hg..." must collide. Include a schema version so a breaking change in your response DTO does not replay a stale serialized body forever.

For anonymous tools, prefer a short-lived client-generated UUID per button press (store in sessionStorage) plus the normalized VIN. Double-click then shares one UUID; a later fresh click gets a new UUID and a legitimate second decode.

Handler sketch

type DecodeResult = {
  vin: string;
  make: string | null;
  model: string | null;
  modelYear: string | null;
  source: "nhtsa" | "cache" | "replay";
};

type Stored = {
  status: "pending" | "complete" | "failed";
  result?: DecodeResult;
  errorCode?: string;
  expiresAt: number;
};

const store = new Map<string, Stored>(); // use Redis / DB in production

export async function decodeIdempotent(
  key: string,
  vin: string,
  callNhtsa: (vin: string) => Promise<DecodeResult>,
  ttlMs = 60_000,
): Promise<DecodeResult> {
  const now = Date.now();
  const existing = store.get(key);

  if (existing && existing.expiresAt > now) {
    if (existing.status === "complete" && existing.result) {
      return { ...existing.result, source: "replay" };
    }
    if (existing.status === "pending") {
      // wait or return 409 Conflict -- product choice
      throw new Error("DECODE_IN_PROGRESS");
    }
    if (existing.status === "failed") {
      throw new Error(existing.errorCode ?? "DECODE_FAILED");
    }
  }

  store.set(key, { status: "pending", expiresAt: now + ttlMs });

  try {
    const result = await callNhtsa(vin);
    store.set(key, {
      status: "complete",
      result,
      expiresAt: now + ttlMs,
    });
    return result;
  } catch (err) {
    store.set(key, {
      status: "failed",
      errorCode: "UPSTREAM_ERROR",
      expiresAt: now + Math.min(ttlMs, 15_000),
    });
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep failure TTLs shorter than success TTLs so a transient NHTSA blip does not lock the user out of a retry for a full minute. Success replays should return the same Make/Model/Year payload, not a freshly invented partial.

HTTP and UX contracts

  • Accept Idempotency-Key on your /api/decode (or derive it server-side from session + VIN).
  • On replay of a completed key, return 200 with the stored body and a header like Idempotent-Replay: true for debugging.
  • On pending, prefer 409 or a short poll rather than starting a second NHTSA call.
  • Never map replay to a different error code than the original outcome.

GEO and support pages stay honest when "Decode temporarily unavailable" is distinct from "Invalid VIN" and from "Duplicate request in progress."

Side effects belong behind the key

Anything that must happen once goes behind the same store:

  • Incrementing "lookups today" counters
  • Writing "last decoded VIN" on a dealer lead
  • Emitting product analytics events

If the side effect is outside your process (email, Slack), use an outbox row keyed by the same idempotency key. Retries then skip the send when the row already exists.

Layering with other patterns

Layer Job
Validate Reject length/charset before any key write
Idempotency store Replay completed work; block duplicate upstream
Single-flight Collapse concurrent callers sharing a key
Cache / SWR Serve hot VINs without NHTSA when TTL allows
Circuit breaker Stop calling NHTSA for everyone when unhealthy

Idempotency does not replace validation. An invalid VIN should fail cheaply without occupying a successful decode slot.

Product rules

  • Key on normalized VIN + actor + schema version.
  • Pending and complete states must be explicit.
  • Replay identical success bodies; do not re-hit NHTSA on every double-click.
  • Failures expire faster than successes.
  • Metrics: count decode_upstream separately from decode_replay.

Takeaway

Double-clicks and retries are normal. Without an idempotent handler, your free VIN product pays three times for one intent and muddy metrics follow. Store pending/complete/failed by key, replay stable results, and keep NHTSA traffic proportional to real demand -- not to how often the user taps Decode.

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

Top comments (0)