Implementing Circuit Breakers for Flag Providers

This how-to is part of Operational Safety and Incident Response. Your flag provider starts timing out and every request that reads a flag now waits out the full timeout before falling back — a slow provider has become a slow service. This how-to builds a circuit breaker that trips after repeated failures so calls go straight to local defaults, then probes for recovery, protecting both your latency and the struggling provider.

The distinction that matters is between a timeout and a breaker. A timeout bounds one call: it caps the damage a single slow evaluation can do at, say, 50ms. But if the provider is down for two minutes and you serve 10,000 requests per second, that is 1.2 million requests each eating a 50ms wall — the tail latency of the whole service climbs even though every individual call is technically bounded. The breaker exists to notice that the failures are correlated and stop paying the 50ms toll on every one of them. Once tripped, an open breaker resolves a flag read in microseconds because it never touches the network at all. That is also a gift to the provider: a degraded control plane recovering from an outage does not need thousands of clients hammering it with retries at the exact moment it is trying to come back.

Circuit breaker states The breaker cycles through three states: closed calls the provider normally, open skips it and serves defaults after a failure threshold, and half-open sends occasional probes to test recovery before closing again. CLOSED calls provider OPEN serves defaults HALF-OPEN probes recovery failures > threshold after cooldown probe ok probe fails
Closed is normal operation; a burst of failures opens the breaker to serve defaults; after a cooldown it half-opens to probe, closing on success or re-opening on failure.

Prerequisites

Circuit breaker prerequisites Safe defaults, per-call timeouts, and a health metric to observe the breaker. Safe defaults served while open Per-call timeout complements breaker Health metric observe transitions
The breaker sits on top of timeouts, not instead of them — the timeout bounds a single slow call, the breaker stops a run of them.

Step-by-Step Procedure

Step 1 — Track failures in a rolling window

Count failures within a time window so a slow trickle of unrelated errors does not trip the breaker, but a genuine burst does. The rolling window is what separates a rate from a count — a fixed count of, say, five lifetime failures would eventually trip on a healthy provider that has simply been running long enough to accumulate a few transient errors. By filtering out timestamps older than windowMs on every record, you are really asking “have there been five failures in the last N seconds?”, which tracks the provider’s current health rather than its history.

A word on what counts as a failure. Be deliberate about which errors increment the counter: a timeout, a connection refusal, or a 5xx from the provider are all signals of a degraded control plane and should trip the breaker. A FLAG_NOT_FOUND or a malformed-context error is a bug in your call, not a provider outage — counting it would open the breaker on a mistake that serving defaults will never fix. Filter for the transport-level and availability errors and let application errors surface normally.

// breaker.ts — rolling-window failure tracking
export function createBreaker({ failureThreshold, windowMs, halfOpenAfterMs }: BreakerCfg) {
  let failures: number[] = [];          // timestamps of recent failures
  let openedAt = 0;
  const now = () => Date.now();

  function recordFailure() {
    const t = now();
    failures = failures.filter(ts => t - ts < windowMs);
    failures.push(t);
    if (failures.length >= failureThreshold) openedAt = t;   // trip
  }
  return { recordFailure, /* ...state getters below */ } as Breaker;
}

Step 2 — Expose the tri-state, including half-open

Derive the state from the timers: open right after tripping, half-open once the cooldown elapses, closed otherwise. Half-open is what lets exactly one probe through. Deriving state from timers rather than storing it as a mutable field is a deliberate simplification — there is no background timer to cancel, no setTimeout to leak, and the state is always a pure function of the current clock. A read of state() costs a subtraction and a comparison, which matters when it sits on the hot path of every flag evaluation.

The half-open window has one sharp edge worth naming: you want exactly one probe in flight, not one probe per cooldown. If two requests both observe half-open before either records a result, both will hit a provider you already suspect is fragile. Under low traffic this rarely bites, but a high-QPS service can leak a handful of concurrent probes in the microseconds between the state flip and the first recordSuccess. If that matters, guard the probe with a compare-and-swap flag so the first caller claims the probe slot and the rest fall through to defaults until the result lands.

