Короткий ответ
Нужно сочетание нескольких механизмов: жёсткий timeout на каждый вызов, circuit breaker, быстро прекращающий попытки при устойчивом отказе, изоляция ресурсов (отдельный пул соединений/потоков на эту интеграцию, чтобы её деградация не исчерпывала общие ресурсы сервиса), и заранее определённое поведение fallback (деградированный ответ или честная ошибка) — конкретные параметры и выбор fallback зависят от критичности зависимости для основной функциональности сервиса.
Как это работает подробнее
без изоляции ресурсов (паттерн bulkhead) медленные вызовы к одному внешнему сервису могут занять все доступные потоки/соединения пула, из-за чего даже запросы, не связанные с этим внешним сервисом, начинают отказывать — это классический каскадный отказ. Circuit breaker, отслеживая долю/количество ошибок за окно времени, переходит в "открытое" состояние и быстро отказывает новым вызовам без реальной попытки, давая внешнему сервису время восстановиться и защищая собственные ресурсы от трат на заведомо обречённые вызовы.
Пример кода
import time
from concurrent.futures import ThreadPoolExecutor
# Bulkhead: отдельный пул ресурсов на конкретную внешнюю интеграцию
partner_pool = ThreadPoolExecutor(max_workers=10)
class CircuitBreaker:
def __init__(self, failure_threshold=5, reset_timeout=30):
self.failure_count = 0
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.opened_at = None
def call(self, func, *args, **kwargs):
if self.opened_at is not None:
if time.time() - self.opened_at < self.reset_timeout:
raise RuntimeError("circuit open: fast-failing without calling dependency")
self.opened_at = None # half-open: пробуем снова
try:
result = func(*args, **kwargs)
self.failure_count = 0
return result
except Exception:
self.failure_count += 1
if self.failure_count >= self.failure_threshold:
self.opened_at = time.time()
raiseЧто использовать на практике
- Рекомендация. параметры (порог ошибок для открытия circuit breaker, время до перехода в half-open для пробного вызова, размер изолированного пула) должны подбираться на основе реального профиля трафика и наблюдаемого поведения зависимости под нагрузкой, а не копироваться "по умолчанию" из библиотеки — слишком чувствительный breaker будет открываться на кратковременные всплески задержки, слишком нечувствительный не защитит вовремя. Fallback-поведение нужно решать для каждой интеграции отдельно: для некритичной зависимости — вернуть кэшированные/дефолтные данные с явной пометкой деградации; для критичной (например, проверка остатка средств перед списанием) — вернуть честную ошибку, а не рискованное приближение. Компромисс: более агрессивная изоляция и более чувствительный circuit breaker снижают риск каскадного отказа, но увеличивают операционную сложность (больше настроек для мониторинга и подстройки, больше ложных срабатываний при кратковременных всплесках) и могут излишне часто показывать деградированный fallback пользователям даже при частичных, восстановимых сбоях; менее агрессивная защита проще в эксплуатации, но оставляет риск, что единичный медленный внешний сервис обрушит доступность всего backend. Выбор конкретных порогов и того, использовать ли fallback или честную ошибку, зависит от критичности зависимости, SLA перед собственными пользователями и готовности команды поддерживать более сложную конфигурацию отказоустойчивости.
Подводные камни
- Риски. использовать общий пул соединений/поток-пул для всех внешних интеграций сервиса, из-за чего отказ одной зависимости исчерпывает ресурсы для всех остальных; настраивать circuit breaker только по количеству подряд идущих ошибок без учёта окна времени, из-за чего он слишком медленно реагирует при низкой частоте запросов или слишком резко — при единичных всплесках; не тестировать поведение системы при реальном отказе зависимости (chaos-тестирование/fault injection), полагаясь, что механизмы "должны сработать" без проверки.