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
Тридцать попыток, двадцать ошибок, десять успешных вызовов. Автомат, считающий попытки, разомкнул цепь после второго вызова и отклонил остальные восемь, хотя сервис успешно отвечал на каждый запрос, которому давали три шанса. Автомат, учитывающий окончательные результаты логических вызовов, увидел десять успехов и не сработал. Повторные попытки намеренно компенсируют нестабильность зависимости; защита, срабатывающая из-за них, мешает собственной политике повторов. У той есть отдельные ограничения, описанные в статье о бюджете повторных попыток.
Отменённые вызовы — третья категория, о которой обычно забывают. Если вызывающая сторона прекратила ждать или дедлайн отменил задачу, внешний сервис не показал ни успеха, ни сбоя. Автомату нужно забыть этот вызов. Особенно это важно в полуоткрытом состоянии: отменённый пробный запрос должен освободить слот, не замыкая и не размыкая цепь.
Пробный запрос¶
Разомкнутое состояние не вечно. После интервала восстановления следующий вызов становится пробным: его результат определяет, замкнётся ли цепь или снова разомкнётся на очередной интервал. До его ответа все остальные получают быстрый локальный отказ, поэтому восстанавливающийся сервис видит один запрос, а не лавину:
Порог — три, интервал восстановления — полсекунды. Три сбоя, один отказ с точным временем до проверки, затем первый вызов после паузы становится пробным. Он обнаруживает восстановление сервиса и замыкает цепь для следующего вызова. Отказ содержит время до следующей проверки: вызывающая сторона понимает, как долго обслуживать запросы из кеша.
У каждого 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 и транспортные ошибки, один сигнал на логический вызов.
Суть — во втором столбце первой таблицы: по пять отказов для двух сервисов, которые ни разу не сломались.