Dev
Probing a Half-Open Circuit Breaker Before Restoring Full VIN Decode Traffic
Dev.toUnited States · NORTH AMERICA
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 whe...
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:
- After
cooldownMs, transition open → half_open and reset the probe counter. - Allow at most
halfOpenMaxProbesin-flight (start with 1). - Extra submits while probing see
circuit_half_open(or reusecircuit_openwith a distinct UX string) -- not a second upstream call. - A probe that finishes inside budget and returns a typed success →
recordProbeSuccess→ closed. - 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_capto "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.