Circuit Breaker Patterns for Degraded Replicas

A single slow replica should never be allowed to keep absorbing reads. When a node’s replication lag drifts past your freshness budget, or its connections start timing out, every request the router still sends there either returns stale data or stalls behind an apply queue. The circuit breaker is the control-theory answer to this: a small state machine that watches a replica’s health signals, trips the moment they cross a threshold, stops routing to the node while it recovers, and cautiously re-admits it only after a probe confirms it is healthy again. This guide covers how the breaker pattern maps onto read-replica routing, where it sits relative to your connection pools and proxies, and how to tune it so it protects your SLA without flapping.

Concept Definition and Scope

A circuit breaker for replica routing is a per-target health gate with three states — CLOSED, OPEN, and HALF-OPEN — that decides whether a given read may be dispatched to a given replica. In the CLOSED state, reads flow normally to the replica and the breaker counts failures. When failures cross a trip condition, the breaker moves to OPEN and short-circuits: reads are routed elsewhere (another replica, or the primary) without even attempting the degraded node. After a cooldown, the breaker enters HALF-OPEN and admits a single probe read; success returns it to CLOSED, failure sends it back to OPEN with a longer cooldown.

This borrows directly from the resilience pattern used for downstream service calls, but the failure signal is different. A service breaker usually trips on error rate and latency. A replica breaker trips on those plus a domain-specific signal that has no analogue in generic RPC: staleness. A replica can answer every query in two milliseconds with a 200 OK and still be returning data that is forty seconds behind the primary. That is a silent correctness failure, not a latency failure, so the breaker must treat a lag-threshold breach as a first-class trip condition alongside connection errors and health-check failures.

The scope of this pattern is the read path only. Writes always go to the primary and are outside the breaker’s concern. What the breaker governs is which read-serving nodes are eligible at any moment, and — through its global variant — what the read path degrades to when no replica is eligible at all. The three guides in this topic area go deeper on each layer: the application-code breaker implementation, infrastructure-level ejection and reintroduction via proxies, and graceful degradation when the whole fleet is stale.

Mechanism Deep-Dive

The heart of the pattern is the transition logic between the three states. The diagram traces every edge and the signal that triggers it.

Replica circuit breaker state machine Three states are shown. CLOSED routes reads to the replica and trips to OPEN when N consecutive lag breaches, an error-rate breach, or a health-check failure occur. OPEN routes reads to the primary or another replica and, after the cooldown elapses, moves to HALF-OPEN. HALF-OPEN admits a single probe read: on success it returns to CLOSED, on failure it returns to OPEN with a longer backoff. CLOSED reads → replica counting failures OPEN reads → primary / healthy peer HALF-OPEN 1 probe read → replica trip: N lag breaches or error-rate / health-check fail cooldown elapsed probe succeeds probe fails → longer backoff

Trip conditions

Three independent signals should be able to trip a replica breaker, combined with OR:

  • Lag-threshold breach. The replica’s measured lag exceeds the tier’s freshness budget for N consecutive health checks. Consecutive, not instantaneous — a single scrape can catch a transient apply spike or a stale metric, and ejecting on one sample causes needless flapping. This signal is what makes a replica breaker different from a generic service breaker, and it depends on the lag telemetry described in detecting and handling replication lag in real time.
  • Error rate. The fraction of reads to this replica that fail (connection refused, timeout, SSL connection closed) over a sliding window exceeds a ceiling, typically 20–50%. A rolling window matters here: a rate computed over a fixed reset interval hides a burst that straddles the boundary.
  • Health-check failure. An out-of-band probe — SELECT pg_is_in_recovery() plus a lag query, or a proxy’s TCP/SQL check — fails or times out. This catches a node that has stopped answering entirely, which the error-rate signal would also catch but more slowly under low read volume.

Cooldown and backoff

When the breaker opens, it records the time and refuses the replica until a cooldown elapses. A fixed cooldown works, but a replica that opens, gets probed, and immediately fails again will thrash. Apply exponential backoff on repeated probe failures — cooldown = base * 2^(consecutive_probe_failures) capped at a ceiling — and add jitter so a fleet of application instances that all tripped on the same lag event do not synchronize their probes into a thundering herd against the recovering node.

