Building a Database Circuit Breaker with Python and Tenacity: Auto-Retry, Smart Backoff, and Self-Healing Connections

Database tutorial - IT technology blog
Database tutorial - IT technology blog

The 2 AM Incident That Changed How I Handle Database Failures

It started with a traffic spike. Our PostgreSQL database slowed under load, queries started timing out, and our application — configured with aggressive retry logic — kept hammering the database with thousands of reconnect attempts per second. Instead of recovering, the database buckled completely. Forty minutes of downtime, a very unhappy team, and a postmortem that all pointed to the same root cause: no circuit breaker.

If you’ve been paged at 2 AM for something like this, you know exactly how it feels. This guide walks you through building a proper database circuit breaker in Python so you don’t have to learn the hard way like I did.

What’s Actually Happening When Your Database Fails

Here’s the failure cascade that catches junior developers off guard:

  1. Database gets slow due to high load or a long-running query
  2. Application requests start timing out
  3. Retry logic kicks in — each failed request retries 3 times
  4. Now you have 3× the database load from retries alone
  5. Database gets slower, more timeouts, more retries
  6. Total collapse

This is called a retry storm (sometimes “thundering herd”). Your retry logic, meant to improve reliability, becomes the thing that kills the database. The root cause isn’t the retries themselves — it’s that retries have no awareness of the overall system health. They keep firing even when it’s obvious the database needs a break.

Three Approaches — And Why Most Teams Pick the Wrong One

Option 1: Simple Retry

import time
import psycopg2

def query_with_retry(sql, max_retries=3):
    for attempt in range(max_retries):
        try:
            conn = psycopg2.connect("postgresql://localhost/mydb")
            cursor = conn.cursor()
            cursor.execute(sql)
            return cursor.fetchall()
        except Exception:
            if attempt == max_retries - 1:
                raise
            time.sleep(1)

This is what most people start with. The problem: during database overload, this amplifies the stress. 100 concurrent users × 3 retries each = 300 connection attempts hitting a struggling database. It actively makes things worse.

Option 2: Exponential Backoff with Tenacity

Tenacity is a Python retry library that handles backoff cleanly. This is a genuine improvement:

pip install tenacity psycopg2-binary
from tenacity import retry, stop_after_attempt, wait_exponential
import psycopg2

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=30)
)
def query_db(sql):
    conn = psycopg2.connect("postgresql://localhost/mydb")
    cursor = conn.cursor()
    cursor.execute(sql)
    return cursor.fetchall()

Exponential backoff means retry delays grow: 1s, 2s, 4s, 8s… This gives the database time to breathe between attempts. Much better than Option 1. But there’s still no mechanism to stop retrying entirely when the database is clearly down for an extended period. You’re still throwing requests at a broken system, just slower.

Option 3: Circuit Breaker Pattern (The Right Tool)

The circuit breaker concept comes from electrical engineering. When a circuit gets overloaded, a breaker trips to protect the system. Once things cool down, you reset it. In software, a circuit breaker has three states:

  • Closed — normal operation, requests pass through
  • Open — circuit is tripped, requests fail immediately without touching the database
  • Half-Open — after a cooldown period, allows one test request to check if the database has recovered

The key insight: when the circuit is Open, you stop hammering the database entirely. This gives it the space to recover on its own.

Building It: Circuit Breaker + Tenacity Together

Here’s the implementation I use in production. Combining Tenacity’s backoff with a custom circuit breaker gives you the best of both worlds — smart retries on transient errors, and a hard stop when the database is genuinely down.

Step 1: The Circuit Breaker Class

import time
import threading
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class DatabaseCircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failure_count = 0
        self.last_failure_time = None
        self.state = CircuitState.CLOSED
        self._lock = threading.Lock()

    def record_failure(self):
        with self._lock:
            self.failure_count += 1
            self.last_failure_time = time.time()
            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
                print(f"[Circuit Breaker] OPEN — {self.failure_count} failures detected")

    def record_success(self):
        with self._lock:
            self.failure_count = 0
            self.state = CircuitState.CLOSED

    def can_attempt(self):
        with self._lock:
            if self.state == CircuitState.CLOSED:
                return True
            if self.state == CircuitState.OPEN:
                elapsed = time.time() - self.last_failure_time
                if elapsed >= self.recovery_timeout:
                    self.state = CircuitState.HALF_OPEN
                    print("[Circuit Breaker] HALF-OPEN — testing recovery")
                    return True
                return False
            return True  # HALF_OPEN: allow one probe

