Из-за временных ограничений на территории РФ наблюдаются проблемы с оплатой. Если платёж не проходит, оставьте запрос в службу поддержки.Служба поддержки работает 24/7 — мы всегда на связи по вопросам хостинга и серверов.Открыт прием заявок на аренду выделенных серверов и размещение оборудования в дата-центре.Напоминаем: рекомендуем включить резервное копирование для дополнительной защиты данных.Доступна новая линейка VPS/VDS с NVMe-дисками и увеличенной производительностью.Технические работы на части серверов завершены. Все сервисы работают в штатном режиме.
Статья10 мин чтенияПросмотры0

Внешний API отвечает ошибками: проверяем circuit breaker без каскада тайм-аутов

Практическое руководство по circuit breaker: три технических сбоя открывают цепь, запросы перестают доходить до внешнего API, неудачная проба продлевает паузу, а успешная возвращает обычный режим.

Комментарии 0

Специалист закрывает защитный шлюз перед линией, пропуская одну проверочную капсулу
В этой статье

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

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

Три состояния и один измеримый результат

Состояние

Что делает клиент

Что проверить

closed

Передаёт вызовы внешнему API и считает технические сбои

После успешного ответа счётчик сбрасывается

open

Немедленно отклоняет вызовы без обращения к зависимости

Число вызовов внешнего API не растёт

half-open

После паузы пропускает ограниченную пробу

Успех закрывает цепь, технический сбой снова открывает

Эта модель соответствует описанию паттерна Circuit Breaker в Microsoft Learn: открытое состояние не лечит внешнюю систему, а даёт ей время восстановиться и защищает вызывающую сторону от каскада ожиданий.

Какие ошибки считать отказом зависимости

  • Тайм-аут соединения или чтения, когда подтверждён ответственный внешний узел.

  • Разрыв соединения и документированные серверные ответы о временной недоступности.

  • Исчерпание соединений или другая локальная причина — только после диагностики: такой сбой может быть внутри самого клиента.

  • Бизнес-отказ вроде «неизвестный SKU» или «недостаточно прав» не открывает цепь: сервис ответил, а повтор того же запроса без изменения данных не устранит причину.

По одному коду ответа выводить рано. Например, HTTP 429 может означать ограничение частоты: паузу задаёт политика поставщика и иногда заголовок Retry-After. HTTP 500 может быть как кратким сбоем, так и устойчивой ошибкой конкретного запроса. Классификатор ошибок должен быть отдельным и проверяемым правилом интеграции.

Стенд и начальные условия

  • CPython 3.12.14, только стандартная библиотека; проверка выполнена 27 октября 2026 года.

  • Изолированная модель без сети, файлов, базы данных и фоновых процессов.

  • Порог — три подряд технических сбоя; пауза перед пробой — 10 условных секунд.

  • Время управляется FakeClock, поэтому тест не ждёт реальные 10 секунд и воспроизводится одинаково.

  • Фиктивный API возвращает заранее заданную последовательность: бизнес-отказ, четыре технических сбоя, два успешных ответа.

Сохраните пример в новом временном каталоге как circuit_breaker_lab.py и запустите обычным пользователем. Он ничего не записывает и не удаляет вне самого файла с примером.

from collections import deque from dataclasses import dataclass from typing import Callable class DependencyUnavailable(RuntimeError): pass class BusinessRejected(RuntimeError): pass class CircuitOpen(RuntimeError): pass @dataclass class FakeClock: value: float = 0.0 def now(self) -> float: return self.value def advance(self, seconds: float) -> None: if seconds < 0: raise ValueError("seconds must be non-negative") self.value += seconds class CircuitBreaker: def __init__( self, failure_threshold: int, reset_timeout: float, clock: Callable[[], float], ) -> None: if failure_threshold < 1 or reset_timeout <= 0: raise ValueError("invalid circuit breaker settings") self.failure_threshold = failure_threshold self.reset_timeout = reset_timeout self.clock = clock self.state = "closed" self.failures = 0 self.opened_at: float | None = None def _open(self) -> None: self.state = "open" self.opened_at = self.clock() def call(self, operation: Callable[[], str]) -> str: if self.state == "open": assert self.opened_at is not None if self.clock() - self.opened_at < self.reset_timeout: raise CircuitOpen("dependency call is temporarily blocked") self.state = "half-open" try: result = operation() except DependencyUnavailable: if self.state == "half-open": self._open() else: self.failures += 1 if self.failures >= self.failure_threshold: self._open() raise except BusinessRejected: # The dependency answered: the request itself was rejected. raise else: self.state = "closed" self.failures = 0 self.opened_at = None return result class FakeSupplierApi: def __init__(self, outcomes: list[str]) -> None: self.outcomes = deque(outcomes) self.calls = 0 def update_stock(self) -> str: self.calls += 1 outcome = self.outcomes.popleft() if outcome == "unavailable": raise DependencyUnavailable("supplier API timed out") if outcome == "business_rejected": raise BusinessRejected("unknown SKU") return outcome clock = FakeClock() api = FakeSupplierApi([ "business_rejected", "unavailable", "unavailable", "unavailable", "unavailable", "stock-updated", "stock-updated-again", ]) breaker = CircuitBreaker(failure_threshold=3, reset_timeout=10, clock=clock.now) try: breaker.call(api.update_stock) except BusinessRejected: pass assert breaker.state == "closed" and breaker.failures == 0 print(f"business error: state={breaker.state} failures={breaker.failures} calls={api.calls}") for _ in range(3): try: breaker.call(api.update_stock) except DependencyUnavailable: pass assert breaker.state == "open" and api.calls == 4 print(f"threshold: state={breaker.state} failures={breaker.failures} calls={api.calls}") try: breaker.call(api.update_stock) except CircuitOpen: pass assert api.calls == 4 print(f"blocked: state={breaker.state} dependency_calls={api.calls}") clock.advance(10) try: breaker.call(api.update_stock) except DependencyUnavailable: pass assert breaker.state == "open" and api.calls == 5 print(f"failed probe: state={breaker.state} dependency_calls={api.calls}") try: breaker.call(api.update_stock) except CircuitOpen: pass assert api.calls == 5 clock.advance(10) assert breaker.call(api.update_stock) == "stock-updated" assert breaker.state == "closed" and breaker.failures == 0 and api.calls == 6 print(f"successful probe: state={breaker.state} dependency_calls={api.calls}") assert breaker.call(api.update_stock) == "stock-updated-again" assert api.calls == 7 print(f"normal call: state={breaker.state} dependency_calls={api.calls}")

