A free VIN decode that retries NHTSA DecodeVinValues after a 429 already needs jittered backoff and a client token bucket. Those are local policies. Upstream often also returns rate-limit quota headers that tell you how many calls remain and when the window resets. Ignoring them and retrying on your own schedule burns the shared courtesy quota and turns a soft throttle into a hard block.
This post is narrower than Retry-After-only parsers and narrower than client token buckets: read RateLimit-* / X-RateLimit-* (and Retry-After when present), decide whether a retry is still allowed, and wait until the reset window -- without inventing a successful decode card while you wait.
Why quota headers beat guessing
Client token buckets answer "how many calls may leave this process?" Jittered backoff answers "when peers fail together, scatter wake-ups." Neither reads what the server said about remaining budget.
Common headers on public APIs and reverse proxies:
-
RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset(IETF draft style) -
X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Reset(legacy) -
Retry-Afteron 429 / 503 (seconds or HTTP-date)
If Remaining is 0 and Reset is thirty seconds out, sleeping 200ms of jitter and firing again is rude. Prefer the upstream clock.
Parse remaining and reset first
Normalize header names case-insensitively. Prefer IETF RateLimit-* when both families appear. Treat missing headers as "unknown," not as infinite quota.
export type QuotaSnapshot = {
limit: number | null;
remaining: number | null;
resetAtMs: number | null;
retryAfterMs: number | null;
source: "ratelimit" | "x-ratelimit" | "retry-after-only" | "none";
};
function header(
headers: Headers,
name: string,
): string | null {
return headers.get(name);
}
function parseIntHeader(raw: string | null): number | null {
if (raw == null || !/^\d+$/.test(raw.trim())) return null;
const n = Number(raw.trim());
return Number.isFinite(n) ? n : null;
}
function parseReset(
raw: string | null,
nowMs: number,
): number | null {
if (raw == null) return null;
const t = raw.trim();
if (/^\d+$/.test(t)) {
const n = Number(t);
// Unix seconds if large; else delta seconds
if (n > 1_000_000_000) return n * 1000;
return nowMs + n * 1000;
}
const when = Date.parse(t);
return Number.isNaN(when) ? null : when;
}
export function readQuotaHeaders(
headers: Headers,
nowMs: number = Date.now(),
): QuotaSnapshot {
const ietfLimit = parseIntHeader(header(headers, "RateLimit-Limit"));
const ietfRem = parseIntHeader(header(headers, "RateLimit-Remaining"));
const ietfReset = parseReset(header(headers, "RateLimit-Reset"), nowMs);
const xLimit = parseIntHeader(header(headers, "X-RateLimit-Limit"));
const xRem = parseIntHeader(header(headers, "X-RateLimit-Remaining"));
const xReset = parseReset(header(headers, "X-RateLimit-Reset"), nowMs);
const retryRaw = header(headers, "Retry-After");
let retryAfterMs: number | null = null;
if (retryRaw) {
const t = retryRaw.trim();
if (/^\d+$/.test(t)) retryAfterMs = Number(t) * 1000;
else {
const when = Date.parse(t);
if (!Number.isNaN(when)) retryAfterMs = Math.max(0, when - nowMs);
}
}
if (ietfRem != null || ietfReset != null || ietfLimit != null) {
return {
limit: ietfLimit,
remaining: ietfRem,
resetAtMs: ietfReset,
retryAfterMs,
source: "ratelimit",
};
}
if (xRem != null || xReset != null || xLimit != null) {
return {
limit: xLimit,
remaining: xRem,
resetAtMs: xReset,
retryAfterMs,
source: "x-ratelimit",
};
}
if (retryAfterMs != null) {
return {
limit: null,
remaining: null,
resetAtMs: null,
retryAfterMs,
source: "retry-after-only",
};
}
return {
limit: null,
remaining: null,
resetAtMs: null,
retryAfterMs: null,
source: "none",
};
}
Gate retries on remaining, not only status
A 200 with RateLimit-Remaining: 0 is a hint to pause before the next paste. A 429 with Remaining still positive may be a proxy quirk -- still honor Retry-After when present.
const MAX_WAIT_MS = 60_000;
const MIN_WAIT_MS = 250;
export type RetryPlan =
| { action: "proceed" }
| { action: "wait"; waitMs: number; reason: string }
| { action: "refuse"; reason: string };
export function planDecodeRetry(
status: number,
quota: QuotaSnapshot,
nowMs: number = Date.now(),
): RetryPlan {
if (status === 200 && (quota.remaining == null || quota.remaining > 0)) {
return { action: "proceed" };
}
const waits: number[] = [];
if (quota.retryAfterMs != null) waits.push(quota.retryAfterMs);
if (quota.resetAtMs != null) waits.push(Math.max(0, quota.resetAtMs - nowMs));
if (quota.remaining === 0 && waits.length === 0) {
return {
action: "refuse",
reason: "rate-limit remaining is 0 without a reset hint",
};
}
if (status === 429 || status === 503 || quota.remaining === 0) {
if (waits.length === 0) {
return {
action: "refuse",
reason: "throttled without Retry-After or reset",
};
}
const waitMs = Math.min(
MAX_WAIT_MS,
Math.max(MIN_WAIT_MS, Math.max(...waits)),
);
return {
action: "wait",
waitMs,
reason: `upstream quota headers source=${quota.source}`,
};
}
return { action: "proceed" };
}
Do not invent catalog rows while waiting. Surface "NHTSA asked us to wait" or a disabled decode button -- never a fake Make/Model card.
What this is not
- Not a client token bucket (local admit rate before the call leaves)
- Not decorrelated jitter (scatter after a transient failure when headers are absent)
- Not circuit-breaker metrics (open/half-open gauges)
- Not a license to retry sparse 200s with empty ADAS fields
Sparse successful decodes are successes. Quota headers guide when the next call may leave, not whether blank PedestrianAutomaticEmergencyBraking should be re-fetched.
Quick checks
import assert from "node:assert/strict";
const h = new Headers({
"RateLimit-Limit": "100",
"RateLimit-Remaining": "0",
"RateLimit-Reset": "30",
"Retry-After": "30",
});
const q = readQuotaHeaders(h, 1_000_000);
assert.equal(q.source, "ratelimit");
assert.equal(q.remaining, 0);
assert.equal(q.retryAfterMs, 30_000);
const plan = planDecodeRetry(429, q, 1_000_000);
assert.equal(plan.action, "wait");
if (plan.action === "wait") {
assert.ok(plan.waitMs >= 250 && plan.waitMs <= 60_000);
}
const ok = planDecodeRetry(
200,
readQuotaHeaders(new Headers({ "RateLimit-Remaining": "12" })),
);
assert.equal(ok.action, "proceed");
Takeaway
When retrying VIN decodes against NHTSA, honor upstream rate-limit headers: parse Remaining/Reset/Retry-After, wait for the reset window, and refuse to stampede when Remaining is zero without a hint. Keep local token buckets and jitter for budgets and peer scatter -- they do not replace reading what the server already told you. Your free decode stays polite when quota headers drive the wait and empty cards stay empty until a real response arrives.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)