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.

