← назад к разделу

Когда приложение обращается к одному-единственному сервису или базе данных, сбои редки. Но в распределённых системах вызовы к другим сервисам — норма. А значит, норма и то, что что-то упадёт: сеть потеряет пакеты, сервис перезапустится, база данных уйдёт на переключение.

Вопрос не «упадёт ли?», а «что произойдёт с остальной системой, когда это случится?».

Паттерны отказоустойчивости не предотвращают сбои — они не дают одному сбою каскадно уронить всё вокруг.

Retry — повторить при временной ошибке

Сетевой вызов упал. Причина может быть случайной: перезапуск пода, кратковременная перегрузка, пауза при сборке мусора. Если попробовать ещё раз через секунду — сработает.

Но если ретраить моментально, сто клиентов одновременно обрушиваются на уже больной сервис. Если не ретраить совсем — теряем запрос из-за случайного сбоя.

Решение — повторять с нарастающей задержкой (Exponential Backoff):

Попытка 1: сразу
Попытка 2: через 1 сек
Попытка 3: через 2 сек
Попытка 4: через 4 сек
Попытка 5: через 8 сек (не больше 30 сек)
@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, max=30),
    retry=retry_if_exception_type(RetryableError),
)
async def call_payment_gateway(request: PaymentRequest) -> PaymentResult:
    return await payment_client.charge(request)

Jitter — чтобы не долбить сервис одновременно

Все сто клиентов получили ошибку в один момент. Все ждут одну секунду. Все ретраят одновременно. Сервис снова падает. Это называется «эффект толпы» (thundering herd).

Jitter добавляет случайный разброс к задержке — клиенты рассредоточиваются во времени:

delay = min(max_delay, base_delay * multiplier ** attempt)
jitter = random.uniform(0, delay / 2)
time.sleep(delay + jitter)

# в tenacity есть готовая стратегия
@retry(wait=wait_exponential_jitter(initial=1, max=30))
async def call_service() -> None: ...

Не все запросы безопасно повторять

  • Безопасно: операции, которые можно повторить без побочных эффектов — GET, PUT с фиксированным ключом, DELETE по идентификатору.
  • Опасно: POST без ключа идемпотентности. Двойная отправка может создать два заказа.

Для неидемпотентных операций используют Idempotency Key — клиент генерирует UUID, сервер проверяет: если запрос с таким ключом уже обработан, возвращает сохранённый результат вместо повторного выполнения.

Circuit Breaker — «автомат» на случай падения сервиса

Платёжный шлюз лёг. Сто потоков ждут ответа по тридцать секунд. Через минуту все потоки заняты. Ваш сервис перестаёт отвечать на любые запросы — даже те, что с оплатой не связаны. Это каскадный отказ.

Circuit Breaker — буквально «выключатель». Он следит за процентом ошибок и, если их слишком много, перестаёт отправлять запросы: сразу возвращает ошибку, не тратя время на ожидание.

diagram

Три состояния:

  • CLOSED — нормальная работа. Запросы проходят, считаем процент ошибок в скользящем окне.
  • OPEN — выключатель сработал. Запросы мгновенно отклоняются без ожидания. Ждём таймаут.
  • HALF_OPEN — пропускаем несколько пробных запросов. Успешны — переходим в CLOSED. Нет — обратно в OPEN.

Пример конфигурации (aiobreaker):

payment_breaker = CircuitBreaker(
    fail_max=5,                              # 5 ошибок подряд → OPEN
    timeout_duration=timedelta(seconds=30),  # через 30 сек → HALF_OPEN
    exclude=[OrderError],                    # бизнес-ошибки не считаются сбоями
)


@payment_breaker
async def charge(user_id: int, amount: Money) -> PaymentResult:
    return await payment_client.charge(user_id, amount.to_kopecks())


async def charge_or_fail_fast(user_id: int, amount: Money) -> PaymentResult:
    try:
        return await charge(user_id, amount)
    except CircuitBreakerError:
        raise ServiceTemporarilyUnavailableError("Payment service")

Важный нюанс: OrderError.NotFound (404) — это бизнес-ответ, не сбой. Если Circuit Breaker будет считать 404 ошибкой, то при серии «заказ не найден» он заблокирует все запросы к платёжному шлюзу. Поэтому exclude — обязательная настройка.

Timeout — не ждать вечно

Сервис не отвечает. Без таймаута поток висит бесконечно, занимая память и ресурсы. Сто таких потоков — и всё останавливается.

