DEV Community

Vin Lookup
Vin Lookup

Posted on

Token-Bucketing VIN Decode Clients So Bursts Do Not Exhaust NHTSA Quotas

A free VIN decode edge that lets every paste, refresh, and parallel tab fire DecodeVinValues immediately will burn through shared NHTSA courtesy quotas in a short burst -- even when each request is valid. Debounce collapses mid-typing keystrokes before a call starts. Load-shed drops optional enrichment when latency spikes. Retry jitter scatters wake-ups after a transient failure. This post is narrower: token-bucket the admitted decode rate so sustained and burst traffic stay inside a budget you chose.

The goal is honest rate control: admit up to a steady refill rate, allow a capped burst when tokens remain, and refuse excess without inventing cached catalog rows to fake a successful decode.

Why a token bucket (not only debounce)

Debounce answers "wait until typing pauses." It does not limit how many complete 17-character VINs a power user or multi-tab session can submit per second after each pause. Load-shed skips enrichment when p95 is high but still allows primary decodes to stampede. A token bucket asks: how many DecodeVinValues calls may leave this process in a window?

Typical pressure: paste-heavy tabs, warm serverless instances that each assume the full public quota, and cron warmers sharing one upstream identity. Without a local budget those paths compete until NHTSA returns 429.

Model: capacity, refill, tryTake

A classic bucket holds up to capacity tokens and refills at refillPerSecond. Each admitted decode costs one token. If the bucket is empty, refuse or wait -- do not silently invent a card.

export type TokenBucket = {
  capacity: number;
  tokens: number;
  refillPerSecond: number;
  updatedAtMs: number;
};

export function createTokenBucket(opts: {
  capacity: number;
  refillPerSecond: number;
  nowMs?: number;
}): TokenBucket {
  const now = opts.nowMs ?? Date.now();
  return {
    capacity: opts.capacity,
    tokens: opts.capacity,
    refillPerSecond: opts.refillPerSecond,
    updatedAtMs: now,
  };
}

export function refill(bucket: TokenBucket, nowMs: number): void {
  const elapsedSec = Math.max(0, (nowMs - bucket.updatedAtMs) / 1000);
  bucket.tokens = Math.min(
    bucket.capacity,
    bucket.tokens + elapsedSec * bucket.refillPerSecond,
  );
  bucket.updatedAtMs = nowMs;
}

export type TakeResult =
  | { ok: true; remaining: number }
  | { ok: false; retryAfterMs: number; remaining: number };

export function tryTake(
  bucket: TokenBucket,
  nowMs: number,
  cost = 1,
): TakeResult {
  refill(bucket, nowMs);
  if (bucket.tokens >= cost) {
    bucket.tokens -= cost;
    return { ok: true, remaining: bucket.tokens };
  }
  const need = cost - bucket.tokens;
  const retryAfterMs = Math.ceil(
    (need / bucket.refillPerSecond) * 1000,
  );
  return { ok: false, retryAfterMs, remaining: bucket.tokens };
}
Enter fullscreen mode Exit fullscreen mode

capacity is the burst you tolerate. refillPerSecond is the sustained rate you will send toward NHTSA from this client identity. Tune both from observed courtesy limits and your multi-tenant share -- not from assuming unlimited public quota.

Wire the bucket before fetch

export type DecodeAdmit =
  | { admitted: true; remaining: number }
  | {
      admitted: false;
      retryAfterMs: number;
      reason: "token_bucket_empty";
    };

export function admitDecode(
  bucket: TokenBucket,
  nowMs: number,
): DecodeAdmit {
  const take = tryTake(bucket, nowMs, 1);
  if (take.ok) {
    return { admitted: true, remaining: take.remaining };
  }
  return {
    admitted: false,
    retryAfterMs: take.retryAfterMs,
    reason: "token_bucket_empty",
  };
}

