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 };
}
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 };
}
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:
- Serving Make/Model folklore when the bucket is empty so the form "still works"
- Sharing one oversized bucket across all tenants so one scraper starves everyone
- Setting capacity to Infinity "until we see 429" (you will)
- 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}`,
);
}
}
}
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
tryTakefailed
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),
),
);
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)