// breaker.ts (continued)
function state(): 'closed' | 'open' | 'half-open' {
  if (!openedAt) return 'closed';
  return (now() - openedAt >= halfOpenAfterMs) ? 'half-open' : 'open';
}
function isOpen() { return state() === 'open'; }             // skip provider entirely
function allowProbe() { return state() === 'half-open'; }    // let one trial call through

Step 3 — Gate provider calls on breaker state

In the evaluation wrapper, skip the provider when open, allow a single probe when half-open, and on a successful probe reset the breaker to closed. Notice that the open path returns the default before the try block — there is no await, no network, and no timeout to wait out, which is the whole point. The default itself comes from the same DEFAULTS map your safe fallback defaults work produced, so an open breaker degrades to a known, reviewed value rather than to the bare false a naive ?? false would hand back.

One subtlety in recordSuccess: resetting on a single successful probe is optimistic, and for most providers that is the right call — a provider that answers one request cleanly after a cooldown is almost certainly back. If you have seen a provider flap (answer one request, then fail the next ten), require two or three consecutive successes in half-open before closing. The cost is a slightly longer recovery; the benefit is you do not slam the closed breaker back open the instant real traffic resumes.

// safe-evaluate.ts
async function safeBool(key: string, ctx: object): Promise<boolean> {
  if (breaker.isOpen()) return DEFAULTS[key] ?? false;       // open: straight to default
  try {
    const v = await withTimeout(client.getBooleanValue(key, DEFAULTS[key] ?? false, ctx), 50);
    breaker.recordSuccess();                                  // half-open probe succeeded -> close
    return v;
  } catch {
    breaker.recordFailure();
    return DEFAULTS[key] ?? false;
  }
}

Step 4 — Emit state transitions as metrics

Record every open, half-open, and close so you can see the breaker working during an incident and alert on prolonged open states. Encode the state as a numeric gauge — closed: 0, half-open: 1, open: 2 — rather than a string label, because a gauge lets you write range queries and max_over_time alerts directly. Label the metric by provider so a fleet running several providers (flagd for one namespace, a SaaS control plane for another) does not collapse into a single indistinguishable line.

The alert that earns its keep is the prolonged open, not the transient one. A breaker that opens for three seconds during a deploy is working exactly as designed and should page no one; a breaker sitting at 2 for ten minutes means every replica has given up on the provider and the whole service is now running on defaults with no live targeting. That second condition is the incident — the max_over_time(flag_breaker_state[10m]) == 2 expression is deliberately tuned to fire only when the open state persists across the full window. Pair it with a lower-severity signal on transition rate so a breaker that flaps open and closed every few seconds is visible before it turns into a sustained outage.

// on each transition
breakerState.set({ provider: 'flagd' }, { closed: 0, 'half-open': 1, open: 2 }[state()]);
// Alert: max_over_time(flag_breaker_state[10m]) == 2  -> provider down for 10m
The four breaker steps Track failures in a rolling window, expose the tri-state including half-open, gate provider calls on the state, then emit state transitions as metrics. 1 Rolling window count failures 2 Tri-state half-open 3 Gate calls on state 4 Emit metrics transitions
The half-open state in step 2 is the whole trick — without it the breaker never learns the provider recovered and stays open indefinitely.

Verification

Drive the breaker through all three states in a test: fail enough calls to open it, wait the cooldown to half-open, then succeed a probe to close it. The load-bearing detail is that time is injected, not read from the real clock — the test uses a fake clock.advance(5_000) to jump past halfOpenAfterMs instantly. If your breaker calls Date.now() directly, this test either sleeps for real (slow, flaky) or cannot be written at all. Inject a now() function the way the createBreaker example does and the whole state machine becomes deterministic to test.

