Latency-aware circuit breakers (covered elsewhere) trip when NHTSA DecodeVinValues stays slow or hard-fails, then fail fast while open. The quiet next risk is how you leave open: flooding upstream when cooldown ends, or flipping straight to closed on the first lucky 200. This post is about the half-open probe phase -- admit a tiny number of trial decodes, score them strictly, and only then restore full VIN decode traffic. Initial open/trip behavior is covered elsewhere; here the focus is probing before you trust the circuit again.

Why half-open is not "open but friendlier"

State Admission Goal
Closed Full interactive + batch (subject to other gates) Normal decode
Open Reject new upstream calls; optional cached last-good Protect NHTSA and your pool
Half-open Cap concurrent probes (often 1) Learn if upstream recovered

Without a probe cap, cooldown expiry turns waiting forms into a thundering herd. Without strict scoring, one lucky 200 or one soft timeout can close the breaker while the next submits hang again.

Probe rules that stay honest

Define half-open explicitly:

  1. After cooldownMs, transition open → half_open and reset the probe counter.
  2. Allow at most halfOpenMaxProbes in-flight (start with 1).
  3. Extra submits while probing see circuit_half_open (or reuse circuit_open with a distinct UX string) -- not a second upstream call.
  4. A probe that finishes inside budget and returns a typed success → recordProbeSuccess → closed.
  5. A probe that times out, 5xxs, or network-fails → recordProbeFailure → open again with a fresh cooldown.

Do not count local validation rejects as probe failures. Do not treat a sparse-but-honest 200 (empty ABS, missing optional fields) as failure -- that is still upstream success for breaker purposes. Do not auto-close because "enough time passed" without a successful probe.

TypeScript sketch: half-open probe gate

type BreakerState = "closed" | "open" | "half_open"; export type HalfOpenConfig = { cooldownMs: number; // e.g. 20_000 halfOpenMaxProbes: number; // e.g. 1 slowThresholdMs: number; // e.g. 7000 }; export class HalfOpenProbeBreaker { private state: BreakerState = "closed"; private openedAt = 0; private halfOpenInFlight = 0; constructor(private readonly cfg: HalfOpenConfig) {} /** Call before starting any DecodeVinValues work. */ tryAdmit(): "allow" | "reject_open" | "reject_probe_cap" { if (this.state === "closed") return "allow"; if (this.state === "open") { if (Date.now() - this.openedAt < this.cfg.cooldownMs) { return "reject_open"; } this.state = "half_open"; this.halfOpenInFlight = 0; } if (this.halfOpenInFlight >= this.cfg.halfOpenMaxProbes) { return "reject_probe_cap"; } this.halfOpenInFlight += 1; return "allow"; } recordProbeSuccess(): void { this.state = "closed"; this.halfOpenInFlight = 0; } recordProbeFailure(): void { this.state = "open"; this.openedAt = Date.now(); this.halfOpenInFlight = 0; } /** Closed-path success/failure helpers omitted -- see open-trip article. */ tripOpen(): void { this.state = "open"; this.openedAt = Date.now(); this.halfOpenInFlight = 0; } } export type ProbeResult<T> = | { ok: true; data: T } | { ok: false; reason: "circuit_open" | "probe_cap" | "upstream_slow" | "upstream"; }; export async function decodeWithHalfOpenProbe<T>( breaker: HalfOpenProbeBreaker, call: (signal: AbortSignal) => Promise<T>, cfg: { slowThresholdMs: number }, ): Promise<ProbeResult<T>> { const admit = breaker.tryAdmit(); if (admit === "reject_open") { return { ok: false, reason: "circuit_open" }; } if (admit === "reject_probe_cap") { return { ok: false, reason: "probe_cap" }; } 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.recordProbeSuccess(); return { ok: true, data }; } catch { clearTimeout(timer); breaker.recordProbeFailure(); const timedOut = controller.signal.aborted && Date.now() - started >= cfg.slowThresholdMs - 50; return { ok: false, reason: timedOut ? "upstream_slow" : "upstream", }; } } 

Gate every submit on tryAdmit() before spinners. When probe_cap rejects, keep the form interactive and say recovery is being tested -- do not queue silent retries that would become a second probe.

What a probe must not do

A half-open probe is still a real DecodeVinValues call with the same per-call budget. Do not lengthen timeouts "just this once" to force a success -- that hides slowness and closes the breaker on a lie. Do not skip VIN normalization, and do not fan out hedges: one probe means one upstream attempt until you reopen or re-trip. Prefer admitting an interactive candidate before batch when both wait for the probe slot.

UX for the probe window

Prefer:

  • "Decode recovering -- testing upstream; try again shortly" for probe_cap
  • One visible probe spinner, not N stacked loads
  • Optional labeled last-good cache while half-open rejects extras

Avoid:

  • Mapping probe_cap to "invalid VIN"
  • Opening all waiting forms the instant cooldown ends
  • Closing the breaker on wall-clock alone without a successful probe
  • Letting batch consume the only probe slot ahead of interactive users

Quick checks

import assert from "node:assert/strict"; const b = new HalfOpenProbeBreaker({ cooldownMs: 1_000, halfOpenMaxProbes: 1, slowThresholdMs: 7_000, }); b.tripOpen(); assert.equal(b.tryAdmit(), "reject_open"); // Simulate cooldown elapsed by tripping then forcing time via reopen path: (b as unknown as { openedAt: number }).openedAt = Date.now() - 2_000; assert.equal(b.tryAdmit(), "allow"); assert.equal(b.tryAdmit(), "reject_probe_cap"); b.recordProbeSuccess(); assert.equal(b.tryAdmit(), "allow"); b.tripOpen(); (b as unknown as { openedAt: number }).openedAt = Date.now() - 2_000; assert.equal(b.tryAdmit(), "allow"); b.recordProbeFailure(); assert.equal(b.tryAdmit(), "reject_open"); 

Review rule: half-open modules must cap probes, distinguish reject reasons, and refuse to restore full traffic without a scored success.

Takeaway

Half-open is a probe phase, not a soft open. Admit a tiny trial budget after cooldown, score timeouts and hard failures strictly, reject excess submits with a clear recovery message, and only then restore full traffic. Keep this separate from the initial trip/open design. Your free decode proxy stays kind to NHTSA when recovery is earned by probes -- not by hope and a herd.

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