Implementing circuit breakers for PMS failover
When the property management system stops responding, the worst thing a pricing pipeline can do is keep hammering it — piling up timeouts, exhausting connection pools, and stalling every request behind the dead dependency. A circuit breaker cuts that off: after enough failures it stops calling the failing PMS, serves a fallback, and probes for recovery before resuming. This guide implements one, extending Security Boundaries & Fallback Routing in the Core Architecture & Pricing Taxonomy pillar.
Prerequisites
- Python 3.11+
- Standard library
time,enum,logging,dataclasses - A PMS client call that can time out or raise
- A defined fallback (last-known-good inventory, or a safe default)
- A monotonic clock for timing state transitions
Step 1 — Model the three breaker states
A circuit breaker is a small state machine: closed (calls flow), open (calls are short-circuited), and half-open (a trial call probes recovery). Encode the states and the thresholds that move between them.
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
logger = logging.getLogger("pms.breaker")
class State(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class Breaker:
failure_threshold: int = 5
reset_timeout: float = 30.0 # seconds open before probing
_state: State = field(default=State.CLOSED, init=False)
_failures: int = field(default=0, init=False)
_opened_at: float = field(default=0.0, init=False)
Step 2 — Decide whether a call may proceed
Before each call, ask the breaker for permission. When open, it stays open until the reset timeout elapses, then allows a single half-open trial.
def allow(self, now: float) -> bool:
if self._state is State.CLOSED:
return True
if self._state is State.OPEN:
if now - self._opened_at >= self.reset_timeout:
self._state = State.HALF_OPEN
logger.info("breaker half-open: probing PMS")
return True
return False
# HALF_OPEN: allow exactly one trial
return True
Step 3 — Record outcomes and transition
Success in half-open closes the breaker and clears the failure count; a failure re-opens it. In closed state, consecutive failures accumulate until the threshold trips the breaker open.
def on_success(self) -> None:
if self._state is State.HALF_OPEN:
logger.info("breaker closed: PMS recovered")
self._state = State.CLOSED
self._failures = 0
def on_failure(self, now: float) -> None:
self._failures += 1
if self._state is State.HALF_OPEN or self._failures >= self.failure_threshold:
self._state = State.OPEN
self._opened_at = now
logger.warning("breaker open after %d failures", self._failures)
def get_inventory(breaker: Breaker, pms_call, fallback):
now = time.monotonic()
if not breaker.allow(now):
logger.info("breaker open; serving fallback")
return fallback()
try:
result = pms_call()
breaker.on_success()
return result
except Exception as exc: # timeout, connection error, 5xx
breaker.on_failure(time.monotonic())
logger.error("PMS call failed: %s; serving fallback", exc)
return fallback()
Serving the fallback the instant the breaker is open — rather than attempting the call and waiting for it to time out — is what protects the rest of the pipeline: a fast, known-good default beats a slow failure every time.
Verification and testing
def test_breaker_opens_then_recovers() -> None:
breaker = Breaker(failure_threshold=2, reset_timeout=10.0)
t = 1000.0
for _ in range(2):
assert breaker.allow(t) is True
breaker.on_failure(t)
assert breaker.allow(t) is False # open, short-circuits
assert breaker.allow(t + 11) is True # half-open probe after timeout
breaker.on_success()
assert breaker.allow(t + 12) is True # closed again
Common pitfalls and edge cases
- No half-open probe. Reopening on a timer without a trial call flaps; use a single half-open probe to confirm recovery.
- Counting expected errors. A 404 for an unknown room is not a PMS outage; only trip on connectivity and 5xx failures.
- Shared breaker across dependencies. One breaker for the PMS and the channel manager conflates failures; use one per dependency.
- Wall-clock timing. An NTP jump can prematurely close or hold a breaker; time transitions with
time.monotonic(). - Silent fallback. Serving stale inventory without emitting a metric hides an ongoing outage; alert whenever the breaker is open.
Related
- Security Boundaries & Fallback Routing — the parent cluster on failure isolation and safe routing.
- Reconciling inventory counts across channel managers — what the fallback inventory must be reconciled against once the PMS recovers.
- Price Recommendation Serving — a downstream layer that applies the same fail-loud, fall-back-safe discipline.