Circuit Breaker em Python Puro: O Disjuntor Que Impede Seu Serviço de Virar Efeito Dominó Quando a API Cai

Você já viu isso: uma API externa começa a responder com timeout. Seu serviço tenta de novo. E de novo. E de novo. Em 30 segundos, você tem 200 threads travadas esperando resposta, a memória estourou, e o que era um problema em um endpoint virou queda geral. O nome disso é cascading failure — e o Circuit Breaker é o disjuntor que desliga o circuito antes do incêndio.

Hoje vou construir um Circuit Breaker completo em Python puro. Sem tenacity, sem pybreaker, sem dependência nenhuma. Só a stdlib. No final, você terá um decorator pronto para produção que protege qualquer função contra falhas em cascata.

O Problema: Por Que Retry Simples Não Resolve

O instinto quando uma chamada falha é tentar de novo. E faz sentido — falhas transitórias existem. Mas existe uma diferença brutal entre:

  • Falha transitória: a API tropeçou, tenta de novo em 2 segundos.
  • Falha persistente: a API caiu de verdade. Tentar de novo só queima recurso seu.

Sem Circuit Breaker, seu código trata os dois casos igual. E é aí que o efeito dominó começa.

O Padrão: Três Estados, Uma Lógica

Um Circuit Breaker tem três estados:

CLOSED — tudo normal, chamadas passam. Se falhar além do limite, abre o circuito.

OPEN — circuito aberto, chamadas são rejeitadas imediatamente (fail fast). Após um timeout, tenta uma chamada de teste.

HALF_OPEN — a chamada de teste passou? Volta para CLOSED. Falhou? Volta para OPEN.

É exatamente como um disjuntor elétrico. Abre quando esquenta demais. Testa se pode fechar. Fecha se a corrente normalizou.

O Código: Circuit Breaker Completo

"""Circuit Breaker em Python puro.
Zero dependências. Pronto para produção.
"""
import time
import threading
from enum import Enum
from functools import wraps
from typing import Callable, Optional, Type, Tuple


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


class CircuitBreakerOpen(Exception):
    """Levantada quando o circuito está aberto e a chamada é rejeitada."""
    def __init__(self, remaining_seconds: float):
        self.remaining_seconds = remaining_seconds
        super().__init__(
            f"Circuito aberto. Tente novamente em {remaining_seconds:.1f}s"
        )


class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 30.0,
        success_threshold: int = 2,
        excluded_exceptions: Optional[Tuple[Type[Exception], ...]] = None,
    ):
        """
        Args:
            failure_threshold: falhas consecutivas para abrir o circuito.
            recovery_timeout: segundos em OPEN antes de testar HALF_OPEN.
            success_threshold: sucessos consecutivos em HALF_OPEN para fechar.
            excluded_exceptions: exceções que NÃO contam como falha
                (ex: ValidationError -- é erro do cliente, não do serviço).
        """
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.success_threshold = success_threshold
        self.excluded_exceptions = excluded_exceptions or ()

        self._state = State.CLOSED
        self._failure_count = 0
        self._success_count = 0
        self._last_failure_time: Optional[float] = None
        self._lock = threading.Lock()

    @property
    def state(self) -> State:
        with self._lock:
            if self._state == State.OPEN:
                elapsed = time.monotonic() - self._last_failure_time
                if elapsed >= self.recovery_timeout:
                    self._state = State.HALF_OPEN
                    self._success_count = 0
            return self._state

    def call(self, func: Callable, *args, **kwargs):
        """Executa func dentro do Circuit Breaker."""
        current = self.state  # trigger transition check

        if current == State.OPEN:
            remaining = self.recovery_timeout - (
                time.monotonic() - self._last_failure_time
            )
            raise CircuitBreakerOpen(max(0, remaining))

        try:
            result = func(*args, **kwargs)
        except self.excluded_exceptions:
            raise  # não conta como falha do circuito
        except Exception as exc:
            self._on_failure()
            raise

        self._on_success()
        return result

    def _on_success(self):
        with self._lock:
            if self._state == State.HALF_OPEN:
                self._success_count += 1
                if self._success_count >= self.success_threshold:
                    self._reset()
            else:
                # CLOSED: sucesso reseta contador de falhas
                self._failure_count = 0

    def _on_failure(self):
        with self._lock:
            self._failure_count += 1
            self._last_failure_time = time.monotonic()

            if self._state == State.HALF_OPEN:
                self._state = State.OPEN
            elif self._failure_count >= self.failure_threshold:
                self._state = State.OPEN

    def _reset(self):
        self._state = State.CLOSED
        self._failure_count = 0
        self._success_count = 0

    def reset(self):
        """Reset manual (útil em testes)."""
        with self._lock:
            self._reset()


