Implementing a Circuit Breaker for Geocoding Providers

TL;DR: Count failures in a time window, keep the state in Redis so every worker shares one view, and put a short lock around the half-open probe so recovery is tested by one request rather than by the whole fleet. The pattern’s place in the wider stack is described in resilience patterns for geocoding APIs.

The State the Breaker Needs

Three values per provider are enough: a rolling record of recent failures, the instant the breaker may next be probed, and a counter of consecutive failed probes for the backoff. All three live in Redis so that every worker reads the same view.

Three keys per provider, and what each is for Three key rows. A sorted set of failure timestamps supports counting failures inside a rolling time window and trimming old entries. An open-until timestamp supports the immediate reject decision without any counting. A consecutive-failed-probes integer supports geometric growth of the cool-down. Key Type Supports cb:{provider}:fails sorted set counting inside a rolling window; old entries trimmed cb:{provider}:open_until string the reject decision, with one read and no counting cb:{provider}:probe_fails integer geometric growth of the cool-down after failed probes The open-until key is what makes the hot path cheap: one GET decides whether to call, without touching the window.

Splitting the decision key from the counting key matters for cost. Every request consults the breaker, and a design that recounts the failure window on each call turns a microsecond decision into a range query. Reading a single timestamp keeps the hot path to one round trip.

The Implementation

from __future__ import annotations

import time
from dataclasses import dataclass

import redis


@dataclass(frozen=True)
class BreakerConfig:
    window_s: float = 60.0        # failures older than this no longer count
    threshold: int = 5            # failures inside the window that open it
    base_cooldown_s: float = 30.0
    max_cooldown_s: float = 600.0


class ProviderBreaker:
    """Shared-state circuit breaker keyed by provider name."""

    def __init__(self, r: redis.Redis, provider: str, cfg: BreakerConfig) -> None:
        self._r = r
        self._cfg = cfg
        self._fails = f"cb:{provider}:fails"
        self._open_until = f"cb:{provider}:open_until"
        self._probe_fails = f"cb:{provider}:probe_fails"
        self._probe_lock = f"cb:{provider}:probe_lock"

    def allows(self) -> bool:
        """True when a call may proceed — the only check on the hot path."""
        raw = self._r.get(self._open_until)
        if raw is None:
            return True                       # closed
        if time.time() < float(raw):
            return False                      # open, still cooling down
        # Cool-down elapsed: exactly one worker gets the probe.
        return bool(self._r.set(self._probe_lock, "1", nx=True, ex=10))

    def record_success(self) -> None:
        pipe = self._r.pipeline()
        pipe.delete(self._fails, self._open_until, self._probe_fails, self._probe_lock)
        pipe.execute()

    def record_failure(self) -> None:
        now = time.time()
        pipe = self._r.pipeline()
        pipe.zadd(self._fails, {f"{now}:{id(self)}": now})
        pipe.zremrangebyscore(self._fails, 0, now - self._cfg.window_s)
        pipe.zcard(self._fails)
        pipe.expire(self._fails, int(self._cfg.window_s * 2))
        count = pipe.execute()[2]

        if count >= self._cfg.threshold:
            attempts = self._r.incr(self._probe_fails)
            cooldown = min(
                self._cfg.base_cooldown_s * (2 ** (attempts - 1)),
                self._cfg.max_cooldown_s,
            )
            self._r.set(self._open_until, now + cooldown)
            self._r.delete(self._probe_lock)

The nx=True on the probe lock is the whole recovery mechanism. When the cool-down elapses, every worker reaches the same branch simultaneously, and exactly one wins the lock; the rest see the breaker as still open and route to the next provider. Without it, recovery begins with a burst equal to your worker count.

Tuning the Two Numbers

Threshold and window together define what “failing” means, and the two most common mistakes pull in opposite directions.

Setting Too low Too high Reasonable start
threshold opens on ordinary noise; healthy providers get skipped absorbs a real outage for minutes before reacting 5 failures
window a quiet minute resets the count; a slow bleed never trips old failures keep the breaker sensitive long after recovery 60 seconds
base cool-down probes an outage constantly, burning quota delays recovery long after the provider is fine 30 seconds
max cool-down a long outage is probed too often a recovered provider stays unused for ages 10 minutes