Step 2: Wrapping Database Calls

from tenacity import retry, stop_after_attempt, wait_exponential, RetryError
import psycopg2

db_circuit = DatabaseCircuitBreaker(failure_threshold=5, recovery_timeout=30)

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10)
)
def _execute_query(sql, params=None):
    conn = psycopg2.connect("postgresql://localhost/mydb")
    cursor = conn.cursor()
    cursor.execute(sql, params or ())
    result = cursor.fetchall()
    conn.close()
    return result

def safe_query(sql, params=None):
    if not db_circuit.can_attempt():
        raise Exception("Circuit OPEN — database unavailable, skipping request")
    try:
        result = _execute_query(sql, params)
        db_circuit.record_success()
        return result
    except RetryError as e:
        db_circuit.record_failure()
        raise Exception(f"Query failed after retries: {e}")
    except Exception as e:
        db_circuit.record_failure()
        raise

Step 3: Background Health Check for Automatic Recovery

The half-open state handles test requests from incoming traffic automatically. For background services with low traffic, add a periodic health probe so the circuit doesn’t stay open forever waiting for a real request:

def db_health_check(circuit_breaker, interval=15):
    """Background thread: probe DB when circuit is open."""
    while True:
        time.sleep(interval)
        if circuit_breaker.state == CircuitState.OPEN:
            try:
                conn = psycopg2.connect(
                    "postgresql://localhost/mydb",
                    connect_timeout=3
                )
                conn.close()
                circuit_breaker.record_success()
                print("[Health Check] Database recovered — circuit CLOSED")
            except Exception:
                print("[Health Check] Database still unavailable")

health_thread = threading.Thread(
    target=db_health_check,
    args=(db_circuit,),
    daemon=True
)
health_thread.start()

Step 4: Smoke Test

if __name__ == "__main__":
    for i in range(20):
        try:
            result = safe_query("SELECT NOW()")
            print(f"Query {i+1}: OK — {result}")
        except Exception as e:
            print(f"Query {i+1}: FAILED — {e}")
        time.sleep(0.5)

Tuning the Parameters for Your Setup

Two numbers matter most:

  • failure_threshold: Failures before the circuit opens. Set this too low (1-2) and you’ll trip on transient network hiccups. Too high and you’re still flooding a struggling database. Starting at 5 works for most setups.
  • recovery_timeout: Seconds to wait before allowing a probe request. 30 seconds is a safe default for Postgres. Under heavy load where the database needs longer to drain its connection queue, I’ve used 60-120 seconds.

One practical note from my own workflow: when I’m setting up test scenarios that need sample data, I often need to convert CSV fixtures to JSON for import scripts. I use toolcraft.app/en/tools/data/csv-to-json — it runs entirely in the browser, so no data leaves your machine. Useful when you’re working with anything close to real production data.

State Transitions at a Glance

CLOSED    → (failure_threshold reached) → OPEN
OPEN      → (recovery_timeout elapsed)  → HALF_OPEN
HALF_OPEN → (test request succeeds)     → CLOSED
HALF_OPEN → (test request fails)        → OPEN

What This Pattern Does Not Cover

A circuit breaker protects your app from overloading the database, but it sits alongside — not instead of — other reliability layers:

  • Connection pooling: Use PgBouncer or SQLAlchemy’s built-in pool to cap total concurrent connections at the OS level
  • Read replicas: Circuit-break against primary and replicas independently — they have different failure modes
  • Graceful degradation: When the circuit is open, decide what your API returns — cached data, an explicit 503, or a safe default value. Failing silently is worse than failing loudly

Once this is in place, database incidents that used to cascade into full application outages tend to self-heal in under a minute. The database gets breathing room, and your app reconnects automatically once it’s healthy. That 40-minute outage I mentioned at the start? After adding circuit breakers across our database layer, the same failure mode became a 45-second blip that resolved itself before anyone woke up.

Share: