Перейти к содержанию

Circuit breaker должен быть отдельным для каждого origin, а не клиента

Circuit breaker проще всего объяснить и проще всего привязать не к тому ключу. Объяснение помещается в предложение: после достаточного числа сбоев ненадолго прекратить вызовы, затем проверить восстановление. Ошибка помещается в имя переменной: счётчик принадлежит клиенту, а клиент обращается к трём сервисам. Один падает, счётчик заполняется, автомат размыкает цепь, и два исправных сервиса тоже становятся недоступны. Я выпускал такую реализацию и снова измерил её для статьи: один клиент, три внешних сервиса, один неисправен, десять кругов запросов. Автомат с ключом по клиенту отклонил половину запросов к двум исправным сервисам.

Числа получены экспериментальным скриптом статьи с тремя origin внутри процесса. Версии: clientwright 0.2.2, httpx 0.28.1, Python 3.13.

Измерение: один счётчик, три внешних сервиса

Сервис заказов через один HTTP-клиент вызывает stock, pricing и reviews. reviews недоступен и на всё отвечает 503. Запросы идут по кругу: десять кругов, тридцать вызовов. Сначала — типичная самописная защита: окно последних двадцати результатов, порог пять сбоев, одно окно на клиент:

breaker keyed on the client (hand-rolled)
    stock      {'200': 5, 'refused by breaker': 5}
    pricing    {'200': 5, 'refused by breaker': 5}
    reviews    {'503': 5, 'refused by breaker': 5}

Пять сбоев reviews разомкнули цепь, после чего клиент отклонял всё, включая обращения к двум сервисам, до этого всегда отвечавшим 200. Приложение потеряло доступ к остаткам и ценам на весь период ожидания восстановления из-за третьей, не связанной с ними зависимости. Такая защита превращает один сбой в три.

Тот же поток запросов, но ключ автомата теперь — origin:

breaker keyed on the origin (clientwright)
    stock      {'200': 10}
    pricing    {'200': 10}
    reviews    {'503': 5, 'refused by breaker': 5}

Пять сбоев разомкнули цепь только для reviews. stock и pricing ничего не заметили. Задача автомата — остановить трафик к зависимости, доказавшей свою недоступность. Зависимость здесь — origin: схема, хост и порт. Один внешний сервис — одна оценка работоспособности. Клиент — деталь реализации вызывающей стороны; собственной работоспособности у него нет.

Что считать сбоем

Вторая распространённая ошибка — учитывать неправильные события. По десять вызовов для четырёх вариантов ответа, порог пять:

429 Too Many Requests      {'429': 10}
503 Service Unavailable    {'503': 5, 'refused by breaker': 5}
connection refused         {'ConnectError': 5, 'refused by breaker': 5}
404 Not Found              {'404': 10}

503 и отказ в соединении означают, что зависимость не может обслужить запрос; после пяти таких событий цепь размыкается. 429 означает, что обслуживать запросы она может, но вы обращаетесь слишком часто. Размыкание превратило бы ограничение частоты в недоступность; правильная реакция — увеличить паузу. 404 — полноценный ответ: ошибка вызывающего кода или факт предметной области, не повод срабатывать. Правило такое: автомат учитывает признаки неспособности внешнего сервиса ответить — таймауты, ошибки соединения, TLS и DNS, разрывы, ошибки протокола и 5xx. Всё остальное означает, что сервис работает.

Один сигнал на логический вызов

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

attempts counted (hand-rolled)         calls ok=2  refused by breaker=8
logical calls counted (clientwright)   calls ok=10 refused by breaker=0  attempts=30 circuit transitions=0

Тридцать попыток, двадцать ошибок, десять успешных вызовов. Автомат, считающий попытки, разомкнул цепь после второго вызова и отклонил остальные восемь, хотя сервис успешно отвечал на каждый запрос, которому давали три шанса. Автомат, учитывающий окончательные результаты логических вызовов, увидел десять успехов и не сработал. Повторные попытки намеренно компенсируют нестабильность зависимости; защита, срабатывающая из-за них, мешает собственной политике повторов. У той есть отдельные ограничения, описанные в статье о бюджете повторных попыток.

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

Пробный запрос

Разомкнутое состояние не вечно. После интервала восстановления следующий вызов становится пробным: его результат определяет, замкнётся ли цепь или снова разомкнётся на очередной интервал. До его ответа все остальные получают быстрый локальный отказ, поэтому восстанавливающийся сервис видит один запрос, а не лавину:

 0.00 s  503
 0.00 s  503
 0.00 s  503
 0.01 s  refused, probe in 0.50s
 0.61 s  200
 0.61 s  200

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

ИДЕЯ В СХЕМЕОдин origin — одно решение о восстановлении
---
config:
  theme: default
  look: classic
  state:
    useMaxWidth: false
---
stateDiagram-v2
    accTitle: Один origin — одно решение о восстановлении
    accDescr: У каждого origin свой автомат состояний. Только пробный запрос решает, возобновлять ли трафик; отмена пробы освобождает слот, не объявляя успех или сбой.
    direction TB
    state "Closed: запросы проходят" as Closed
    state "Open: быстрый отказ" as Open
    state "Half-open: пробный запрос" as HalfOpen
    [*] --> Closed
    Closed --> Open: Порог ошибок достигнут
    Open --> HalfOpen: Время ожидания истекло
    HalfOpen --> Closed: Проба успешна
    HalfOpen --> Open: Проба неуспешна

У каждого origin свой автомат состояний. Только пробный запрос решает, возобновлять ли трафик; отмена пробы освобождает слот, не объявляя успех или сбой.

Конфигурация

from clientwright import CircuitBreakerConfig, CircuitKey, ClientConfig, build

config = ClientConfig(
    service_name="orders",
    circuit_breaker=CircuitBreakerConfig(
        fail_threshold=5,            # consecutive tripping calls, final outcomes only
        recovery_timeout=30.0,       # seconds open before the next call becomes the probe
        half_open_max_calls=1,       # probes in flight at once
        key=CircuitKey.ORIGIN,       # the default: one verdict per scheme://host:port
    ),
)
client = build("httpx", config)

Есть два более точных ключа для origin, части которого могут иметь разную работоспособность: по маршруту, чтобы один медленный endpoint не отключал быстрые, и по методу. Оба уже, чем origin; более широкого ключа по клиенту нет — именно его проблему разбирает статья.

Состояние автомата хранится в runtime с областью жизни приложения, а не в объекте клиента. Это вторая половина правильной реализации: созданный на один запрос автомат не имеет памяти и потому бесполезен. Сервис, создающий новый клиент на каждый запрос, незаметно остаётся без защиты. В clientwright она включена по умолчанию: ключ по origin, срабатывание на 5xx и транспортные ошибки, один сигнал на логический вызов.

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