The window is the setting people get wrong most often, usually by counting attempts instead of seconds. A count-based window has no notion of time, so five failures spread across an hour trip the breaker exactly as readily as five failures in a second — and the first is normal operation while the second is an outage.

Why the Window Must Be Time-Based

The same five failures, two window definitions Two timelines each carrying five failures. In the first, the failures are spread evenly across an hour of otherwise successful traffic. In the second, they occur within ten seconds. A count-based window opens the breaker in both cases. A time-based window opens it only in the second, which is the one that represents an outage. five failures spread over an hour — normal operation count-based window: opens · time-based window: stays closed — correct five failures in ten seconds — an outage both windows open — correct

The upper timeline is what a large batch looks like on an ordinary day: a handful of transient failures across many thousands of successful calls. Opening the breaker there takes a healthy provider out of service and pushes traffic onto a fallback that is usually worse, more expensive, or both.

Reading the Breaker’s Behaviour

Two metrics tell you whether the settings are right, and both are trivial to emit from the methods above. The first is time-in-state per provider per day; the second is the count of probes and their outcomes.

A breaker that never opens is either well tuned or not connected — check that record_failure is actually reached on the error paths, because it is easy to add the breaker to the happy path and forget the branch where a timeout is raised. A breaker that opens several times an hour and closes immediately is oscillating, which usually means the threshold is below the provider’s ordinary noise floor.

The probe outcome series is the more interesting one during an incident. A run of failed probes with a growing cool-down is the system behaving exactly as designed; a run of successful probes followed by immediate re-opening means the provider is flapping, and the right response is to raise the base cool-down so each recovery attempt gets more room.

Testing a Breaker Without a Provider

A breaker is easy to test badly, because the obvious test — point it at a broken provider and watch — is slow, flaky and dependent on someone else’s outage. Test it against a fake clock and a fake failure source instead, and every branch becomes deterministic.

Inject the time function rather than calling time.time() directly. With an injectable clock, “the cool-down elapses” is an assignment rather than a sleep, and a test that exercises the full open, half-open and closed cycle runs in milliseconds. The same seam lets you assert the geometric growth precisely instead of approximately.

Use a real Redis instance rather than a mock. The breaker’s correctness depends on Redis semantics — the atomicity of SET NX, the behaviour of sorted-set range removal — and a mock that implements those approximately will happily pass tests for code that races in production. A container running for the duration of the suite is cheap and tests the thing you actually deploy.

Write one test per transition and one for the concurrency property. The transitions are straightforward; the concurrency test — spawn twenty coroutines against a breaker whose cool-down has just elapsed, assert exactly one obtains the probe lock — is the one that catches the mistake this design exists to prevent, and it is the test most often missing.

Cool-Down Growth Over an Outage

Geometric cool-down across a long outage A timeline of a forty-minute outage. Probes occur after 30 seconds, then 60, 120, 240 and 480 seconds, doubling each time until the configured ten-minute maximum is reached. A final successful probe closes the breaker and resets the counter. 30 s 60 s 120 s 240 s 480 s probe succeeds Six probes across forty minutes, instead of eighty A fixed thirty-second cool-down would have made eighty doomed calls over the same outage; the geometric version makes six, and still detects recovery within one cool-down of it happening.

Edge Cases and Failure Modes

Redis itself becomes unavailable. The breaker must fail open, not closed: if the state store cannot be read, allow the call. A breaker that blocks all traffic because its bookkeeping is down converts a Redis incident into a total outage.

Clock skew between workers. The open-until value is an absolute timestamp, so workers whose clocks differ by seconds will disagree about when the cool-down ended. Keep hosts on NTP, and prefer a generous cool-down over a tight one where skew is possible.

A provider that fails only for some inputs. A breaker is a per-provider signal and cannot express “this provider fails on Japanese addresses”. Those cases belong in the routing registry, not the breaker, or the breaker will open on a provider that is healthy for the traffic actually routed to it.

Probe lock outliving its usefulness. The lock carries a short expiry so a worker that dies mid-probe does not block recovery forever. Ten seconds is comfortably longer than one request and short enough that a crash costs one cool-down at most.

Integration Note

The breaker sits between the bulkhead and the retry loop described in resilience patterns for geocoding APIs, and its open state is what the fallback chain reads when it skips a provider without calling it. Stamping the skip reason onto the record — breaker-open rather than provider-error — is what later lets you distinguish an incident from a coverage gap when reviewing why a batch fell back so often.