Half-open probing

HALF-OPEN is the single most important state for avoiding a false recovery. The breaker admits exactly one read (or a small fixed number) to the previously-degraded replica and watches the outcome. Crucially, the probe must re-evaluate the same signal that tripped the breaker: if the trip reason was lag, the probe checks current lag, not just whether a connection succeeds. A replica whose TCP listener is up but whose apply queue is still forty seconds deep must fail the probe. Only when the probe confirms the node is within budget does the breaker close and resume full traffic.

Per-replica vs global breakers

These are two layers, not two alternatives. A per-replica breaker governs routing to one node and lets you eject a single degraded replica while healthy peers keep serving. A global breaker sits above the fleet and trips only when every per-replica breaker is open — that is the signal to enter a degraded read mode that falls back to the primary with load shedding, covered in the graceful-degradation guide. Running only a global breaker is a common mistake: one slow node then trips reads for the entire fleet.

Interaction with connection pools and load balancers

A breaker does not move traffic by itself — it changes a decision that some routing component then acts on, and where that component sits determines what “open” actually does. Three integration points matter.

Connection pools. When a per-replica breaker opens, the pool for that replica should stop handing out connections, but it must not slam existing checked-out connections shut mid-query — that turns a staleness problem into a burst of query errors. Drain, don’t kill: mark the pool ineligible for new checkouts, let in-flight queries finish, and recycle idle connections on their normal lifetime. The pool’s own validation query (a lightweight SELECT 1 on checkout) is a second, faster trip signal that complements the lag poll, and sizing the primary pool to absorb the redirected read load is a prerequisite covered in the connection pool architecture for read replicas.

Load balancers. A TCP or HTTP load balancer in front of the replicas already has a health-check and ejection mechanism — rise/fall counters, drain/down states, slowstart. The breaker’s job here is to feed the balancer a freshness-aware verdict rather than a bare liveness check, so the balancer ejects a lagging-but-reachable node. The balancer then provides the hysteresis and warm-up for free. This is why a proxy breaker and an application breaker are not redundant: the balancer enforces one ejection decision for every client, while the in-process breaker adds per-request consistency context the balancer cannot see.

Ordering. The breaker check must happen before pool checkout, not after. Acquiring a connection and then discovering the replica is tripped wastes a pool slot and adds latency; the eligibility decision belongs at the top of the routing path so a tripped replica is skipped without ever touching its pool.

Trade-Off Comparison Table

Where the breaker lives determines what signals it can see and how fast it reacts.

Placement Reaction latency Signals available Blast radius on trip Best for
In-application (per-request) Immediate — decided at routing time Lag, per-request errors, causal-read LSN, hints Single process view; other instances decide independently ORM middleware and services needing consistency context
Proxy layer (ProxySQL / HAProxy / pgpool) One health-check interval (1–5 s) Lag via monitor, connection errors, TCP/SQL checks Fleet-wide — all clients behind the proxy react together Centralized proxy-layer routing
Sidecar / connection-pool wrapper Sub-second, shared across pool Pool checkout errors, validation query, lag poll Per-host pool for the local process Pool-integrated deployments; see pool architecture
Global degraded-mode breaker Aggregated across per-replica state “All replicas open” derived signal Entire read path switches to fallback Primary protection and load shedding

The application-level breaker reacts instantly and sees request context but only has one process’s view of the world; a proxy breaker has a fleet-wide view but reacts at the granularity of its health-check interval. High-scale deployments run both — a proxy breaker for coarse ejection and an application breaker for per-request consistency overrides — the same layering described in the broader connection routing and pooling strategy.

Configuration Runbook

Application-level breaker (pseudocode)

The logic below is framework-agnostic; the application-code guide turns it into a concrete, thread-safe implementation.

python
# Per-replica breaker evaluated at routing time
STATE = {"closed": 0, "open": 1, "half_open": 2}

