Fetching latest headlines…

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...

1 views0 likes0 comments

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.

Comments (0)

Sign in to join the discussion

Be the first to comment!