DEV Community

Vin Lookup
Vin Lookup

Posted on

Circuit-Breaking NHTSA Calls So One Slow Upstream Does Not Stall Every VIN Form

Error-rate breakers trip when NHTSA is failing hard. A quieter failure mode is worse for forms: the upstream is merely slow. One hung DecodeVinValues call holds a shared client, saturates your concurrency budget, and every other VIN submit waits behind a spinner that never resolves. Retries with longer timeouts make the pile deeper.

This post is about latency-aware circuit breaking for free VIN decode: treat sustained slowness as a trip condition, fail fast for concurrent forms while open, and keep one slow call from stalling the whole UI surface. Classic consecutive-failure breakers are covered elsewhere; here the focus is slow upstream stalls every form.

Slow is not the same as down

Signal Typical user experience Breaker role
Hard 5xx / network error Immediate error card Count as failure
Sparse 200 with Make/Model Partial but usable card Success (not a trip)
Hang past budget Spinner forever; other forms blocked Count as slow failure
Burst of 8s+ latencies Queue of submits backs up Open on rate of slow calls

A form that shares one decode client (module singleton, server action pool, or browser fetch queue) is especially fragile. Without a breaker, the first hung request occupies the only in-flight slot; the second submit waits; the third looks "broken" even though validation is fine.

What should count as slow failure

Define an explicit per-call budget (for example 6–8s wall time). When the call exceeds the budget -- via AbortSignal.timeout, a racing Promise, or your HTTP client's deadline -- record a slow failure for the breaker, abort the in-flight work, and return a typed UPSTREAM_SLOW result.

Count toward the trip:

  • Timeouts and deadline aborts
  • Network errors that leave the request unresolved
  • HTTP 502 / 503 / 504 after a long wait (still a failure; also slow)

Do not count:

  • Local validation rejects (never called upstream)
  • Fast 200s with empty ABS / Make gaps (honest sparse success)
  • User-aborted navigations when you already cancelled cleanly

Mixing validation noise into slow-failure counts opens the circuit on bad paste days and blocks good VINs for no reason.

TypeScript sketch: latency trips + form gate

type BreakerState = "closed" | "open" | "half_open";

export type LatencyBreakerConfig = {
  slowThresholdMs: number; // e.g. 7000
  failureThreshold: number; // e.g. 3 consecutive slow/hard failures
  cooldownMs: number; // e.g. 20_000
  halfOpenMaxProbes: number; // e.g. 1
};

export class LatencyCircuitBreaker {
  private state: BreakerState = "closed";
  private consecutiveFailures = 0;
  private openedAt = 0;
  private halfOpenProbes = 0;

  constructor(private readonly cfg: LatencyBreakerConfig) {}

  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;
    }
    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 type DecodeResult<T> =
  | { ok: true; data: T }
  | { ok: false; reason: "circuit_open" | "upstream_slow" | "upstream" };

export async function decodeWithLatencyBreaker<T>(
  breaker: LatencyCircuitBreaker,
  call: (signal: AbortSignal) => Promise<T>,
  cfg: { slowThresholdMs: number },
): Promise<DecodeResult<T>> {
  if (!breaker.canRequest()) {
    return { ok: false, reason: "circuit_open" };
  }
  breaker.beforeRequest();
  const controller = new AbortController();
  const timer = setTimeout(
    () => controller.abort(),
    cfg.slowThresholdMs,
  );
  const started = Date.now();
  try {
    const data = await call(controller.signal);
    clearTimeout(timer);
    breaker.recordSuccess();
    return { ok: true, data };
  } catch (err) {
    clearTimeout(timer);
    breaker.recordFailure();
    const timedOut =
      controller.signal.aborted &&
      Date.now() - started >= cfg.slowThresholdMs - 50;
    return {
      ok: false,
      reason: timedOut ? "upstream_slow" : "upstream",
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Gate every form submit on canRequest() before disabling inputs or starting a spinner. When the circuit is open, return immediately with circuit_open so sibling forms stay interactive.

UX that does not stall the form

Prefer:

  • Instant "decode temporarily unavailable -- upstream is slow" when open
  • Disable only the submit that would call NHTSA; keep paste/clear/help controls alive
  • Optional stale last-good decode for that VIN, labeled "cached earlier; live refresh paused"

Avoid:

  • A global page-level spinner held by one hung fetch
  • Mapping circuit_open or upstream_slow to "invalid VIN"
  • Silent retries that re-queue the same hung call behind the open circuit

Keep reason codes separate (CIRCUIT_OPEN vs UPSTREAM_SLOW vs VIN_INVALID) so support and status copy stay accurate.

Quick checks

import assert from "node:assert/strict";

const breaker = new LatencyCircuitBreaker({
  slowThresholdMs: 50,
  failureThreshold: 2,
  cooldownMs: 60_000,
  halfOpenMaxProbes: 1,
});

async function hang(_signal: AbortSignal): Promise<never> {
  await new Promise(() => {});
  throw new Error("unreachable");
}

const a = await decodeWithLatencyBreaker(breaker, hang, {
  slowThresholdMs: 50,
});
assert.equal(a.ok, false);
if (!a.ok) assert.equal(a.reason, "upstream_slow");

const b = await decodeWithLatencyBreaker(breaker, hang, {
  slowThresholdMs: 50,
});
assert.equal(b.ok, false);

assert.equal(breaker.canRequest(), false);
const blocked = await decodeWithLatencyBreaker(breaker, hang, {
  slowThresholdMs: 50,
});
assert.deepEqual(blocked, { ok: false, reason: "circuit_open" });
Enter fullscreen mode Exit fullscreen mode

Review rule: form submit handlers must short-circuit on open before awaiting upstream. Never let one slow call own the only concurrency slot without a budget.

Takeaway

One slow NHTSA call should not freeze every VIN form. Budget each decode, count timeouts as failures, open the circuit before the queue piles up, and fail fast with an honest "temporarily unavailable" state. Layer latency breakers with validation and single-flight, and free VIN decode stays responsive when the public API merely crawls instead of crashing.

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

Top comments (0)