def route_read(replica, now):
    b = replica.breaker
    if b.state == "open":
        if now - b.opened_at >= b.cooldown:
            b.state = "half_open"          # admit exactly one probe
        else:
            raise ReplicaUnavailable       # short-circuit, router tries next
    if b.state == "half_open" and not b.probe_slot.acquire():
        raise ReplicaUnavailable           # another request holds the probe

    lag_ms = replica.measured_lag_ms()
    if lag_ms > b.trip_lag_ms:
        b.record_breach()                  # consecutive-breach counter
        if b.breaches >= b.trip_threshold or b.state == "half_open":
            b.open(now)                     # backoff applied inside open()
        raise ReplicaUnavailable
    # healthy sample
    if b.state == "half_open" and lag_ms <= b.reenter_lag_ms:
        b.close()                          # hysteresis: lower re-entry threshold
    b.record_success()
    return replica.connection()

Key parameters and safe defaults for a user-facing tier:

yaml
replica_breaker:
  trip_lag_ms: 500          # tier freshness SLO
  reenter_lag_ms: 200       # hysteresis: must be well under trip to close
  trip_threshold: 3         # consecutive breaches before OPEN
  error_rate_threshold: 0.25
  error_window_s: 30        # rolling, not fixed-reset
  cooldown_base_ms: 2000
  cooldown_max_ms: 60000    # exponential backoff ceiling
  cooldown_jitter_ms: 500
  half_open_probes: 1

Proxy health-check approach (HAProxy + agent-check)

A proxy breaker is expressed as a health check whose result ejects the backend. HAProxy’s agent-check calls a lightweight agent on the replica that returns up, drain, or down based on measured lag, which HAProxy then acts on without ejecting for a single blip because of the rise/fall counters:

text
backend pg_read
    balance leastconn
    option httpchk GET /health
    # rise/fall provide the consecutive-sample hysteresis
    default-server inter 2s fall 3 rise 5 slowstart 30s
    server replica_a 10.0.1.11:5432 check agent-check agent-port 9101
    server replica_b 10.0.1.12:5432 check agent-check agent-port 9101

The agent returns drain when lag crosses the budget (stop new sessions, keep existing) and down when the node is unreachable; rise 5 means five consecutive healthy checks are required to close, fall 3 means three consecutive failures to eject, and slowstart 30s ramps weight back up over thirty seconds after recovery — the exact ejection-and-reintroduction mechanics covered in the replica-ejection guide. The fall/rise asymmetry is the proxy’s built-in hysteresis, and slowstart is its half-open probe.

Monitoring and Alerting Signals

Every breaker transition must be observable, or a fleet silently running in degraded mode looks identical to a healthy one. Expose at minimum:

  • replica_breaker_state{replica} — 0=CLOSED, 1=OPEN, 2=HALF_OPEN. The single most important panel.
  • replica_breaker_trips_total{replica, reason="lag|error_rate|health_check"} — counts trips by cause so you can tell a lag problem from a connectivity problem.
  • replica_breaker_probe_total{replica, result="success|fail"} — half-open outcomes; a high fail ratio means the cooldown is too short for the recovery time.
  • replica_measured_lag_ms{replica} — the raw signal, so you can correlate a trip with the lag curve that caused it.
  • read_path_degraded — the global breaker’s derived state; 1 when all replicas are open and the primary-fallback path is active.
yaml
groups:
  - name: replica_circuit_breaker
    rules:
      - alert: ReplicaBreakerOpen
        expr: replica_breaker_state == 1
        for: 60s
        labels: {severity: warning}
        annotations:
          summary: "Replica {{ $labels.replica }} breaker OPEN — ejected from read pool"
      - alert: AllReplicaBreakersOpen
        expr: max(read_path_degraded) == 1
        for: 30s
        labels: {severity: critical}
        annotations:
          summary: "All replica breakers OPEN — reads falling back to primary"
      - alert: BreakerFlapping
        expr: rate(replica_breaker_trips_total[10m]) > 6
        for: 5m
        labels: {severity: warning}
        annotations:
          summary: "Replica {{ $labels.replica }} tripping >6x/10m — tune hysteresis/cooldown"

