DEV Community

Vin Lookup
Vin Lookup

Posted on

Client Idempotency Keys for VIN Decode Retries Without Double-Charging Upstream Quotas

Mobile networks drop mid-response. Users tap Decode twice. Your fetch layer retries a 504 with the same VIN. Each attempt that reaches NHTSA burns the same free-quota unit even when the user intent was one decode. Handler-side idempotency stores (covered elsewhere) protect your API after the request arrives. This post is about the client-generated key that travels with every retry so an edge, BFF, or gateway can refuse a second upstream call before quota is spent.

The goal is narrow: one logical decode attempt, one client key, zero duplicate NHTSA hits when the network or the user repeats themselves. Do not confuse this with VIN-content caching ("same VIN forever") or with circuit breakers that open on slow upstreams.

Why the client must mint the key

Server-derived keys built only from normalized VIN + userId collide across different user intents: yesterday's decode and today's deliberate refresh share the same fingerprint. For quota protection on retries, you want the opposite of forever-dedupe: a short-lived key that is stable for one button press and its automatic retries, then expires so a later deliberate click is a new charge.

The browser (or mobile app) is the right place to mint that key:

  1. User taps Decode (or the form auto-submits once)
  2. Client creates a UUID (or ULID) and stores it next to the in-flight VIN
  3. Every retry of that attempt sends the same Idempotency-Key header
  4. A fresh tap after success or cancel mints a new key

That pattern preserves legitimate second decodes while collapsing double-clicks and transport retries onto one upstream budget.

Shape the request, not the VIN

export type DecodeAttempt = {
  vinNormalized: string;
  idempotencyKey: string;
  createdAt: number;
};

const ATTEMPT_TTL_MS = 60_000;

export function mintDecodeAttempt(vinNormalized: string): DecodeAttempt {
  return {
    vinNormalized,
    idempotencyKey: crypto.randomUUID(),
    createdAt: Date.now(),
  };
}

export function headerForAttempt(attempt: DecodeAttempt): HeadersInit {
  return {
    "Idempotency-Key": attempt.idempotencyKey,
    "Content-Type": "application/json",
  };
}

export function isAttemptFresh(attempt: DecodeAttempt, now = Date.now()): boolean {
  return now - attempt.createdAt < ATTEMPT_TTL_MS;
}
Enter fullscreen mode Exit fullscreen mode

Send the key on POST (or on your BFF's decode route). Do not bake the key into the VIN string. Do not reuse yesterday's key from localStorage across sessions -- that silently merges unrelated user actions and can skip a decode the buyer intended.

Edge / BFF: honor the key before calling NHTSA

Your product layer still owns side effects (metrics, audit rows, UI history). The client key lets the edge short-circuit before DecodeVinValues:

type GateResult =
  | { kind: "proceed" }
  | { kind: "replay"; body: unknown }
  | { kind: "inflight" };

type StoredAttempt = {
  status: "pending" | "complete";
  body?: unknown;
  expiresAt: number;
};

const attempts = new Map<string, StoredAttempt>(); // Redis in production

export function gateUpstream(
  key: string,
  ttlMs = 60_000,
): GateResult {
  const now = Date.now();
  const existing = attempts.get(key);
  if (existing && existing.expiresAt > now) {
    if (existing.status === "complete") {
      return { kind: "replay", body: existing.body };
    }
    return { kind: "inflight" };
  }
  attempts.set(key, { status: "pending", expiresAt: now + ttlMs });
  return { kind: "proceed" };
}

export function completeAttempt(key: string, body: unknown, ttlMs = 60_000): void {
  attempts.set(key, {
    status: "complete",
    body,
    expiresAt: Date.now() + ttlMs,
  });
}
Enter fullscreen mode Exit fullscreen mode

On proceed, call NHTSA once, then completeAttempt. On replay, return the stored body without touching upstream. On inflight, return 409 or a typed "decode already in progress" so the client waits instead of opening a second socket.

Client retry loop that keeps the key

export async function decodeWithClientKey(
  attempt: DecodeAttempt,
  post: (init: RequestInit) => Promise<Response>,
  maxRetries = 2,
): Promise<Response> {
  if (!isAttemptFresh(attempt)) {
    throw new Error("idempotency attempt expired; mint a new key");
  }
  let last: Response | undefined;
  for (let i = 0; i <= maxRetries; i++) {
    last = await post({
      method: "POST",
      headers: headerForAttempt(attempt),
      body: JSON.stringify({ vin: attempt.vinNormalized }),
    });
    if (last.status === 409) {
      await new Promise((r) => setTimeout(r, 200 * (i + 1)));
      continue;
    }
    if (last.ok || last.status < 500) return last;
  }
  return last!;
}
Enter fullscreen mode Exit fullscreen mode

Critical rule: retries must not call mintDecodeAttempt again. Minting on each retry is how you double-charge quota while believing you are being careful.

Quota accounting stays honest

Count an upstream charge only when DecodeVinValues actually leaves your process. Replays from gateUpstream and 409-inflight waits must not increment NHTSA counters or "decodes today" meters. If the dashboard shows three upstream calls for one client key, the gate is wrong or the retry loop is minting keys. Keep server TTL short (30–90s) so deliberate refreshes stay real charges.

Forbidden patterns

  1. Deriving the only key from VIN alone (collides intentional refreshes; also skips cache-bust when you need one)
  2. Minting a new UUID on every retry tick
  3. Persisting keys forever in localStorage so next week's visit replays last week's body
  4. Treating a client key as proof the VIN is valid -- still validate length and check digit locally
  5. Counting local validation failures as upstream quota events

Client keys protect transport and double-submit windows. They do not replace honest field display, circuit breakers, or long-term VIN result caches.

Quick checks

import assert from "node:assert/strict";

const a = mintDecodeAttempt("1HGCM82633A004352");
const b = mintDecodeAttempt("1HGCM82633A004352");
assert.notEqual(a.idempotencyKey, b.idempotencyKey);

assert.equal(gateUpstream(a.idempotencyKey).kind, "proceed");
assert.equal(gateUpstream(a.idempotencyKey).kind, "inflight");
completeAttempt(a.idempotencyKey, { make: "HONDA" });
const replay = gateUpstream(a.idempotencyKey);
assert.equal(replay.kind, "replay");

const headers = headerForAttempt(a);
assert.equal(
  (headers as Record<string, string>)["Idempotency-Key"],
  a.idempotencyKey,
);
Enter fullscreen mode Exit fullscreen mode

Review rule: retry helpers must accept an existing attempt object; they must not mint inside the loop.

Takeaway

Client idempotency keys stop retry storms and double-clicks from burning NHTSA quota twice for one decode intent. Mint once per tap, send the same header on every retry, gate upstream before DecodeVinValues, and expire keys quickly so deliberate refreshes remain real. Handler idempotency and VIN caches remain useful -- they solve different windows. Quota honesty starts at the client key.

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

Top comments (0)