Любой сетевой вызов должен ограничиваться по времени:

async def charge_async(user_id: int, amount: Money) -> PaymentResult:
    async with asyncio.timeout(5):
        return await payment_client.charge(user_id, amount.to_kopecks())
# таймауты на уровне HTTP-клиента
client = httpx.AsyncClient(
    timeout=httpx.Timeout(5.0, connect=2.0),
)

Это касается не только HTTP. Любой вызов к базе данных, Redis, gRPC, очереди сообщений — везде должен быть таймаут.

Как сочетаются Retry, Circuit Breaker и Timeout

Три паттерна работают вместе, и порядок важен:

Circuit Breaker → Retry → Timeout → Внешний сервис
  • Circuit Breaker — снаружи. Если цепь разомкнута, не тратим время на ретраи.
  • Retry — в середине. Повторяет при временном сбое.
  • Timeout — внутри. Каждая отдельная попытка ограничена по времени.

Bulkhead — изоляция ресурсов

Сервис вызывает три внешних системы: платёжный шлюз, склад, страховую. Платёжный шлюз тормозит — все двести потоков заняты ожиданием. Запросы к складу и страховой тоже встают в очередь, хотя эти системы работают нормально.

Bulkhead — по аналогии с переборками в корабле. Каждый отсек герметичен: затопление одного не топит остальные.

diagram

Два варианта реализации:

Thread Pool Isolation — каждый внешний вызов идёт в отдельном пуле потоков. Зависший вызов не занимает основной пул. Overhead на переключение потоков.

Semaphore Isolation — ограничение числа одновременных вызовов без отдельного пула. Легче по ресурсам, но если вызов завис — поток основного пула тоже занят.

# Thread Pool Isolation — отдельный пул на каждую систему
payment_pool = ThreadPoolExecutor(max_workers=20, thread_name_prefix="payment")
inventory_pool = ThreadPoolExecutor(max_workers=20, thread_name_prefix="inventory")

# Semaphore Isolation — ограничение одновременных вызовов в asyncio
payment_semaphore = asyncio.Semaphore(20)


async def charge(user_id: int, amount: Money) -> PaymentResult:
    async with payment_semaphore:
        return await payment_client.charge(user_id, amount.to_kopecks())

Для HTTP-вызовов с таймаутами обычно достаточно Semaphore. Thread Pool нужен, когда таймауты ненадёжны или не контролируются.

Fallback — запасной ответ

Если основной путь недоступен — используем запасной. Не всегда нужен идеальный ответ, иногда «приемлемый» лучше, чем ошибка.

Три варианта запасного ответа:

Значение по умолчанию — когда точные данные недоступны, отдаём разумную замену:

@rate_breaker
async def fetch_rate(currency: str) -> ExchangeRate:
    return await exchange_rate_client.get_rate(currency)


async def get_rate(currency: str) -> ExchangeRate:
    try:
        return await fetch_rate(currency)
    except (CircuitBreakerError, ExchangeRateError):
        cached = exchange_rate_cache.get_latest(currency)
        return cached if cached is not None else ExchangeRate.fallback(currency)

Кэшированный ответ — при сбое отдаём последние известные данные:

@catalog_breaker
async def fetch_products(category: str) -> list[Product]:
    products = await catalog_client.get_products(category)
    product_cache.put(category, products)
    return products


async def get_products(category: str) -> list[Product]:
    try:
        return await fetch_products(category)
    except (CircuitBreakerError, CatalogError):
        return product_cache.get(category) or []

Упрощённая логика — вместо персонализированных рекомендаций отдаём популярные товары:

@recommendation_breaker
async def fetch_personalized(user_id: int) -> list[Product]:
    return await recommendation_client.get_personalized(user_id)


async def get_recommendations(user_id: int) -> list[Product]:
    try:
        return await fetch_personalized(user_id)
    except (CircuitBreakerError, RecommendationError):
        return product_repository.find_top_by_popularity(limit=10)

Когда fallback не подходит: если платёжный шлюз недоступен, нельзя «примерно» списать деньги. Здесь правильный ответ — честная ошибка «сервис временно недоступен, попробуйте позже».

Rate Limiter — защита от перегрузки

Внешний API ограничивает вас: 100 запросов в секунду. Превышение — бан на пять минут. Или ваш собственный сервис получает всплеск трафика — нужно защититься.

payment_api_limiter = AsyncLimiter(max_rate=50, time_period=1.0)