Alert on the derived global state separately from individual replica trips. A single replica opening is routine and self-healing; the whole fleet opening is a page-worthy incident because the primary is now absorbing read load it was never sized for.

Failure Modes and Recovery Steps

1. Breaker flapping between OPEN and CLOSED

Cause: The trip and re-entry thresholds are identical, so a replica hovering right at the budget oscillates every health-check interval. Recovery: Introduce hysteresis — set reenter_lag_ms well below trip_lag_ms (e.g. 200 vs 500), require multiple consecutive healthy probes before closing, and apply exponential backoff to the cooldown so repeated failures widen the retry gap. Confirm the fix by watching replica_breaker_trips_total flatten.

2. False recovery on a shallow probe

Cause: The half-open probe only checked that a TCP connection succeeded, so a replica with a live listener but a deep apply queue closed the breaker and immediately started serving stale reads. Recovery: Make the probe re-evaluate the trip signal — if the breaker tripped on lag, the probe must query current lag (pg_last_wal_replay_lsn position or heartbeat delta) and require it under reenter_lag_ms before closing.

3. Global breaker trips on one slow node

Cause: Only a single fleet-wide breaker was configured, so one degraded replica short-circuited reads for every node. Recovery: Split into per-replica breakers for routing plus a global breaker that trips only when all per-replica breakers are open. The per-replica layer ejects the one bad node; the global layer stays closed while healthy peers absorb the traffic.

4. Thundering herd against a recovering replica

Cause: Many application instances tripped on the same lag event and, with identical fixed cooldowns, all probed the recovering node at the same instant, re-saturating it. Recovery: Add jitter to the cooldown and cap half-open concurrency to one probe per instance. At the proxy layer, slowstart ramps weight gradually so the node is not hit with full traffic the moment it passes its first check.

5. Breaker masks a real capacity shortfall

Cause: Replicas repeatedly trip on lag because the fleet is genuinely undersized for write volume, and the breaker keeps shunting load to the primary, which hides the underlying problem until the primary saturates. Recovery: Treat a sustained AllReplicaBreakersOpen or a high steady-state trip rate as a capacity signal, not just a routing event — add replica capacity or tune parallel apply workers rather than widening the lag budget to silence the breaker.

In This Topic Area

These guides implement each layer of the pattern described above.

Implementing a Replica Health Circuit Breaker in Application Code A concrete, thread-safe per-replica breaker that checks lag before routing, trips on N consecutive breaches, and half-open probes — with the metrics wiring to make every transition observable.

Automatic Replica Ejection and Reintroduction on Lag Spikes Doing the same ejection at the infrastructure layer: HAProxy agent-checks returning drain/down, ProxySQL shunning, and Consul health, with hysteresis and warm-up to avoid flapping on reintroduction.

Graceful Degradation When All Replicas Exceed the Lag Budget What the global breaker does when no replica is eligible: primary fallback with load shedding, serving cached or stale-with-warning reads, and protecting the primary from a read stampede.

FAQ

Should I run one circuit breaker per replica or a single global breaker?

Run both at different scopes. A per-replica breaker makes routing decisions and ejects a single degraded node without affecting healthy peers. A global breaker sits above them and trips only when every replica is open, switching the whole read path into a primary-fallback degraded mode with load shedding. A single global breaker alone is too coarse: one slow replica would trip reads for the entire fleet.

What lag threshold should trip the circuit breaker?

Derive it from the service tier’s freshness SLO, not a fixed number. A user-facing tier with a 500 ms staleness budget might trip at a p95 lag of 500 ms sustained for three consecutive checks. Trip on consecutive breaches rather than a single sample so transient WAL apply spikes and scrape jitter do not eject a healthy replica. Pair the lag trip with an absolute error-rate trip for connection failures.

How do I stop the circuit breaker from flapping between open and closed?

Use hysteresis: require more evidence to close than to open. Trip after three consecutive breaches but only close after the probe passes for a sustained window, and require lag to fall below a lower re-entry threshold than the trip threshold. Add exponential backoff with jitter to the cooldown so a replica that fails its probe waits progressively longer before the next attempt, preventing a tight open/half-open oscillation.

← Back to Replication Lag & Consistency Management