Do not stop at the happy cycle. Add a case where the probe fails and assert the breaker returns to open with the cooldown clock reset — this is the transition the dashed arrow in the diagram represents, and it is the one that a subtly wrong implementation gets wrong by leaving the breaker stuck in half-open forever. A second worthwhile case: record failures spread across a span longer than windowMs and assert the breaker never opens, proving the rolling-window filter actually evicts stale timestamps rather than accumulating them.

// breaker.test.ts
test('opens after threshold, half-opens after cooldown, closes on probe success', async () => {
  for (let i = 0; i < 5; i++) breaker.recordFailure();
  expect(breaker.state()).toBe('open');
  clock.advance(5_000);                       // past halfOpenAfterMs
  expect(breaker.state()).toBe('half-open');
  breaker.recordSuccess();                    // probe succeeds
  expect(breaker.state()).toBe('closed');
});
The state cycle under test Five failures open the breaker, advancing past the cooldown moves it to half-open, and a successful probe closes it again. 5 fails → open cooldown → half-open probe ok → closed
Testing the full cycle — not just the trip — is what catches a breaker that opens but can never recover.

Gotchas & Edge Cases

Three breaker edge cases Per-replica breaker state means the fleet trips unevenly, a probe storm if every replica half-opens at once, and a threshold too low that flaps. Per-replica state fleet trips unevenly expected, fine Probe storm all half-open together jitter the cooldown Flapping threshold too low tune to error rate
The probe storm is the one that can prolong an outage — if every replica probes at the same instant, the recovering provider is hit by a synchronized wave.

Troubleshooting & FAQ

The breaker opens but the service still slows during an outage. Why?

The timeout is probably too long, so each request before the breaker trips still waits it out, and the breaker’s threshold is high enough that many requests fail slowly first. Tighten the timeout and lower the threshold so the breaker trips before the slow calls accumulate.

Should the breaker be shared across replicas via a central store?

Usually not. A shared breaker adds a network dependency to the very path you are protecting and can blackout the fleet on one replica’s failures. Keep breaker state local; use aggregate metrics to see fleet-wide health.

What happens to in-flight evaluations when the breaker opens?

Nothing special — they complete or time out on their own. The breaker only affects new calls, routing them to defaults. In-flight calls are already bounded by their timeout.

Do I need a circuit breaker if my SDK already caches flags locally?

They solve different problems and compose well. A local cache serves the last known value even while the provider is unreachable, which is often a better fallback than a static default. The breaker’s job is to stop paying the latency cost of calls that are going to fail — cache or no cache, without a breaker each read still attempts the provider and waits out the timeout. Put the breaker in front, and let the open path fall back to the cache first and the static default only if the cache is empty.

How is this different from retrying a failed provider call?

Retries and breakers pull in opposite directions, which is exactly why you need both. A retry helps a single request survive a transient blip by trying again. A breaker helps the whole service survive a sustained outage by refusing to try at all. Retrying every call during a real outage multiplies load on the struggling provider — the breaker is what caps that, so scope retries to one attempt and let the breaker handle the correlated failures.

What failure threshold and window should I start with?

There is no universal number, but a reasonable starting point for a low-latency internal provider is a threshold of about five failures over a two-second window, with a cooldown around five seconds. Then tune against reality: if the breaker flaps during normal operation, raise the threshold or lengthen the window; if the service still slows noticeably before the breaker trips, lower the threshold or tighten the per-call timeout.

Should the breaker fail open or fail closed?

For a flag provider it should almost always fail open — meaning it stops calling the provider and serves your reviewed defaults, keeping the service running. “Fail closed” (blocking requests entirely when the dependency is down) makes sense for a hard dependency like auth, but a flag read is a soft dependency: the correct degraded behavior is a sensible default, not an error. Make sure your defaults reflect the safe choice so failing open never enables a half-built feature.