async def register(request: PaymentRegisterDto) -> PaymentResponse:
    async with payment_api_limiter:
        return await payment_client.register(request)

Основные алгоритмы:

  • Fixed Window — счётчик сбрасывается в начале каждого окна. Простой, но на границе окон может пропустить вдвое больше лимита.
  • Sliding Window — скользящее окно, более точное ограничение.
  • Token Bucket — токены накапливаются с постоянной скоростью. Позволяет кратковременные всплески, если есть накопленные токены.
  • Leaky Bucket — запросы обрабатываются с постоянной скоростью. Сглаживает всплески, но добавляет задержку.

Dead Letter Queue — что делать с необрабатываемыми сообщениями

Сообщение в Kafka не обрабатывается: битые данные, неизвестный формат, бизнес-валидация не проходит. Бесконечные повторы бессмысленны — сообщение «отравлено» и блокирует обработку следующих.

Dead Letter Queue (DLQ, очередь мёртвых писем) — после нескольких неудачных попыток сообщение перекладывается в отдельную очередь для разбора вручную.

diagram
@retry(
    stop=stop_after_attempt(3),
    wait=wait_fixed(1),
    retry=retry_if_not_exception_type((ValidationError, json.JSONDecodeError)),
)
async def handle(msg: ConsumerRecord) -> None:
    await process_order_event(msg.value)


async def consume_order_events(
    consumer: AIOKafkaConsumer, producer: AIOKafkaProducer,
) -> None:
    async for msg in consumer:
        try:
            await handle(msg)
        except Exception:
            # 3 неудачи или неретраебельная ошибка → в DLQ
            await producer.send_and_wait(
                f"{msg.topic}.dlq", msg.value, partition=msg.partition)

Что делать с DLQ:

  • Мониторинг — настроить алерт, когда в DLQ появляются сообщения.
  • Повторная отправка — после устранения причины переотправить в основной топик. Осторожно: если причина не устранена, сообщение снова попадёт в DLQ.
  • Ручной разбор — для бизнес-ошибок, которые нельзя автоматизировать.

Как паттерны работают вместе

На практике паттерны комбинируются. Полный стек для одного внешнего вызова выглядит так (порядок снаружи → внутрь):

  1. Rate Limiter — если лимит исчерпан, не тратим ресурсы дальше.
  2. Bulkhead — если пул заполнен, не создаём новые вызовы.
  3. Circuit Breaker — если сервис недоступен, мгновенный отказ или fallback.
  4. Retry — если вызов упал, повторяем с задержкой.
  5. Timeout — каждая попытка ограничена по времени.
payment_limiter = AsyncLimiter(max_rate=100, time_period=1.0)
payment_semaphore = asyncio.Semaphore(25)
payment_breaker = CircuitBreaker(fail_max=5, timeout_duration=timedelta(seconds=30))


async def call_payment(request: PaymentRequest) -> PaymentResult:
    async with payment_limiter:                     # 1. Rate Limiter
        async with payment_semaphore:               # 2. Bulkhead
            return await charge_protected(request)


@payment_breaker                                    # 3. Circuit Breaker
@retry(
    stop=stop_after_attempt(3),                     # 4. Retry
    wait=wait_exponential(multiplier=1),
    retry=retry_if_exception_type(RetryableError),
)
async def charge_protected(request: PaymentRequest) -> PaymentResult:
    async with asyncio.timeout(5):                  # 5. Timeout
        return await payment_client.charge(request)

Коротко

  • Retry + Exponential Backoff — переживаем кратковременные сбои; Jitter предотвращает одновременный шквал повторов.
  • Circuit Breaker — не тратим ресурсы на недоступный сервис; три состояния: CLOSED / OPEN / HALF_OPEN.
  • Timeout — любой сетевой вызов без таймаута — потенциальная утечка потоков.
  • Bulkhead — изолируем ресурсы; один проблемный сервис не блокирует остальные.
  • Fallback — деградируем, но не падаем; не всегда применим (платёж — честная ошибка, не «примерный» ответ).
  • Rate Limiter — защищаем от перегрузки себя и внешние API.
  • DLQ — не теряем и не зацикливаемся на «отравленных» сообщениях.
  • Порядок обёртки: Rate Limiter → Bulkhead → Circuit Breaker → Retry → Timeout.

Что почитать дальше

  • Распределённые паттерны — Saga и Outbox для согласованности данных между сервисами.
  • Apache Kafka — DLQ и идемпотентный потребитель в деталях.