def circuit_breaker(
    failure_threshold: int = 5,
    recovery_timeout: float = 30.0,
    success_threshold: int = 2,
    excluded_exceptions: Optional[Tuple[Type[Exception], ...]] = None,
    fallback: Optional[Callable] = None,
):
    """Decorator para proteger qualquer função com Circuit Breaker.

    Args:
        fallback: função alternativa chamada quando o circuito está aberto.
                  Recebe os mesmos args da função original.
    """
    breaker = CircuitBreaker(
        failure_threshold=failure_threshold,
        recovery_timeout=recovery_timeout,
        success_threshold=success_threshold,
        excluded_exceptions=excluded_exceptions,
    )

    def decorator(func: Callable) -> Callable:
        @wraps(func)
        def wrapper(*args, **kwargs):
            try:
                return breaker.call(func, *args, **kwargs)
            except CircuitBreakerOpen:
                if fallback is not None:
                    return fallback(*args, **kwargs)
                raise

        wrapper.breaker = breaker  # expõe o breaker para inspeção
        return wrapper

    return decorator

Usando na Prática

Protegendo uma chamada HTTP

import urllib.request
import json


def _fallback_busca(query):
    """Cache local quando a API está fora."""
    return {"results": [], "source": "fallback", "query": query}


@circuit_breaker(
    failure_threshold=3,
    recovery_timeout=60.0,
    success_threshold=2,
    fallback=_fallback_busca,
)
def buscar(query: str) -> dict:
    url = f"https://api.exemplo.com/busca?q={urllib.parse.quote(query)}"
    req = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=5) as resp:
        return json.loads(resp.read().decode())


# Uso normal
resultado = buscar("python circuit breaker")
print(resultado)

# Inspecionar estado do breaker
print(buscar.breaker.state)  # State.CLOSED

Protegendo queries de banco

import sqlite3

# Exceções de validação não devem abrir o circuito
class QueryInvalida(Exception):
    pass


@circuit_breaker(
    failure_threshold=5,
    recovery_timeout=120.0,
    excluded_exceptions=(QueryInvalida,),
)
def executar_query(sql: str, params: tuple = ()) -> list:
    if not sql.strip().upper().startswith("SELECT"):
        raise QueryInvalida("Apenas SELECT permitido aqui")

    conn = sqlite3.connect("app.db")
    try:
        cursor = conn.execute(sql, params)
        return cursor.fetchall()
    finally:
        conn.close()


try:
    rows = executar_query("SELECT * FROM usuarios WHERE ativo = ?", (1,))
except CircuitBreakerOpen as e:
    print(f"Banco indisponível, retry em {e.remaining_seconds:.0f}s")
except QueryInvalida:
    print("Query inválida -- isso não afeta o circuito")

Monitoramento: O Circuit Breaker Precisa Falar

Um Circuit Breaker que não emite logs é um disjuntor no escuro. Você não sabe quando abriu até tudo parar. Vamos adicionar observabilidade:

import logging

logger = logging.getLogger("circuit_breaker")


class ObservableCircuitBreaker(CircuitBreaker):
    def __init__(self, name: str = "default", **kwargs):
        super().__init__(**kwargs)
        self.name = name

    def _on_failure(self):
        old_state = self._state
        super()._on_failure()
        logger.warning(
            "[CB:%s] falha #%d | %s -> %s",
            self.name,
            self._failure_count,
            old_state.value,
            self._state.value,
        )

    def _on_success(self):
        old_state = self._state
        super()._on_success()
        if old_state != State.CLOSED:
            logger.info(
                "[CB:%s] sucesso | %s -> %s",
                self.name,
                old_state.value,
                self._state.value,
            )


# Uso com nome para identificar nos logs
breaker_api = ObservableCircuitBreaker(
    name="api-pagamentos",
    failure_threshold=3,
    recovery_timeout=60.0,
)

Testando: Simulando Falhas em Cascata

"""Teste que demonstra o Circuit Breaker funcionando."""
import time

cb = CircuitBreaker(failure_threshold=3, recovery_timeout=2.0, success_threshold=1)

call_count = 0


def unstable_service():
    global call_count
    call_count += 1
    raise ConnectionError("API fora do ar")


# Fase 1: falhas acumulam até abrir
for i in range(3):
    try:
        cb.call(unstable_service)
    except ConnectionError:
        pass

