Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Negatively Caching Invalid VIN Responses So Bad Digits Do Not Hammer NHTSA

A free VIN decode that always forwards every paste to live NHTSA DecodeVinValues pays upstream cost when users typo a check digit, paste seventeen zeros, or retry the same invalid string. Positive ETag caches and make-pa

A free VIN decode that always forwards every paste to live NHTSA DecodeVinValues pays upstream cost when users typo a check digit, paste seventeen zeros, or retry the same invalid string. Positive ETag caches and make-pattern warmup (covered elsewhere) store successful bodies. This post is different: a negative cache for VINs you already know are invalid or that vPIC already refused, so bad digits do not hammer NHTSA on every retry.

The goal is honest short-circuit: remember "this normalized VIN is not worth another upstream call for a while," show a clear invalid/error state, and never invent a successful decode card from a negative entry.

Negative cache vs positive caches

Use a negative cache when:

  • Client-side checks (length, charset, check digit) already fail before HTTP
  • Upstream returned a clear "no decode / error / empty Results" for that VIN
  • The same bad paste repeats within a short TTL (fat-finger retries, bot loops)

Do not use negative cache to store successful DecodeVinValues bodies (ETag / warmup territory), invent folklore Make/Model/Year, suppress live calls forever (bound TTL), or treat 429/outages as permanent "invalid VIN."

When the entry expires or the failure was transient (5xx, timeout), allow a live retry. Client-invalid shapes can use a longer TTL than soft upstream empties.

Classify before you store

Separate client-invalid (never call upstream) from upstream-negative (you called, got a clear no-decode). Both can live in one map keyed by normalized VIN, with different reasons and TTLs.

export type NegReason =
  | "client-length"
  | "client-charset"
  | "client-check-digit"
  | "upstream-empty"
  | "upstream-error";

export type NegEntry = {
  vinNormalized: string;
  reason: NegReason;
  storedAt: number; // epoch ms
  ttlMs: number;
  message: string; // user-safe, no invented specs
};

export type NegStore = Map<string, NegEntry>;

const CLIENT_TTL_MS = 60 * 60 * 1000; // 1h for obvious typos
const UPSTREAM_TTL_MS = 15 * 60 * 1000; // 15m for empty/error bodies

export function normalizeVin(raw: string): string {
  return raw.trim().toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
}

export function clientInvalidReason(vin: string): NegReason | null {
  if (vin.length !== 17) return "client-length";
  if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(vin)) return "client-charset";
  // Check-digit algorithm omitted for brevity; return "client-check-digit" on fail
  return null;
}

export function putNegative(
  store: NegStore,
  vinNormalized: string,
  reason: NegReason,
  message: string,
  nowMs = Date.now(),
): NegEntry {
  const ttlMs =
    reason.startsWith("client-") ? CLIENT_TTL_MS : UPSTREAM_TTL_MS;
  const entry: NegEntry = {
    vinNormalized,
    reason,
    storedAt: nowMs,
    ttlMs,
    message,
  };
  store.set(vinNormalized, entry);
  return entry;
}

export function getNegative(
  store: NegStore,
  vinNormalized: string,
  nowMs = Date.now(),
): NegEntry | null {
  const hit = store.get(vinNormalized);
  if (!hit) return null;
  if (nowMs - hit.storedAt >= hit.ttlMs) {
    store.delete(vinNormalized);
    return null;
  }
  return hit;
}

Never put a successful body into this map. Never upgrade a negative hit into Make/Model/Year rows.

Decode path with short-circuit

export type DecodeOutcome =
  | { kind: "negative"; entry: NegEntry }
  | { kind: "live"; body: Record<string, string | null> }
  | { kind: "transient-error"; status: number };

export async function decodeWithNegCache(
  store: NegStore,
  rawVin: string,
  fetchLive: (vin: string) => Promise<{
    ok: boolean;
    status: number;
    body: Record<string, string | null> | null;
    empty: boolean;
  }>,
  nowMs = Date.now(),
): Promise<DecodeOutcome> {
  const vin = normalizeVin(rawVin);
  const cached = getNegative(store, vin, nowMs);
  if (cached) return { kind: "negative", entry: cached };

  const clientReason = clientInvalidReason(vin);
  if (clientReason) {
    const entry = putNegative(
      store,
      vin,
      clientReason,
      "VIN failed local validation -- not sent to NHTSA",
      nowMs,
    );
    return { kind: "negative", entry };
  }

  const live = await fetchLive(vin);
  if (!live.ok && live.status >= 500) {
    return { kind: "transient-error", status: live.status };
  }
  if (!live.ok || live.empty || !live.body) {
    const entry = putNegative(
      store,
      vin,
      live.ok ? "upstream-empty" : "upstream-error",
      "No decode available from vPIC for this VIN",
      nowMs,
    );
    return { kind: "negative", entry };
  }
  return { kind: "live", body: live.body };
}

Transient 5xx stays out of the negative store (or uses a very short TTL if you must). That keeps outages from branding a good VIN as permanently invalid.

Forbidden upgrades

Product pressure often asks for:

  1. Serving a fake "success" card from a negative hit so the UI looks complete
  2. Infinite TTL so one empty response never rechecks after catalog updates
  3. Collapsing rate-limit 429 into "invalid VIN" negatives
  4. Mixing negative keys with positive ETag bodies in one undifferentiated blob

Refuse those. Negative cache is a courtesy throttle and UX shortcut -- not a second invent-specs path.

export function assertNoNegInvent(moduleSource: string): void {
  const banned = [
    /invent.*(make|model|year)/i,
    /negative.*success.?card/i,
    /forever.?invalid/i,
    /treat.?429.?as.?invalid/i,
  ];
  for (const re of banned) {
    if (re.test(moduleSource)) {
      throw new Error(`Negative cache must not invent decode cards: ${re}`);
    }
  }
}

export function uiMessage(entry: NegEntry): string {
  return entry.message;
}

UI copy that stays honest

Prefer:

  • "VIN failed local validation -- not sent to NHTSA"
  • "No decode available from vPIC for this VIN (cached briefly)"
  • "Temporary upstream error -- try again shortly" for transient failures

Avoid Make/Model/Year from memory on negative hits, "invalid forever" without TTL disclosure, and silent success styling on a negative entry.

Quick checks

import assert from "node:assert/strict";

const store: NegStore = new Map();
const short = normalizeVin("123");
assert.equal(clientInvalidReason(short), "client-length");

const neg = putNegative(
  store,
  short,
  "client-length",
  "VIN failed local validation -- not sent to NHTSA",
  1_000,
);
assert.equal(getNegative(store, short, 1_000)?.reason, "client-length");
assert.equal(getNegative(store, short, 1_000 + CLIENT_TTL_MS)?.reason, undefined);

assert.equal(uiMessage(neg).includes("Make"), false);
assert.ok(!/standard|verified|package/i.test(uiMessage(neg)));

Review rule: negative-cache modules must not invent successful decode fields. Bound TTL; separate transient errors from invalid VINs.

Takeaway

A negative cache stops bad digits and known empty upstream results from hammering NHTSA on every retry. Store reason + TTL, short-circuit with honest UI copy, and leave successful bodies to ETag and warmup paths. Invalid pastes stay remembered briefly -- never upgraded into invented catalog rows.

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

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.