Half-Open Recovery for Geofence Circuit Breakers
Opening a circuit breaker is the easy half; closing it cleanly is where breakers go wrong. A breaker that recovers too eagerly floods a just-healed consumer with the full backlog and knocks it straight back down; a breaker that recovers too timidly leaves a healthy consumer cut off for minutes after it recovered. This page sits under circuit breakers for downstream trigger consumers and the broader event routing and backpressure architecture, and it answers the narrow question of how the half-open state should behave: how many probes, at what rate, with what backoff, and what success threshold turns a probe run into a full close. Get this right and a consumer outage recovers in a single clean transition; get it wrong and the breaker flaps, oscillating OPEN and HALF-OPEN for minutes while the consumer never gets a stable chance to recover.
Concept and specification
The half-open state is a controlled test: admit a small number of probe emits to the recovering consumer, and decide from their outcome whether to close (resume full traffic) or re-open (wait longer). Three parameters govern it.
Backoff window. After each trip, the breaker stays OPEN for a window that grows exponentially so a persistently broken consumer is probed less and less often:
for the $n$-th consecutive trip, base window , and ceiling . With and , the windows grow 1, 2, 4, 8, 16, 30, 30… — probing a dead consumer every 30s instead of hammering it every second.
Probe rate. In HALF-OPEN, admit at most a small fraction of normal traffic — a fixed probe count or a low rate. This is the single most important defense against the thundering herd: if HALF-OPEN admitted full traffic, a consumer that recovered with a cold cache would be hit by the entire accumulated backlog at once and re-fail. Cap probes to a handful of in-flight calls.
Success threshold. Close only after $S$ consecutive probe successes, not the first one. A single success can be luck — a request that hit a warm shard while the rest are cold. Requiring $S$ consecutive successes (typically 3–5) confirms the consumer is stably healthy before restoring full load.
The interaction that prevents flapping is jitter on the backoff. If many breakers (across router replicas, or many consumers behind one gateway) trip together, they will also probe together unless the window is jittered, re-synchronizing the herd on every cycle. Add uniform jitter:
with , so probes desynchronize across breakers.
| Parameter | Symbol | Typical | Controls |
|---|---|---|---|
| Base open window | 1 s | first retry delay | |
| Max open window | 30 s | probe floor for dead consumers | |
| Backoff factor | — | 2x | how fast probing slows |
| Jitter fraction | $j$ | 0.2 | herd desynchronization |
| Probe concurrency | — | 1–3 | thundering-herd defense |
| Successes to close | $S$ | 5 | confirmation of stable health |
The recovery discipline composes with the trip discipline from the parent circuit breaker page: the same latency-primary signal that trips the breaker also judges the probes, so a probe that succeeds but slowly (P99 still above the trip threshold) counts as a failure, not a success. Recovering to “responds but slow” is not recovery.
Step-by-step implementation
Prerequisites: Python 3.11+, standard library (time.monotonic, random for jitter, asyncio for probe concurrency). This extends the breaker from the parent page with a probe-aware half-open state.
1. Grow the open window with jittered exponential backoff on every trip.
from __future__ import annotations
import random
import time
from dataclasses import dataclass, field
@dataclass(slots=True)
class HalfOpenPolicy:
base_s: float = 1.0
max_s: float = 30.0
jitter: float = 0.2 # +/- 20% to desynchronize herds
successes_to_close: int = 5
probe_concurrency: int = 2 # cap in-flight probes — herd defense
_consecutive_trips: int = 0
def next_open_window(self) -> float:
# Exponential growth, capped, then jittered so replicas do not re-sync.
raw = min(self.max_s, self.base_s * (2 ** self._consecutive_trips))
self._consecutive_trips += 1
return raw * (1.0 + random.uniform(-self.jitter, self.jitter))
def reset(self) -> None:
self._consecutive_trips = 0 # clean close resets the backoff ladder
2. Gate probe admission by a concurrency cap, not a rate. A fixed small number of in-flight probes is the simplest thundering-herd defense and needs no timer.
import asyncio
@dataclass(slots=True)
class ProbeGate:
limit: int
_inflight: int = 0
def try_acquire(self) -> bool:
if self._inflight >= self.limit:
return False # too many probes already testing
self._inflight += 1
return True
def release(self) -> None:
self._inflight = max(0, self._inflight - 1)
3. Judge each probe on latency, not just success, and require consecutive wins.
@dataclass(slots=True)
class HalfOpenState:
policy: HalfOpenPolicy
p99_trip_ms: float = 50.0
_successes: int = 0
def on_probe_result(self, latency_ms: float, ok: bool) -> str:
# A slow success is a failure: "responds but slow" is not recovered.
healthy = ok and latency_ms <= self.p99_trip_ms
if not healthy:
self._successes = 0
return "reopen" # re-open with a longer, jittered window
self._successes += 1
if self._successes >= self.policy.successes_to_close:
self._successes = 0
self.policy.reset() # clean recovery: reset backoff
return "close"
return "probe" # keep probing
Gotcha: reset the backoff ladder (
_consecutive_trips) only on a clean close, never on an individual probe success. Resetting on the first success lets a flapping consumer keep the window short forever, defeating the exponential backoff and re-enabling the herd. The window only shrinks back to base once the consumer proves stably healthy.
4. Drive probes with bounded concurrency.
async def run_probe(gate: ProbeGate, state: HalfOpenState, send, fallback,
trigger: object) -> str:
if not gate.try_acquire():
await fallback(trigger) # over probe cap: still divert
return "probe"
start = time.perf_counter()
try:
await asyncio.wait_for(send(trigger), timeout=0.2)
return state.on_probe_result((time.perf_counter() - start) * 1000.0, ok=True)
except (asyncio.TimeoutError, Exception): # noqa: BLE001
return state.on_probe_result((time.perf_counter() - start) * 1000.0, ok=False)
finally:
gate.release()
Benchmark and verification
The scenario opens a breaker against a consumer whose P99 spikes to 900ms, then recovers to healthy at t=20s. Two policies are compared: eager (fixed 1s window, full-traffic half-open, close on first success) and backed-off (jittered exponential window, 2-probe cap, 5 consecutive successes to close).
| Metric | Eager recovery | Backoff + probe cap |
|---|---|---|
| Re-trips after true recovery | ~7 | 0 |
| Time healthy → fully closed | ~42 s (flapping) | ~4 s |
| Probes hitting cold consumer | full backlog | ≤ 2 in-flight |
| Consumer re-failures caused by probing | ~5 | 0 |
| Backoff window at worst | 1 s (never grows) | 1→2→4 s, capped 30 s |
The eager policy flaps: it closes on the first success, dumps the full backlog onto a consumer whose cache is still cold, re-trips, and repeats — taking ~42 seconds of oscillation after the consumer was actually healthy, and causing ~5 re-failures that prolong the outage. The backed-off policy admits at most two probes, requires five consecutive healthy (fast) responses, and closes once in ~4 seconds with zero re-trips. A minimal harness:
import asyncio
async def recovery_bench(state, gate, healthy_at: float, secs: float) -> dict[str, int]:
trips = probes = 0
start = asyncio.get_event_loop().time()
async def send(_trigger: object) -> None:
# Consumer is slow until healthy_at, fast after.
elapsed = asyncio.get_event_loop().time() - start
await asyncio.sleep(0.9 if elapsed < healthy_at else 0.005)
end = start + secs
while asyncio.get_event_loop().time() < end:
nonlocal_result = await run_probe(gate, state, send, _noop, object())
probes += 1
if nonlocal_result == "reopen":
trips += 1
await asyncio.sleep(0.5)
return {"trips": trips, "probes": probes}
async def _noop(_t: object) -> None:
return None
Verify by asserting zero re-trips in the 10 seconds after healthy_at, and by confirming the backoff window actually grows across consecutive trips (log next_open_window() and check the 1, 2, 4 progression). If re-trips persist after recovery, the success threshold is too low or the probe cap too high.
Failure modes and edge cases
- Flapping (backoff reset too eagerly). The dominant failure. Resetting
_consecutive_tripson any probe success keeps the window pinned at base, so a marginal consumer oscillates forever. Reset only on a clean close. - Thundering herd (probe cap too high or no jitter). A half-open state that admits full traffic, or unjittered windows across replicas, re-synchronizes the load onto a cold consumer and re-fails it. Cap probe concurrency and jitter the window.
- Slow-success trap. Judging probes on success alone lets “responds but slow” close the breaker, re-inheriting the consumer’s tail into the router. Judge probes on latency too, using the same trip threshold.
- Backoff ceiling too low. A
max_sof a few seconds keeps probing a genuinely dead consumer aggressively, wasting connections and log volume. Cap at 30s+ so a dead consumer is probed rarely. - Lost probe result. If a probe’s coroutine is cancelled (router shutdown, timeout race) without calling
on_probe_result, the success counter can stall mid-run. Treat a cancelled probe as a failure so the state cannot hang in half-open indefinitely.
Related
- Circuit Breakers for Downstream Trigger Consumers — parent overview of the three-state breaker and its trip conditions.
- Backpressure & Flow-Control Strategies — the priority-shed fallback probes divert into while the breaker is open.
- Event Routing & Backpressure — the routing tier the breaker’s recovery protects.