assert cb.state == State.OPEN
assert call_count == 3
print(f"Circuito abriu após {call_count} falhas")

# Fase 2: chamadas são rejeitadas sem tocar o serviço
for i in range(10):
    try:
        cb.call(unstable_service)
    except CircuitBreakerOpen:
        pass

assert call_count == 3  # não aumentou!
print(f"10 chamadas rejeitadas. Serviço não foi tocado (calls={call_count})")

# Fase 3: após recovery_timeout, teste em HALF_OPEN
time.sleep(2.1)
assert cb.state == State.HALF_OPEN
print("Circuito em HALF_OPEN, testando...")

# Simula recuperação
def stable_service():
    return "ok"

result = cb.call(stable_service)
assert cb.state == State.CLOSED
print(f"Circuito fechou. Resultado: {result}")

Box Perrengue: O Dia que o Circuit Breaker Me Salvou de uma Conta de R$ 4.000

Eu tinha um serviço que consumia uma API de geolocalização. Cobrança por request. Numa terça-feira, a API começou a dar 503. Meu código fazia retry com backoff exponencial — 1s, 2s, 4s, 8s, 16s. Parecia esperto.

Só que eu tinha 50 workers processando uma fila de 10.000 endereços. Cada worker tentava, falhava, esperava, tentava de novo. Em 20 minutos, eu tinha disparado mais de 12.000 requests para a API caída. A boa notícia: a maioria foi rejeitada com 503, sem cobrança. A má notícia: quando a API voltou, os workers ainda tinham milhares de itens na fila e dispararam tudo de uma vez.

A conta veio salgada porque os picos de request foram taxados como “burst”. Com Circuit Breaker, o circuito teria aberto no worker 3. Quando a API voltasse, o HALF_OPEN teria testado com uma única chamada antes de liberar o fluxo. A diferença entre R$ 80 e R$ 4.000 era um disjuntor de 30 linhas.

Quando NÃO Usar Circuit Breaker

Nem tudo precisa de Circuit Breaker. Evite quando:

Chamadas locais sem I/O. Funções puras de CPU não travam em cascata. O Circuit Breaker adiciona latência de lock desnecessária.

Operações idempotentes com retry natural. Se já tem retry inteligente e a operação é barata, o Circuit Breaker pode ser overkill.

Comunicação síncrona single-thread. Sem concorrência, não há efeito dominó. Um simples try/except resolve.

Arquitetura: Circuit Breaker vs. Retry vs. Bulkhead

Esses três padrões são complementares, não concorrentes:

Retry resolve falhas transitórias. Circuit Breaker resolve falhas persistentes. Bulkhead isola recursos para que um serviço não consuma todos os threads dos outros.

A combinação ideal:

"""Retry + Circuit Breaker + Bulkhead (thread pool isolado)."""
import concurrent.futures

# Bulkhead: pool dedicado para esta API (não compartilha threads)
api_pool = concurrent.futures.ThreadPoolExecutor(
    max_workers=5, thread_name_prefix="api-geo"
)


@circuit_breaker(failure_threshold=3, recovery_timeout=30.0)
def geolocalizar(endereco: str) -> dict:
    req = urllib.request.Request(
        f"https://api.geo.com/v1/locate?q={urllib.parse.quote(endereco)}",
        headers={"Accept": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=5) as resp:
        return json.loads(resp.read().decode())


def geolocalizar_com_retry(endereco: str, max_retries: int = 2) -> dict:
    """Retry DENTRO do Circuit Breaker (não fora)."""
    last_exc = None
    for attempt in range(max_retries + 1):
        try:
            return geolocalizar(endereco)
        except (ConnectionError, TimeoutError) as e:
            last_exc = e
            if attempt < max_retries:
                time.sleep(1.0 * (attempt + 1))
    raise last_exc


# Uso via bulkhead
future = api_pool.submit(geolocalizar_com_retry, "Rua Augusta, SP")
resultado = future.result(timeout=15)

CTA: Qual Serviço Seu Está Desprotegido?

Abre o terminal agora. Conta quantas chamadas HTTP externas seu código faz sem proteção. Se a resposta for “mais de zero”, cola esse Circuit Breaker ali. São 80 linhas, zero dependências, e já salvou minha conta de R$ 4.000.

Qual foi a última vez que uma API externa derrubou seu serviço inteiro? E o que você fez pra impedir que acontecesse de novo? Conta aí nos comentários — bora trocar perrengue.

Posts Similares