export async function decodeWithTokenBucket(opts: {
  vin: string;
  bucket: TokenBucket;
  nowMs: number;
  fetchDecode: (vin: string) => Promise<Record<string, string>>;
}): Promise<
  | { status: "ok"; fields: Record<string, string>; remaining: number }
  | {
      status: "rate_limited";
      retryAfterMs: number;
      fields: null;
    }
> {
  const admit = admitDecode(opts.bucket, opts.nowMs);
  if (!admit.admitted) {
    return {
      status: "rate_limited",
      retryAfterMs: admit.retryAfterMs,
      fields: null,
    };
  }
  const fields = await opts.fetchDecode(opts.vin);
  return { status: "ok", fields, remaining: admit.remaining };
}
Enter fullscreen mode Exit fullscreen mode

When rate_limited, return a clear retry hint. Do not refill the UI from a sibling VIN cache and pretend the limited call succeeded.

Forbidden upgrades

Product pressure often asks for:

  1. Serving Make/Model folklore when the bucket is empty so the form "still works"
  2. Sharing one oversized bucket across all tenants so one scraper starves everyone
  3. Setting capacity to Infinity "until we see 429" (you will)
  4. Collapsing debounce, load-shed, and token-bucket into one vague "slow down" flag

Refuse those. Debounce delays start-of-typing. Load-shed drops enrichment. Token-bucket limits admitted primary calls. Keep the tools separate.

export function rateLimitLines(result: {
  status: string;
  retryAfterMs?: number;
}): string[] {
  if (result.status === "rate_limited") {
    return [
      "Decode rate limited -- local token bucket empty",
      `Retry after roughly ${result.retryAfterMs ?? 0} ms`,
    ];
  }
  return ["Decode admitted under token-bucket budget"];
}

export function assertNoTokenBucketInvent(moduleSource: string): void {
  const banned = [
    /typical for this model/i,
    /served from sibling vin/i,
    /quota.?unlimited/i,
    /fake.?success.?while.?limited/i,
  ];
  for (const re of banned) {
    if (re.test(moduleSource)) {
      throw new Error(
        `Token-bucket module must not invent decode results: ${re}`,
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

UI copy that stays honest

Prefer:

  • "Too many decodes right now -- try again in a moment"
  • "Rate limited locally to protect NHTSA quota"

Avoid:

  • Grey cards filled with "usual for this model year" while limited
  • Silent success toasts when tryTake failed

Quick checks

import assert from "node:assert/strict";

const b = createTokenBucket({
  capacity: 2,
  refillPerSecond: 1,
  nowMs: 0,
});
assert.equal(tryTake(b, 0).ok, true);
assert.equal(tryTake(b, 0).ok, true);
const denied = tryTake(b, 0);
assert.equal(denied.ok, false);
if (!denied.ok) {
  assert.ok(denied.retryAfterMs > 0);
}

refill(b, 1000);
assert.equal(tryTake(b, 1000).ok, true);

const limited = await decodeWithTokenBucket({
  vin: "1HGCM82633A004352",
  bucket: createTokenBucket({ capacity: 0, refillPerSecond: 1, nowMs: 0 }),
  nowMs: 0,
  fetchDecode: async () => ({ Make: "HONDA" }),
});
assert.equal(limited.status, "rate_limited");
assert.equal(limited.fields, null);
assert.ok(
  rateLimitLines(limited).some((l) => /token bucket empty/i.test(l)),
);
assert.ok(
  !rateLimitLines(limited).some((l) =>
    /typical for this model|sibling vin/i.test(l),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Review rule: token-bucket modules must not invent catalog fields on refusal, and must stay distinct from debounce and load-shed.

Takeaway

Token-bucket VIN decode clients so bursts cannot exhaust the NHTSA-facing budget you share. Cap capacity, refill steadily, refuse cleanly when empty, and keep debounce, load-shed, and retry jitter as separate tools. Your free VIN API stays kind to upstream when admitted calls match a real budget -- without inventing decode results over a dry bucket.

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

Top comments (0)