Ожидаемый и полученный вывод:

business error: state=closed failures=0 calls=1 threshold: state=open failures=3 calls=4 blocked: state=open dependency_calls=4 failed probe: state=open dependency_calls=5 successful probe: state=closed dependency_calls=6 normal call: state=closed dependency_calls=7

Процесс завершился с кодом 0. Все переходы дополнительно закреплены assert-проверками: если вызов всё-таки дойдёт до внешней зависимости при открытой цепи или счётчик не сбросится после успеха, пример остановится с ошибкой.

Что именно подтвердил прогон

Сценарий

Наблюдение

Критерий результата

Бизнес-отказ

Состояние closed, технических сбоев 0

Отказ запроса не ухудшает состояние зависимости

Три технических сбоя

Состояние open после четвёртого общего вызова

Порог открывает цепь

Вызов до истечения паузы

Счётчик зависимости остаётся 4

Запрос заблокирован локально

Неудачная проба

Состояние снова open, вызовов зависимости 5

Пауза начинается заново

Успешная проба

Состояние closed, вызовов зависимости 6

Обычный режим восстановлен

Следующий запрос

Успех, вызовов зависимости 7

После восстановления трафик проходит

Почему для паузы нужны монотонные часы

В производственном коде длительность паузы лучше измерять монотонными часами: они не идут назад при коррекции системного времени. В Python для этого предназначена функция time.monotonic. Календарное время оставьте для журналов и меток событий.

Учебный FakeClock реализует то же важное свойство: его значение меняется только явным advance. Это позволяет отдельно проверить момент до границы паузы и саму границу, не превращая тест в десятисекундное ожидание.

Circuit breaker не заменяет повтор

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

Если поставщик вернул 429 или 503 с Retry-After, сначала проверьте форму заголовка и рассчитанную паузу. Локальный reset_timeout не должен заставлять клиента обращаться раньше явно указанного допустимого момента.

Что добавить перед рабочим запуском

  1. Согласовать список учитываемых ошибок для каждого внешнего метода, а не для API целиком.

  2. Задать порог и паузу по наблюдаемому времени восстановления, лимитам поставщика и допустимой задержке бизнеса.

  3. Ограничить число half-open проб при конкуренции. В нескольких процессах нужна общая атомарная координация либо отдельная цепь на процесс с понятными последствиями.

  4. Разделить цепи по независимым зависимостям и операциям: сбой обновления остатков не обязан останавливать чтение статуса заказа.

  5. Добавить метрики переходов closed/open/half-open, число локально отклонённых вызовов и результат проб.

  6. Определить поведение вызывающего процесса: поставить задачу в очередь, вернуть контролируемую ошибку или использовать допустимые устаревшие данные.

  7. Проверить восстановление после перезапуска приложения и синхронизацию состояния между экземплярами.

Для операции записи отдельно решите вопрос идемпотентности. Если клиент получил тайм-аут, внешний сервис мог применить изменение, хотя ответ не дошёл. Открытие цепи предотвращает новые вызовы, но не выясняет судьбу уже отправленной команды.

Границы модели

  • Проверен один процесс без потоков и асинхронных задач; конкурентный доступ к half-open состоянию не моделировался.

  • Не проверялись реальные сетевые библиотеки, DNS, TLS, прокси, лимиты соединений и формат ответов конкретного поставщика.

  • Порог 3 и пауза 10 секунд выбраны для учебного прогона, а не как универсальные рабочие значения.

  • Пример не сохраняет состояние между перезапусками и не координирует несколько экземпляров приложения.

  • Модель не повторяет запросы и не меняет данные: она проверяет только допуск вызова к зависимости.

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

Обсуждение 0

Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.

Пока никто не написал. Начни обсуждение.