Cascading failures are the silent killer of production data pipelines — one degraded API can take down six systems you never touched. This deep-dive lesson teaches you how to implement production-grade circuit breakers in Python, integrate them with Airflow and Prefect, back them with Redis for multi-worker environments, and tune their parameters for the burst-heavy reality of batch data workloads.

It's 2:47 AM on a Tuesday. Your on-call alert fires. The payments processing pipeline has been hammering a degraded downstream API for the last 90 minutes — 847,000 requests, all failing, all being retried with increasingly desperate backoff intervals. The API team had a partial outage, and your pipeline, rather than gracefully stepping aside, decided to become the main antagonist in their incident. By the time anyone noticed, you'd also taken down the shared rate-limit pool used by your fraud detection service, which then started dropping requests to your recommendation engine, which finally cascaded into your homepage personalization layer serving cached content from 2019. You broke six systems. You touched none of them directly.
This is the cascading failure problem, and it's the most underappreciated risk in production data engineering. Distributed systems are inherently optimistic — they assume the thing they're calling will respond, will be healthy, will behave within expected parameters. When that assumption breaks, pipelines need a mechanism to detect failure, back off intelligently, and protect both themselves and their dependencies from digging a deeper hole. That mechanism is the circuit breaker pattern, borrowed from electrical engineering and adapted for software systems by Michael Nygard in Release It!, and it's one of the most valuable architectural tools you can add to your production data workflows.
By the end of this lesson, you'll understand how circuit breakers work at a mechanical level, how to implement them in Python for real data pipeline scenarios, how to integrate them with orchestration frameworks like Airflow and Prefect, and how to tune their parameters for the kinds of bursty, stateful, batch-oriented workloads that distinguish data pipelines from web services.
What you'll learn:
This lesson assumes you're comfortable with:
You don't need to have implemented a circuit breaker before. That's what we're here for.
Before we write a line of code, you need a clear mental model of what a circuit breaker actually does. The pattern has three states — Closed, Open, and Half-Open — and understanding the purpose of each state is what prevents you from cargo-culting the implementation.
Closed is the normal operating state. Current flows. Your pipeline makes calls to downstream systems, and the circuit breaker sits in the middle, watching. It tracks failures — not every error (we'll come back to this) — specifically failures that suggest the downstream system is degraded. Connection timeouts, HTTP 503s, database unavailable errors. When the failure rate or count crosses a configured threshold within a rolling window, the circuit breaker trips.
Open is the protective state. No current flows. The circuit breaker immediately rejects any attempt to call the downstream system, raising a CircuitBreakerOpen exception without even attempting the network call. This is the counterintuitive but critical behavior: it feels wrong to fail fast when you're hoping the system recovers, but the alternative — continuing to hammer a degraded system — prevents it from recovering. An overloaded API that's trying to recover can't do so if you're sending it thousands of requests per second. Open state gives the downstream system breathing room.
Half-Open is the probing state. After the circuit has been open for a configured timeout period, it transitions to Half-Open and allows a single request through. If that request succeeds, the circuit closes and normal operation resumes. If it fails, the circuit trips back to Open and the timeout resets. This is how the system self-heals without requiring manual intervention for every downstream blip.
threshold timeout
Closed ──────────────────► Open ──────────────────► Half-Open
▲ │
│ │
└──────────────── success ──────────────────────────── ┘
│
Open ◄──── failure ─────────────────┘
What makes this model powerful for data pipelines specifically is that batch workloads often hit downstream systems in concentrated bursts. A web service might spread 10,000 API calls across an hour. A data pipeline might fire all 10,000 in a 4-minute window after a scheduled trigger. A circuit breaker without proper window sizing for your workload shape will either trip constantly on normal batch behavior or fail to trip when it should. We'll address this in tuning.
The failure taxonomy problem: Not all errors should count against the circuit. A
404 Not Foundon an API lookup for a record that genuinely doesn't exist is not evidence of downstream degradation — it's a data quality issue. A429 Too Many Requestsis evidence that you are the problem, not the downstream. Only errors that indicate the downstream system is failing to serve requests it should be able to serve — timeouts, connection errors, 5xx responses — should count as circuit-relevant failures.
Let's build a circuit breaker that you'd actually use in production. We'll start with the core state machine and then layer on the features you need for real workloads.
import time
import threading
import logging
from enum import Enum
from dataclasses import dataclass, field
from typing import Callable, Optional, Type, Tuple
from functools import wraps
logger = logging.getLogger(__name__)
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreakerOpen(Exception):
"""Raised when a call is attempted against an open circuit."""
def __init__(self, breaker_name: str, retry_after: float):
self.breaker_name = breaker_name
self.retry_after = retry_after
super().__init__(
f"Circuit '{breaker_name}' is OPEN. "
f"Retry after {retry_after:.1f}s"
)
@dataclass
class CircuitBreakerConfig:
# How many failures within the window trigger the circuit
failure_threshold: int = 5
# Rolling window in seconds for counting failures
failure_window_seconds: float = 60.0
# How long the circuit stays open before probing (seconds)
recovery_timeout_seconds: float = 30.0
# Number of consecutive successes in half-open to close circuit
success_threshold: int = 2
# Exception types that count as circuit-relevant failures
counted_exceptions: Tuple[Type[Exception], ...] = field(
default_factory=lambda: (Exception,)
)
# Exception types that should pass through without counting
excluded_exceptions: Tuple[Type[Exception], ...] = field(
default_factory=tuple
)
Notice counted_exceptions and excluded_exceptions. This is where you implement the failure taxonomy. In practice you'll configure something like:
from requests.exceptions import Timeout, ConnectionError
from sqlalchemy.exc import OperationalError
config = CircuitBreakerConfig(
failure_threshold=10,
failure_window_seconds=120.0,
recovery_timeout_seconds=60.0,
success_threshold=3,
counted_exceptions=(Timeout, ConnectionError, OperationalError),
excluded_exceptions=(ValueError, KeyError), # data quality issues
)
Now the core breaker class:
class CircuitBreaker:
def __init__(self, name: str, config: CircuitBreakerConfig):
self.name = name
self.config = config
self._state = CircuitState.CLOSED
self._failure_timestamps: list[float] = []
self._consecutive_successes = 0
self._opened_at: Optional[float] = None
self._lock = threading.RLock() # Reentrant for nested calls
self._on_state_change: Optional[Callable] = None
@property
def state(self) -> CircuitState:
with self._lock:
self._evaluate_state()
return self._state
def _evaluate_state(self) -> None:
"""Evaluate whether a state transition should occur.
Must be called with self._lock held."""
if self._state == CircuitState.OPEN:
elapsed = time.monotonic() - self._opened_at
if elapsed >= self.config.recovery_timeout_seconds:
self._transition_to(CircuitState.HALF_OPEN)
def _count_recent_failures(self) -> int:
"""Count failures within the rolling window."""
cutoff = time.monotonic() - self.config.failure_window_seconds
# Prune old failures
self._failure_timestamps = [
ts for ts in self._failure_timestamps if ts > cutoff
]
return len(self._failure_timestamps)
def _transition_to(self, new_state: CircuitState) -> None:
"""Handle state transition with logging and callback."""
old_state = self._state
self._state = new_state
if new_state == CircuitState.OPEN:
self._opened_at = time.monotonic()
self._consecutive_successes = 0
elif new_state == CircuitState.CLOSED:
self._failure_timestamps.clear()
self._consecutive_successes = 0
self._opened_at = None
logger.warning(
"Circuit breaker state transition",
extra={
"circuit_breaker": self.name,
"from_state": old_state.value,
"to_state": new_state.value,
"failure_count": len(self._failure_timestamps),
}
)
if self._on_state_change:
self._on_state_change(self.name, old_state, new_state)
def on_state_change(self, callback: Callable) -> "CircuitBreaker":
"""Register a callback for state changes (for metrics/alerting)."""
self._on_state_change = callback
return self
def record_success(self) -> None:
with self._lock:
if self._state == CircuitState.HALF_OPEN:
self._consecutive_successes += 1
if self._consecutive_successes >= self.config.success_threshold:
self._transition_to(CircuitState.CLOSED)
def record_failure(self, exception: Exception) -> None:
with self._lock:
# Check if this exception type should be counted
if self.config.excluded_exceptions and isinstance(
exception, self.config.excluded_exceptions
):
return
if not isinstance(exception, self.config.counted_exceptions):
return
self._failure_timestamps.append(time.monotonic())
failure_count = self._count_recent_failures()
if self._state == CircuitState.HALF_OPEN:
# Any failure in half-open trips back to open
self._transition_to(CircuitState.OPEN)
elif self._state == CircuitState.CLOSED:
if failure_count >= self.config.failure_threshold:
self._transition_to(CircuitState.OPEN)
def call(self, func: Callable, *args, **kwargs):
"""Execute func through the circuit breaker."""
with self._lock:
self._evaluate_state()
if self._state == CircuitState.OPEN:
elapsed = time.monotonic() - self._opened_at
retry_after = self.config.recovery_timeout_seconds - elapsed
raise CircuitBreakerOpen(self.name, max(0, retry_after))
try:
result = func(*args, **kwargs)
self.record_success()
return result
except Exception as e:
self.record_failure(e)
raise
The use of threading.RLock() is deliberate. If you're running in a multithreaded environment (Airflow workers, for instance), you need the state transitions to be atomic. The RLock (reentrant lock) handles the case where call() acquires the lock, then calls record_failure(), which also needs the lock — a regular Lock would deadlock here.
For most usage, you'll want a decorator rather than calling .call() directly:
def __call__(self, func: Callable) -> Callable:
"""Use circuit breaker as a decorator."""
@wraps(func)
def wrapper(*args, **kwargs):
return self.call(func, *args, **kwargs)
return wrapper
Which lets you write:
payment_api_breaker = CircuitBreaker(
name="payment_api",
config=CircuitBreakerConfig(
failure_threshold=10,
failure_window_seconds=60.0,
recovery_timeout_seconds=120.0,
success_threshold=3,
counted_exceptions=(Timeout, ConnectionError),
)
)
@payment_api_breaker
def fetch_payment_record(payment_id: str) -> dict:
response = requests.get(
f"https://payments-api.internal/v2/payments/{payment_id}",
timeout=5.0,
)
response.raise_for_status()
return response.json()
For imperative code in pipeline tasks, a context manager is often cleaner:
def __enter__(self):
with self._lock:
self._evaluate_state()
if self._state == CircuitState.OPEN:
elapsed = time.monotonic() - self._opened_at
retry_after = self.config.recovery_timeout_seconds - elapsed
raise CircuitBreakerOpen(self.name, max(0, retry_after))
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.record_success()
elif exc_val is not None:
self.record_failure(exc_val)
return False # Don't suppress exceptions
Usage:
def enrich_customer_records(records: list[dict]) -> list[dict]:
enriched = []
for record in records:
try:
with crm_api_breaker:
crm_data = crm_client.get_customer(record["customer_id"])
enriched.append({**record, **crm_data})
except CircuitBreakerOpen as e:
logger.warning(
"CRM circuit open, skipping enrichment",
extra={"customer_id": record["customer_id"],
"retry_after": e.retry_after}
)
enriched.append({**record, "enrichment_status": "circuit_open"})
return enriched
This is a critical pattern for data pipelines: handle CircuitBreakerOpen gracefully at the record level, so a single task failure doesn't abort an entire batch run. The appropriate behavior depends on your pipeline's semantics — sometimes you skip and continue, sometimes you raise and fail the task, sometimes you route to a dead-letter mechanism.
The implementation above has a serious production problem: state lives in memory. When an Airflow worker dies and a task gets picked up by another worker, the circuit breaker state resets. If you're running multiple Airflow workers in parallel, each has its own independent circuit breaker instance. Worker A might have 9 recorded failures and be one failure away from opening, while Worker B has seen none.
For production, you need a shared, persistent circuit breaker state. Redis is the natural fit.
import redis
import json
from typing import Optional
class RedisCircuitBreaker(CircuitBreaker):
"""Circuit breaker with Redis-backed shared state."""
def __init__(
self,
name: str,
config: CircuitBreakerConfig,
redis_client: redis.Redis,
key_prefix: str = "circuit_breaker",
):
super().__init__(name, config)
self.redis = redis_client
self.key_prefix = key_prefix
self._state_key = f"{key_prefix}:{name}:state"
self._failures_key = f"{key_prefix}:{name}:failures"
self._opened_at_key = f"{key_prefix}:{name}:opened_at"
self._successes_key = f"{key_prefix}:{name}:successes"
def _get_redis_state(self) -> CircuitState:
raw = self.redis.get(self._state_key)
if raw is None:
return CircuitState.CLOSED
return CircuitState(raw.decode())
def _set_redis_state(self, state: CircuitState) -> None:
self.redis.set(self._state_key, state.value)
def record_failure(self, exception: Exception) -> None:
if self.config.excluded_exceptions and isinstance(
exception, self.config.excluded_exceptions
):
return
if not isinstance(exception, self.config.counted_exceptions):
return
now = time.time()
window_start = now - self.config.failure_window_seconds
pipe = self.redis.pipeline()
# Add failure timestamp to sorted set (score = timestamp)
pipe.zadd(self._failures_key, {str(now): now})
# Remove failures outside the window
pipe.zremrangebyscore(self._failures_key, 0, window_start)
# Set TTL so keys self-clean
pipe.expire(
self._failures_key,
int(self.config.failure_window_seconds * 2)
)
pipe.execute()
failure_count = self.redis.zcard(self._failures_key)
current_state = self._get_redis_state()
if current_state == CircuitState.HALF_OPEN:
self._redis_transition_to(CircuitState.OPEN)
elif current_state == CircuitState.CLOSED:
if failure_count >= self.config.failure_threshold:
self._redis_transition_to(CircuitState.OPEN)
def record_success(self) -> None:
current_state = self._get_redis_state()
if current_state == CircuitState.HALF_OPEN:
pipe = self.redis.pipeline()
pipe.incr(self._successes_key)
pipe.expire(self._successes_key, 300)
_, new_count = pipe.execute()
if new_count >= self.config.success_threshold:
self._redis_transition_to(CircuitState.CLOSED)
def _redis_transition_to(self, new_state: CircuitState) -> None:
old_state = self._get_redis_state()
pipe = self.redis.pipeline()
pipe.set(self._state_key, new_state.value)
if new_state == CircuitState.OPEN:
pipe.set(self._opened_at_key, time.time())
pipe.delete(self._successes_key)
elif new_state == CircuitState.CLOSED:
pipe.delete(self._failures_key)
pipe.delete(self._opened_at_key)
pipe.delete(self._successes_key)
pipe.execute()
logger.warning(
"Circuit breaker state transition",
extra={
"circuit_breaker": self.name,
"from_state": old_state.value,
"to_state": new_state.value,
}
)
def call(self, func: Callable, *args, **kwargs):
current_state = self._get_redis_state()
# Evaluate timeout expiry for OPEN state
if current_state == CircuitState.OPEN:
opened_at_raw = self.redis.get(self._opened_at_key)
if opened_at_raw:
opened_at = float(opened_at_raw)
elapsed = time.time() - opened_at
if elapsed >= self.config.recovery_timeout_seconds:
self._redis_transition_to(CircuitState.HALF_OPEN)
current_state = CircuitState.HALF_OPEN
else:
retry_after = self.config.recovery_timeout_seconds - elapsed
raise CircuitBreakerOpen(self.name, retry_after)
if current_state == CircuitState.OPEN:
raise CircuitBreakerOpen(self.name, self.config.recovery_timeout_seconds)
try:
result = func(*args, **kwargs)
self.record_success()
return result
except Exception as e:
self.record_failure(e)
raise
Redis pipeline atomicity warning: The Redis
pipeline()is not fully atomic — it's a batch of commands that reduces round-trips but doesn't provide MULTI/EXEC transaction semantics by default. For high-concurrency environments, usepipeline(transaction=True)and be aware of the performance implications. For most batch data pipeline workloads, the non-transactional pipeline is sufficient because the failure window provides a natural dampening effect.
Airflow's task model creates specific challenges for circuit breakers. Each task runs in an isolated process (or container, in the KubernetesPodOperator world), so in-memory state is useless. You need Redis-backed state. Beyond that, you need to handle the interaction between circuit breaker behavior and Airflow's retry/failure semantics.
The simplest integration wraps the downstream call inside the task function:
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
import redis
redis_client = redis.Redis(host="redis.internal", port=6379, db=0)
payment_breaker = RedisCircuitBreaker(
name="payment_api_v2",
config=CircuitBreakerConfig(
failure_threshold=15,
failure_window_seconds=300.0, # 5-minute window
recovery_timeout_seconds=180.0, # 3-minute open period
success_threshold=3,
counted_exceptions=(Timeout, ConnectionError),
),
redis_client=redis_client,
)
def process_payment_batch(**context):
batch_date = context["ds"]
records = load_payment_records_for_date(batch_date)
failed_records = []
processed = 0
for record in records:
try:
result = payment_breaker.call(
enrich_with_payment_api,
record["payment_id"]
)
save_enriched_record(result)
processed += 1
except CircuitBreakerOpen as e:
# Circuit is open — fail the entire task immediately
# rather than spinning through thousands of records
logger.error(
"Payment API circuit open, failing task",
extra={"processed": processed, "remaining": len(records) - processed}
)
raise # Let Airflow handle retry scheduling
except Exception as e:
failed_records.append({"payment_id": record["payment_id"], "error": str(e)})
if failed_records:
save_failed_records(failed_records, batch_date)
logger.info(f"Processed {processed}/{len(records)} records, "
f"{len(failed_records)} failures")
with DAG(
dag_id="payment_enrichment",
schedule_interval="@hourly",
start_date=days_ago(1),
default_args={
"retries": 3,
"retry_delay": timedelta(minutes=10),
"retry_exponential_backoff": True,
}
) as dag:
process_task = PythonOperator(
task_id="process_payment_batch",
python_callable=process_payment_batch,
)
Notice the explicit decision to raise on CircuitBreakerOpen. When the circuit is open, continuing to loop through records is pointless — every call will immediately fail. Failing the task and letting Airflow's retry mechanism wait 10+ minutes gives the downstream system time to recover, and because the circuit breaker state is in Redis, the next retry attempt will correctly read the current circuit state rather than starting fresh.
For pipelines where the downstream system must be healthy before a DAG should even start, implement a circuit breaker sensor:
from airflow.sensors.base import BaseSensorOperator
class CircuitBreakerSensor(BaseSensorOperator):
"""Waits until a named circuit breaker is in CLOSED or HALF_OPEN state."""
def __init__(
self,
circuit_breaker_name: str,
redis_host: str,
redis_port: int = 6379,
key_prefix: str = "circuit_breaker",
**kwargs
):
super().__init__(**kwargs)
self.circuit_breaker_name = circuit_breaker_name
self.redis_host = redis_host
self.redis_port = redis_port
self.key_prefix = key_prefix
def poke(self, context) -> bool:
redis_client = redis.Redis(
host=self.redis_host, port=self.redis_port
)
state_key = f"{self.key_prefix}:{self.circuit_breaker_name}:state"
raw = redis_client.get(state_key)
if raw is None:
return True # No state = CLOSED = proceed
state = raw.decode()
self.log.info(f"Circuit '{self.circuit_breaker_name}' is {state}")
return state != CircuitState.OPEN.value
with DAG("payment_enrichment_with_guard", ...) as dag:
wait_for_payment_api = CircuitBreakerSensor(
task_id="wait_for_payment_api",
circuit_breaker_name="payment_api_v2",
redis_host="redis.internal",
poke_interval=60,
timeout=3600, # Give up after an hour
mode="reschedule", # Don't hold a worker slot while waiting
)
process_task = PythonOperator(...)
wait_for_payment_api >> process_task
The mode="reschedule" is important here. Without it, the sensor holds an Airflow worker slot for the entire wait period. In a cluster with limited workers, a few long-running sensors can starve your actual processing tasks.
Prefect's model is more amenable to circuit breakers because flows and tasks are Python-native constructs. You can use the decorator interface directly:
from prefect import flow, task
from prefect.blocks.system import Secret
import redis
def get_redis_client():
# In Prefect, you'd typically retrieve config from blocks
return redis.Redis(host="redis.internal", port=6379)
@task(retries=3, retry_delay_seconds=exponential_backoff(backoff_factor=2))
def fetch_inventory_update(sku_id: str, breaker: RedisCircuitBreaker) -> dict:
"""Fetch inventory update from warehouse API."""
return breaker.call(
warehouse_api_client.get_inventory,
sku_id=sku_id,
)
@task
def handle_circuit_open(sku_ids: list[str], error: CircuitBreakerOpen):
"""Route skipped SKUs to dead-letter for later processing."""
logger.warning(
f"Circuit open, routing {len(sku_ids)} SKUs to dead-letter",
extra={"retry_after": error.retry_after}
)
for sku_id in sku_ids:
dead_letter_queue.put({
"sku_id": sku_id,
"reason": "circuit_open",
"retry_after": time.time() + error.retry_after,
})
@flow(name="inventory-sync")
def inventory_sync_flow(sku_ids: list[str]):
redis_client = get_redis_client()
warehouse_breaker = RedisCircuitBreaker(
name="warehouse_api",
config=CircuitBreakerConfig(
failure_threshold=8,
failure_window_seconds=120.0,
recovery_timeout_seconds=90.0,
success_threshold=2,
counted_exceptions=(Timeout, ConnectionError),
),
redis_client=redis_client,
)
results = []
pending_skus = []
for sku_id in sku_ids:
try:
result = fetch_inventory_update(sku_id, breaker=warehouse_breaker)
results.append(result)
except CircuitBreakerOpen as e:
# Collect remaining SKUs for dead-letter routing
pending_skus = sku_ids[len(results):]
handle_circuit_open(pending_skus, e)
break
return {"processed": len(results), "deferred": len(pending_skus)}
Prefect task and breaker interaction: Prefect tasks have their own retry semantics. When a
CircuitBreakerOpenexception propagates out of a task, Prefect will retry that task according to itsretriesconfiguration. This is usually what you want — but be aware that Prefect's retry delay and your circuit breaker'srecovery_timeoutneed to be aligned. If Prefect retries in 30 seconds but your circuit doesn't open for 90 seconds, you'll waste two retry attempts. Setretry_delay_secondsto at least yourrecovery_timeout_seconds.
This is where most implementations go wrong. Circuit breaker parameters from web service examples don't translate directly to batch data pipelines.
A typical web service has a relatively smooth request rate. A data pipeline emits requests in bursts: nothing for 45 minutes, then 50,000 calls in 8 minutes when the DAG triggers. If your failure window is 60 seconds, and your pipeline fires 50,000 calls in 8 minutes, a 2% transient error rate (1,000 failures) will blast through a failure_threshold=5 in the first few seconds.
Your failure window needs to encompass the full burst duration, not just a representative slice:
# Bad: 60-second window with batch workload
config = CircuitBreakerConfig(
failure_threshold=5,
failure_window_seconds=60.0, # Too short for a 10-minute burst
)
# Better: window covers the expected burst duration
config = CircuitBreakerConfig(
failure_threshold=50, # Higher absolute count
failure_window_seconds=600.0, # 10-minute window
recovery_timeout_seconds=300.0, # 5-minute recovery
success_threshold=5, # Multiple successes to confirm recovery
)
Alternatively, consider a percentage-based threshold instead of an absolute count. Here's an extension to the core breaker:
@dataclass
class AdaptiveCircuitBreakerConfig(CircuitBreakerConfig):
# Trip if failure rate exceeds this percentage (0.0-1.0)
# Takes precedence over failure_threshold if set
failure_rate_threshold: Optional[float] = None
# Minimum number of calls before rate-based tripping applies
minimum_throughput: int = 20
def _should_trip(self, failure_count: int) -> bool:
if self.config.failure_rate_threshold is None:
return failure_count >= self.config.failure_threshold
# Rate-based: need enough calls to make the rate meaningful
total_calls = self._get_total_calls_in_window()
if total_calls < self.config.minimum_throughput:
return False
rate = failure_count / total_calls
return rate >= self.config.failure_rate_threshold
When multiple pipeline instances all have circuits that open simultaneously (say, your 20 Airflow workers all see the same downstream failure), they'll all wait exactly recovery_timeout_seconds and then all probe simultaneously. This thundering herd at recovery time can re-trigger the very failure you were waiting to clear.
Add jitter to the recovery timeout:
import random
def _get_recovery_timeout_with_jitter(self) -> float:
base = self.config.recovery_timeout_seconds
jitter = random.uniform(0, base * 0.2) # ±20% jitter
return base + jitter
Here's a systematic approach to calibrating thresholds:
failure_window_seconds to cover 1.5x your p99 burst duration.failure_threshold to approximately 5% of your expected call volume within that window. If you fire 2,000 calls in 8 minutes and your window is 12 minutes, threshold = 100.recovery_timeout_seconds based on your downstream system's observed recovery time from historical incidents. Check your runbook and post-mortems.A circuit breaker you can't observe is a circuit breaker you can't trust. You need three things: state change events, failure metrics, and a way to inspect and manually override state.
All state transitions and circuit-open events should emit structured log events that your log aggregation pipeline (Datadog, Splunk, CloudWatch Logs Insights) can query:
# In _transition_to():
logger.warning(
"circuit_breaker_transition",
extra={
"event_type": "circuit_breaker_transition",
"circuit_breaker_name": self.name,
"from_state": old_state.value,
"to_state": new_state.value,
"failure_count": len(self._failure_timestamps),
"failure_window_seconds": self.config.failure_window_seconds,
"timestamp": time.time(),
}
)
# In call() when circuit is open:
logger.info(
"circuit_breaker_rejected",
extra={
"event_type": "circuit_breaker_rejected",
"circuit_breaker_name": self.name,
"retry_after_seconds": retry_after,
}
)
from prometheus_client import Counter, Gauge, Histogram
class InstrumentedCircuitBreaker(RedisCircuitBreaker):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._call_counter = Counter(
"circuit_breaker_calls_total",
"Total calls through circuit breaker",
["breaker_name", "outcome"],
)
self._state_gauge = Gauge(
"circuit_breaker_state",
"Current state (0=closed, 1=half_open, 2=open)",
["breaker_name"],
)
self._failure_counter = Counter(
"circuit_breaker_failures_total",
"Counted failures",
["breaker_name"],
)
self.on_state_change(self._update_state_gauge)
def _update_state_gauge(self, name, old_state, new_state):
state_value = {
CircuitState.CLOSED: 0,
CircuitState.HALF_OPEN: 1,
CircuitState.OPEN: 2,
}[new_state]
self._state_gauge.labels(breaker_name=name).set(state_value)
def call(self, func, *args, **kwargs):
try:
result = super().call(func, *args, **kwargs)
self._call_counter.labels(
breaker_name=self.name, outcome="success"
).inc()
return result
except CircuitBreakerOpen:
self._call_counter.labels(
breaker_name=self.name, outcome="rejected"
).inc()
raise
except Exception:
self._call_counter.labels(
breaker_name=self.name, outcome="failure"
).inc()
raise
In production, you will eventually need to force a circuit open or force it closed. Maybe the downstream system is healthy but accumulated stale failures are keeping the circuit open. Maybe you know a downstream system is about to go into maintenance and you want to preemptively open the circuit.
class CircuitBreakerAdmin:
"""CLI/API interface for manual circuit breaker management."""
def __init__(self, redis_client: redis.Redis, key_prefix: str = "circuit_breaker"):
self.redis = redis_client
self.key_prefix = key_prefix
def force_open(self, breaker_name: str) -> None:
state_key = f"{self.key_prefix}:{breaker_name}:state"
opened_at_key = f"{self.key_prefix}:{breaker_name}:opened_at"
pipe = self.redis.pipeline()
pipe.set(state_key, CircuitState.OPEN.value)
pipe.set(opened_at_key, time.time())
pipe.execute()
print(f"Circuit '{breaker_name}' forced OPEN")
def force_close(self, breaker_name: str) -> None:
state_key = f"{self.key_prefix}:{breaker_name}:state"
pipe = self.redis.pipeline()
pipe.set(state_key, CircuitState.CLOSED.value)
pipe.delete(f"{self.key_prefix}:{breaker_name}:failures")
pipe.delete(f"{self.key_prefix}:{breaker_name}:opened_at")
pipe.delete(f"{self.key_prefix}:{breaker_name}:successes")
pipe.execute()
print(f"Circuit '{breaker_name}' forced CLOSED")
def get_status(self, breaker_name: str) -> dict:
state_raw = self.redis.get(f"{self.key_prefix}:{breaker_name}:state")
opened_at_raw = self.redis.get(f"{self.key_prefix}:{breaker_name}:opened_at")
failure_count = self.redis.zcard(f"{self.key_prefix}:{breaker_name}:failures")
return {
"name": breaker_name,
"state": state_raw.decode() if state_raw else "closed",
"failure_count": failure_count,
"opened_at": float(opened_at_raw) if opened_at_raw else None,
"open_duration_seconds": (
time.time() - float(opened_at_raw)
if opened_at_raw else None
),
}
Add this to a simple Flask or FastAPI app behind your internal tooling plane, and you have a serviceable circuit breaker dashboard that your SRE team can use during incidents.
You're building a customer data platform that enriches clickstream events with customer profile data from a CRM API. The pipeline runs every 30 minutes, processes approximately 50,000 events per run, and makes one CRM API call per event. The CRM system has had three incidents in the past 60 days, with recovery times of 12, 45, and 8 minutes respectively.
Your task:
Part 1: Design the circuit breaker configuration
Before writing code, answer these questions:
ConnectionError/Timeout for infrastructure failures.)failure_window_seconds be?recovery_timeout_seconds be?failure_threshold if you want to trip at approximately 3% error rate but require at least 500 calls to have occurred?Part 2: Implement the pipeline task
Write a Python function enrich_clickstream_batch(events: list[dict]) -> dict that:
RedisCircuitBreakerCircuitBreakerOpen by routing remaining events to a dead-letter list{"processed": int, "failed": int, "deferred": int}Part 3: Add observability
Extend your implementation to:
Part 4: Simulate a failure
Write a test using unittest.mock that:
failure_threshold=5, failure_window_seconds=60ConnectionError on the first 10 callsCircuitBreakerOpen without calling the mockrecovery_timeout_seconds and asserts the circuit transitions to HALF_OPENExpected outcome: You should have a complete, testable circuit breaker implementation integrated into a realistic pipeline context, with observability and test coverage.
Symptom: Circuit trips constantly on normal operations, or never trips when the downstream is actually down.
Cause: Misconfigured counted_exceptions — either too broad (catching data quality errors as infrastructure failures) or too narrow (not catching the actual exception type the client library raises).
Fix: Run your pipeline against a deliberately broken downstream in a staging environment and log every exception type. Build your counted_exceptions tuple from observed types, not assumed types. Libraries like requests, httpx, and sqlalchemy have their own exception hierarchies that don't always match what you'd expect.
Symptom: Circuit never trips despite clear downstream failures. Or circuit trips correctly on one worker but others keep firing requests.
Cause: Using the in-memory CircuitBreaker implementation in an Airflow/Celery environment where each task runs in a separate process.
Fix: Switch to RedisCircuitBreaker. If Redis isn't available, you can approximate shared state with a file-based lock and state file on a shared filesystem, but Redis is strongly preferred.
Symptom: Circuit breaker opens, but Airflow retries the task too quickly, hitting the still-open circuit on every retry and wasting all retry attempts.
Cause: Airflow's retry_delay is shorter than recovery_timeout_seconds.
Fix: Set retry_delay >= recovery_timeout_seconds * 1.2 to give the circuit time to transition to HALF_OPEN. Use retry_exponential_backoff=True for additional safety.
Symptom: Tasks retry aggressively, circuit never has time to reset, downstream system stays hammered.
Cause: CircuitBreakerOpen is caught in a generic except Exception handler that logs and continues, meaning the pipeline keeps looping and hitting the open circuit.
Fix: Always handle CircuitBreakerOpen specifically and decisively. Either re-raise immediately (fail the task), route to dead-letter, or break out of the processing loop. Never silently swallow it.
Symptom: Intermittent bugs where the circuit behaves incorrectly around DST transitions or NTP corrections.
Cause: Using time.time() for interval calculations. time.time() can jump backward when NTP corrects the clock.
Fix: Use time.monotonic() for all interval calculations within a single process. Use time.time() only when you need to persist timestamps to external storage (Redis), where monotonic time isn't meaningful across process boundaries. This is why our Redis implementation uses time.time() for stored timestamps but the in-memory implementation uses time.monotonic().
Symptom: Circuit opens due to a transient issue that resolves quickly, but the circuit stays open for the full recovery_timeout period because the half-open probe happens to hit a slow moment. Engineers can't proceed with urgent data processing.
Fix: Implement the CircuitBreakerAdmin force-close interface from the observability section and document it in your runbooks before an incident. You don't want to be writing the override script at 3 AM.
You now have the conceptual foundation and practical implementation skills to protect your data pipelines from cascading failures using the circuit breaker pattern. Let's consolidate what you've built:
The mental model: Three states (Closed → Open → Half-Open → Closed) create a self-healing protective mechanism. The critical insight is that failing fast protects the downstream system, not just your pipeline.
The implementation: A production-grade circuit breaker needs thread-safe state management, configurable failure taxonomy (not all errors are equal), and Redis-backed shared state for multi-worker environments. In-memory implementations are useful for testing and single-process contexts only.
The orchestration integration: In Airflow, handle CircuitBreakerOpen explicitly — either fail the task and let Airflow's retry delay act as the recovery window, or use a CircuitBreakerSensor to gate DAG execution. In Prefect, align task retry_delay_seconds with recovery_timeout_seconds.
The tuning: Batch workloads require wider failure windows and higher absolute thresholds than web service examples suggest. Add jitter to recovery timeouts to prevent thundering herds at recovery time.
The observability: Structured logs for state transitions, Prometheus metrics for rate tracking, and a manual override interface for incident response. All three are mandatory for production.
Next steps to deepen this knowledge:
Implement a bulkhead pattern alongside circuit breakers. Bulkheads isolate thread pools or connection pools so that a slow downstream doesn't consume all your workers. Circuit breakers and bulkheads are complementary.
Study the Hystrix documentation (even though Hystrix is deprecated, its operational model remains the canonical reference for production circuit breaker tuning).
Implement adaptive circuit breaking where the failure threshold adjusts based on historical baseline failure rates — your pipeline might normally have a 1% error rate, and you want to trip at 5%, not at an absolute count.
Explore tenacity as a retry library that integrates well with circuit breaker patterns, particularly its stop_after_attempt and wait_exponential strategies.
Build a failure injection framework for your pipelines — the ability to deliberately inject failures into downstream calls to validate that your circuit breakers behave correctly under load, before a real incident tells you they don't.
The circuit breaker pattern is not a silver bullet. A poorly tuned circuit breaker can mask real problems by failing fast before you've gathered enough signal, or it can fail to protect systems by waiting too long to trip. Treat threshold calibration as an ongoing operational activity, not a one-time configuration decision.