Retries with jitter help a single flaky request. They do not help when NHTSA vPIC is systematically unhealthy. If every decode retries three times, your free VIN tool multiplies traffic into an outage and turns a five-minute upstream blip into a self-inflicted overload. A circuit breaker stops calling the dependency for a cooldown when failure rates cross a threshold, then probes carefully before returning to normal traffic.
This post is a practical TypeScript circuit breaker around DecodeVinValues: states, what counts as failure, UX while open, and how it differs from rate-limit handling and negative caches.
Retry is not a breaker
| Pattern | Question it answers |
|---|---|
| Validate locally | Is this VIN even eligible to send? |
| Single-flight | Are duplicate in-flight lookups coalesced? |
| Retry + jitter | Should this one transient failure try again? |
| Circuit breaker | Should we stop calling upstream for everyone right now? |
| Negative cache | Should we remember "no match" for this VIN briefly? |
Use all of them in layers. The breaker sits outside per-request retries: open the circuit on aggregated failure rate or consecutive failures, not on the first timeout.
States that match product reality
Classic three states work well for public VIN decode:
- Closed (normal): requests flow to NHTSA. Track successes and failures in a short window.
- Open (tripped): do not call NHTSA. Fail fast with a stable "decode temporarily unavailable" result. Start a cooldown timer.
- Half-open (probe): allow a small number of trial requests. If they succeed, close. If they fail, open again.
For a free consumer tool, prefer failing fast while open over queueing users behind a pile of retries. Honest degradation beats a spinner that lies.
What should trip the breaker
Count as failure for breaker math:
- Network errors and hard timeouts
- HTTP 502 / 503 / 504
- HTTP 429 when it arrives in bursts (treat sustained 429 as upstream pressure)
- Response bodies that are unparseable when you normally expect JSON
Do not count as breaker failures:
- Local validation rejects (length, charset, check digit)
- HTTP 200 with sparse Make/Model fields (partial success)
- Clear "no data" decode rows for a weird VIN (business miss, not outage)
Mixing validation noise into the failure rate trips the breaker during a bad OCR day and blocks good VINs for no reason.
TypeScript sketch
type BreakerState = "closed" | "open" | "half_open";
export type BreakerConfig = {
failureThreshold: number; // e.g. 5 consecutive, or use rate below
cooldownMs: number; // e.g. 30_000
halfOpenMaxProbes: number; // e.g. 2
};
export class NhtsaCircuitBreaker {
private state: BreakerState = "closed";
private consecutiveFailures = 0;
private openedAt = 0;
private halfOpenProbes = 0;
constructor(private readonly cfg: BreakerConfig) {}
canRequest(): boolean {
if (this.state === "closed") return true;
if (this.state === "open") {
if (Date.now() - this.openedAt >= this.cfg.cooldownMs) {
this.state = "half_open";
this.halfOpenProbes = 0;
return true;
}
return false;
}
// half_open
return this.halfOpenProbes < this.cfg.halfOpenMaxProbes;
}
beforeRequest(): void {
if (this.state === "half_open") this.halfOpenProbes += 1;
}
recordSuccess(): void {
this.consecutiveFailures = 0;
this.state = "closed";
this.halfOpenProbes = 0;
}
recordFailure(): void {
this.consecutiveFailures += 1;
if (
this.state === "half_open" ||
this.consecutiveFailures >= this.cfg.failureThreshold
) {
this.state = "open";
this.openedAt = Date.now();
this.halfOpenProbes = 0;
}
}
}
export async function decodeWithBreaker<T>(
breaker: NhtsaCircuitBreaker,
call: () => Promise<T>,
): Promise<{ ok: true; data: T } | { ok: false; reason: "circuit_open" | "upstream" }> {
if (!breaker.canRequest()) return { ok: false, reason: "circuit_open" };
breaker.beforeRequest();
try {
const data = await call();
breaker.recordSuccess();
return { ok: true, data };
} catch {
breaker.recordFailure();
return { ok: false, reason: "upstream" };
}
}
Tune failureThreshold and cooldownMs from your traffic. A low-volume hobby tool can use consecutive failures. A busier API should prefer a sliding window failure rate so one user cannot trip the world alone - combine with per-VIN single-flight so duplicates do not inflate counts.
UX while the circuit is open
Show a distinct state, not a fake empty vehicle card:
- Title: "Vehicle decode is temporarily unavailable"
- Body: "The upstream NHTSA service is not responding normally. Try again in a minute."
- Optional: serve stale last-good decode for that VIN if you already have a cache, clearly labeled "cached earlier; live refresh paused"
Do not invent Make/Model. Do not map circuit-open to "invalid VIN." Keep error codes separate (CIRCUIT_OPEN vs VIN_INVALID vs UPSTREAM_5XX) so support and GEO-facing status pages stay accurate.
Observability
Export metrics:
-
breaker_state(gauge/enum) breaker_open_totaldecode_requests_short_circuited- failure rate in the closed window
Alert on time spent open, not on every upstream 503. Pair with rate-limit UX: a breaker protects NHTSA from your retries; client rate limits protect you from abusive paste floods.
Product rules
- Breaker wraps upstream calls only; validation stays always-on.
- Open = fail fast + optional stale read; never fabricate specs.
- Half-open probes are few and counted.
- Do not trip on sparse 200s or local rejects.
- Document the dependency: manufacturer attributes come from NHTSA; downtime is possible.
Takeaway
When NHTSA error rates spike, more retries make you part of the outage. A small circuit breaker fails fast, cools down, probes, and returns - while your UI tells the truth about temporary unavailability. Layer it with validation, single-flight, and bounded retries, and free VIN decode stays kind to both users and the public API.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)