
Внешний сервис начал отвечать за 3 секунды вместо 30 миллисекунд. Ничего не упало, в логах чисто, но пул соединений забит, очередь запросов растёт, и через минуту ваш сервис лежит вместе с ним, хотя сам полностью исправен.
Отказоустойчивость - это набор решений о том, что делать в такие моменты: повторять или нет, сколько ждать, что вернуть, когда ответа не будет вовсе. Почти все они принимаются в коде, а не в конфигурации кластера. Дальше - как они выглядят на Python и Java, на обычных задачах: вызов внешнего API, запрос в базу, фоновая обработка.
Повторные попытки (Retry) и экспоненциальный backoff

Когда вызов внешнего сервиса упал по сети, разумно попробовать ещё раз - сбой мог быть секундным. Всё остальное в ретраях неочевидно.
Python, Tenacity
Ретраи несложно написать руками: while, счётчик попыток и time.sleep(). Проблема в том, что рукописный цикл почти всегда выходит наивным: фиксированная пауза без разброса, except Exception вместо разбора типов ошибок, ни бюджета, ни потолка по времени. Tenacity делает эти решения явными параметрами: их придётся принять осознанно, а не пропустить по умолчанию.
import logging import requests from tenacity import ( retry, stop_after_attempt, wait_random_exponential, retry_if_exception, before_sleep_log, ) logger = logging.getLogger(__name__) def is_retryable(exc: BaseException) -> bool: if isinstance(exc, (requests.exceptions.ConnectionError, requests.exceptions.Timeout)): return True if isinstance(exc, requests.exceptions.HTTPError): code = exc.response.status_code if exc.response is not None else None # 429 - нас просят подождать, 5xx - сервер сломался. # 4xx кроме 429 повторять бессмысленно: запрос не станет валиднее. return code == 429 or (code is not None and 500 <= code < 600) return False @retry( stop=stop_after_attempt(3), # full jitter: без случайного разброса все клиенты # придут повторно в одну и ту же миллисекунду wait=wait_random_exponential(multiplier=0.1, max=5), retry=retry_if_exception(is_retryable), # без reraise наружу полетит tenacity.RetryError, # а не исходное requests-исключение reraise=True, before_sleep=before_sleep_log(logger, logging.WARNING), ) def fetch_data(url): # таймаут обязателен: без него ретраи будут висеть, а не повторяться response = requests.get(url, timeout=(1, 3)) response.raise_for_status() return response.json() try: data = fetch_data("https://api.example.com/data") except requests.exceptions.RequestException as e: logger.error("Не удалось получить данные: %s", e)
Декоратор @retry перехватывает не все исключения подряд, а те, что прошли проверку is_retryable: сетевые ошибки, таймауты, 429 и 5xx. Ответ 400 Bad Request уйдёт наверх с первой же попытки, повторять его бессмысленно, запрос не станет валиднее.
Пауза растёт экспоненциально, но берётся не сама граница, а случайная величина от нуля до неё (потолок идёт 0,1 → 0,2 → 0,4 … до 5 секунд). Это full jitter. Если сервис лёг под тысячей клиентов, то без разброса все они вернутся одновременно и уронят его повторно ровно в тот момент, когда он поднимался.
reraise=True здесь не косметика: без него после трёх неудач Tenacity бросит собственное RetryError, и вызывающий код поймает не то, что ожидал, а except requests.exceptions.RequestException вообще не сработает.
И таймаут внутри самого запроса обязателен. Без него попытка не завершится ошибкой, а зависнет: ретраить будет нечего, потому что первая попытка ещё не закончилась.
Java, Resilience4j
Стандартный выбор с тех пор, как Hystrix в 2018 году ушёл в maintenance. Идея та же, но модульно: Retry, CircuitBreaker, Bulkhead и RateLimiter подключаются по отдельности и комбинируются между собой. Со Spring Boot интегрируется аннотациями, а сама политика ретраев выносится в application.yml.
@Service public class OrderService { // Аннотация указывает использовать Retry с именем "inventoryService" @Retry(name = "inventoryService", fallbackMethod = "fallbackInventory") public Inventory getInventory(int productId) { return externalInventoryClient.getInventory(productId); } }
Аннотация задаёт только точку подключения. Сама политика сколько раз повторять, с какой паузой и какие ошибки вообще считать поводом для повтора - лежит в конфигурации, и связаны они по имени: name = "inventoryService" в аннотации соответствует instances.inventoryService в yaml.
resilience4j: retry: instances: inventoryService: max-attempts: 3 wait-duration: 200ms enable-exponential-backoff: true exponential-backoff-multiplier: 2 # тот же jitter, что и в Python-примере: # без него все клиенты повторяют синхронно enable-randomized-wait: true randomized-wait-factor: 0.5 retry-exceptions: - java.io.IOException - java.util.concurrent.TimeoutException ignore-exceptions: - com.example.InventoryNotFoundException
retry-exceptions и ignore-exceptions - это эквивалент is_retryable в Python-примере, только декларативный. Список исключений и есть политика: если его не задать, Resilience4j будет повторять всё подряд, включая ошибки бизнес-логики. Здесь InventoryNotFoundException вынесен в игнор явно - товара нет, и он не появится с третьей попытки.
Классы в retry-exceptions должны совпадать с тем, что действительно бросает ваш клиент. Feign оборачивает сетевые сбои в FeignException, RestTemplate в ResourceAccessException, и java.io.IOException в списке до них не достанет.
Если после всех попыток сервис так и не ответил, вызывается fallback:
public Inventory fallbackInventory(int productId, Exception e) { // ignore-exceptions отключает повторы, но не отключает fallback: // бизнес-исключения дойдут сюда, и их надо пропустить дальше. if (e instanceof InventoryNotFoundException notFound) { throw notFound; } log.warn("Inventory service unavailable for product {}: {}", productId, e.getMessage()); inventoryDegraded.increment(); // метрика: как часто работаем в деградации // Не выдумываем остаток. Ноль неотличим от настоящего нуля // и уйдёт в бизнес-логику как факт «товара нет». return Inventory.unknown(productId); }
ignore-exceptions отключает только повторы - fallback вызовется всё равно. Поэтому бизнес-исключения приходится пробрасывать вручную, иначе «товар не найден» превратится в выдуманные данные.
Возвращать в таком фолбэке ноль нельзя: сервис инвентаризации всего лишь недоступен, а магазин из-за такой заглушки покажет весь каталог распроданным.
Снаружи всё это невидимо. Система на заглушках выглядит здоровой: ошибок нет, а latency даже лучше обычной, она же не ходит в упавший сервис. Поэтому нужна отдельная метрика на сам факт деградации, иначе о фальшивых ответах вы узнаете от пользователей.
Ретраи умножаются
Три сервиса в цепочке, у каждого по три попытки - один запрос пользователя превращается в 27 запросов к нижнему сервису. К тому самому, который уже лежит. Ретраи, расставленные на каждом слое «на всякий случай», не складываются в защиту, а перемножаются в нагрузку: трафик на упавший сервис растёт ровно тогда, когда ему нужно восстановиться.
Лечится это тем, что повторяет кто-то один. Выберите слой - обычно ближайший к внешней зависимости - и ретрайте только там, остальные пробрасывают ошибку наверх. Проверьте заодно, что ретраев нет по умолчанию в HTTP-клиенте и в service mesh: даже по одному повтору на каждом слое - уже восьмикратная амплификация.
Но и один слой остаётся опасным, пока число попыток фиксировано. «Три попытки на запрос» ведут себя хуже всего именно под массовым сбоем: пока всё работает, ретраев почти нет, а как только сервис лёг - их втрое больше обычного трафика. Бюджет разрывает эту связь: считаем долю ретраев от общего числа запросов за окно и перестаём повторять, когда она превысила порог, обычно 10–20%. Единичный сбой повторяется как и раньше, массовый гасится сам.
Так сделано в gRPC (retryThrottling), в retry-квотах AWS SDK и в retry_budget Envoy. На голом HTTP-клиенте минимальная версия - token bucket: успешный запрос кладёт долю токена, а повтор забирает целый. Отсюда и получается «один повтор на пять успехов» при ratio=0.2. Кончились токены - перестаём повторять, пока успехи их не вернут.
import threading class RetryBudget: SCALE = 1000 def __init__(self, ratio: float = 0.2, max_tokens: int = 10): self._per_success = round(ratio * self.SCALE) self._max = max_tokens * self.SCALE # старт с половины: повторы работают сразу self._tokens = self._max // 2 self._lock = threading.Lock() def on_success(self) -> None: with self._lock: self._tokens = min(self._max, self._tokens + self._per_success) def try_retry(self) -> bool: with self._lock: if self._tokens >= self.SCALE: self._tokens -= self.SCALE return True return False # бюджет исчерпан
Счёт идёт в целых тысячных долях токена, а не во float. Это не педантизм: при ratio=0.1 десять сложений 0.1 дают 0.9999999999999999, и каждый десятый повтор теряется на пустом месте. Блокировка - по обычной причине: под пулом потоков проверка и списание без неё не атомарны, и два потока спишут один и тот же токен.
Бюджет заводится на каждую зависимость отдельно. Общий на всё приложение бессмыслен: сбой одного API съест бюджет остальных, и ретраи отключатся там, где всё было в порядке.
Теперь его надо подключить к Tenacity. Одного is_retryable уже мало - решение зависит не только от типа ошибки, но и от номера попытки, и от состояния бюджета. Поэтому вместо retry_if_exception передаём собственный объект: Tenacity принимает в параметр retry любой вызываемый объект, получающий состояние попытки целиком.
class BudgetedRetry: def __init__(self, budget: RetryBudget, max_attempts: int, name: str): self._budget = budget self._max_attempts = max_attempts self._name = name def __call__(self, retry_state) -> bool: exc = retry_state.outcome.exception() if exc is None or not is_retryable(exc): return False # Tenacity вызывает предикат и после последней попытки, # хотя повтора уже не будет. Токен на это тратить не за что. if retry_state.attempt_number >= self._max_attempts: return False if not self._budget.try_retry(): # Это событие обязано быть в метриках: оно означает, # что зависимость лежит массово, а не разово. logger.warning("retry budget exhausted for %s", self._name) return False return True
Итоговый декоратор:
max_attempts = 3 inventory_budget = RetryBudget(ratio=0.2, max_tokens=10) @retry( stop=stop_after_attempt(max_attempts), wait=wait_random_exponential(multiplier=0.1, max=5), retry=BudgetedRetry(inventory_budget, max_attempts, "inventory-api"), reraise=True, before_sleep=before_sleep_log(logger, logging.WARNING), ) def fetch_data(url): response = requests.get(url, timeout=(1, 3)) response.raise_for_status() inventory_budget.on_success() # успех пополняет бюджет return response.json()
on_success() вызывается после raise_for_status(), а не до - иначе успешным будет считаться и ответ 500, и бюджет пополнится ровно тогда, когда пополняться не должен.
Поведение получается такое: пока сервис отвечает, try_retry() почти всегда возвращает True - успехов много, токенов хватает. Как только сервис ляжет и успехи прекратятся, накопленный запас уйдёт на первые же повторы и пополняться перестанет - дальше повторы отключаются сами. Потолок max_tokens ограничивает этот запас: сервис, который был здоров сутки, не должен получать право на сутки повторов.
В Resilience4j готового бюджета нет - Retry умеет только считать попытки. Собирают его руками тем же счётчиком или выносят на уровень инфраструктуры: в Envoy и Istio retry_budget настраивается конфигом сайдкара.
Circuit Breaker (размыкатель цепи)

Ретраи помогают, пока сбой короткий. Если сервис лежит десять минут, повторы только жгут потоки: каждый запрос честно ждёт таймаута, чтобы получить ту же ошибку. Circuit breaker убирает это ожидание - после серии неудач он перестаёт ходить в проблемный сервис вообще и отвечает ошибкой сразу.
Уже занятые потоки это не освобождает, они висят до своего таймаута. Но новые занять не даёт, и вызывающий остаётся живым, пока чужая проблема решается.
Состояний три, и брейкер ничего не опрашивает сам - он считает исходы обычных вызовов. В Closed вызовы проходят, каждый исход пишется в окно последних вызовов, когда доля ошибок в окне превышает порог, breaker переходит в Open. В Open вызовы не выполняются вовсе, исключение бросается мгновенно, и так держится заданное время - обычно десятки секунд. Потом Half-Open: несколько пробных запросов пропускаются, остальные по-прежнему отклоняются. Решение принимается по доле ошибок среди пробных, а не по первому провалу: при трёх пробах и пороге 50% один упавший цепь не разомкнёт.
Окно настраивать важнее, чем порог. Вместе с порогом обязательно задаётся минимальное число вызовов: без него два неудачных запроса на старте дают 100% ошибок и размыкают цепь на пустом месте. Дальше тип окна - по количеству вызовов или по времени. Для редкого трафика окно из 20 вызовов растянется на десятки секунд, и breaker будет реагировать на давно прошедшее, там берут окно по времени.
Сетевые исключения и таймауты считаются отказом очевидно, бизнес-ошибки вроде валидации лучше не считать, иначе цепь откроется на исправном сервисе. А вот HTTP-коды это отдельная ловушка: ответ 500 для клиента - обычный успешный обмен по сети. Пока не вызван raise_for_status() в Python или не задан recordFailurePredicate в Resilience4j, breaker пятисотки не увидит и оставит цепь замкнутой при полностью нерабочем сервисе.
Порог по доле ошибок не поймает медленный сервис. Он отвечает, но за секунды: ошибок нет, доля отказов нулевая, цепь замкнута, а вызывающий держит потоки в ожидании и ложится сам. Поэтому медленный вызов тоже должен считаться отказом. В Resilience4j для этого есть отдельная пара параметров: порог длительности и допустимая доля медленных вызовов. В Python отдельного механизма не нужно, при заданном таймауте медленный вызов сам становится исключением и попадает в общий счётчик.
Java, Resilience4j
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; @Service public class PaymentService { // Аннотация автоматически обернёт метод circuit breaker'ом с именем "paymentService" @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback") public Receipt chargePayment(Order order) { // Внешний вызов платежного сервиса return paymentClient.charge(order); } // Цепь разомкнута: запрос не уходил, деньги точно не списаны. // Здесь отказать безопасно. public Receipt paymentFallback(Order order, CallNotPermittedException ex) { log.warn("Payment circuit is open, rejecting order {} fast", order.id()); return Receipt.rejected(order, "payment temporarily unavailable"); } // Вызов ушёл и не вернулся. Прошёл платёж или нет — неизвестно. public Receipt paymentFallback(Order order, Throwable ex) { log.error("Payment outcome unknown for order {}: {}", order.id(), ex.getMessage()); return Receipt.pending(order); } }
Resilience4j выбирает fallback по типу исключения: более специфичный побеждает. Здесь их два, потому что два случая требуют противоположных ответов.
CallNotPermittedException означает, что цепь разомкнута и запрос вообще не уходил в сеть: платёж гарантированно не прошёл, отказать клиенту безопасно. Любое другое исключение - таймаут, обрыв соединения - означает обратное: запрос ушёл, ответа нет, и прошёл платёж или нет, вы не знаете.
Один общий фолбэк склеил бы эти случаи в один ответ, и что бы он ни вернул, в половине ситуаций он окажется неправ. Объявит неуспех - при таймауте клиент повторит операцию и заплатит дважды. Объявит успех - система запишет платёж, которого не было. Разомкнутая цепь тем и ценна, что это единственная ситуация, где точно известно: ничего не произошло.
Политика задаётся в конфиге под тем же именем.
resilience4j: circuitbreaker: instances: paymentService: sliding-window-type: COUNT_BASED sliding-window-size: 20 minimum-number-of-calls: 10 # без этого порог сработает на двух запросах failure-rate-threshold: 50 slow-call-duration-threshold: 2s slow-call-rate-threshold: 50 # медленные вызовы тоже считаем отказом wait-duration-in-open-state: 30s permitted-number-of-calls-in-half-open-state: 3 ignore-exceptions: - com.example.PaymentDeclinedException # «карта не прошла» — не сбой
Python, pybreaker
Готовых реализаций несколько - pybreaker и circuitbreaker, ниже первая. Принцип тот же: создаётся объект размыкателя, и им декорируется вызов.
import logging import pybreaker import requests from prometheus_client import Counter logger = logging.getLogger(__name__) profile_degraded = Counter( "profile_degraded_total", "Ответы из заглушки вместо user-api" ) class UserNotFound(Exception): pass breaker = pybreaker.CircuitBreaker( fail_max=5, # пять неудач подряд reset_timeout=60, # через минуту пропустит пробный вызов exclude=[UserNotFound], # «профиля нет» — не сбой сервиса name="user-api", ) @breaker def fetch_profile(user_id): r = requests.get(f"https://api.example.com/users/{user_id}", timeout=(1, 3)) if r.status_code == 404: raise UserNotFound(user_id) r.raise_for_status() return r.json() def _stub(user_id): profile_degraded.inc() return {"user_id": user_id, "name": "Unknown", "stale": True} def get_user_profile(user_id): try: return fetch_profile(user_id) except UserNotFound: # Пользователя нет — это ответ, а не сбой. Отдаём наверх как есть. raise except pybreaker.CircuitBreakerError: # Цепь разомкнута: до сервиса даже не пошли logger.warning("user-api circuit is open, serving stub") return _stub(user_id) except requests.exceptions.RequestException as e: # Сервис не ответил; брейкер этот исход уже посчитал logger.warning("user-api call failed (%s), serving stub", e) return _stub(user_id)
Отдельно стоит UserNotFound. Он передан в exclude, поэтому не увеличивает счётчик неудач: сервис жив и ответил по существу, размыкать цепь не за что. Наверх он при этом уходит как есть - «пользователя нет» это ответ, а не деградация, и подменять его заглушкой было бы враньём.
fail_max в pybreaker - это неудачи подряд, а не доля отказов в окне. Один успешный вызов обнуляет счётчик: четыре неудачи, успех и ещё четыре неудачи цепь не разомкнут. Модель проще, чем в Resilience4j, но и грубее - сервис, стабильно отдающий ошибку на каждый второй запрос, такой брейкер не откроет никогда. Совет «открывать цепь, если 50% из последних 20 запросов упали» относится к Resilience4j, в pybreaker его так не выразить.
В Half-Open pybreaker пропускает один пробный вызов: прошёл - цепь замыкается, упал - снова открывается на reset_timeout. Здесь модель «по первому провалу» верна, в отличие от Resilience4j, где решение принимается по доле отказов среди нескольких пробных.
Брейкер живёт в процессе
И в pybreaker, и в Resilience4j состояние по умолчанию хранится в памяти процесса. Если сервис развёрнут в пятидесяти подах, у зависимости не один брейкер, а пятьдесят независимых: каждый набирает свою статистику и открывается сам по себе.
Порог при этом считается от трафика одного инстанса: окно из двадцати вызовов на поде, который видит сотую долю общего трафика, наберётся во сто раз медленнее, и брейкер отреагирует с запозданием. Зато восстановление получается размазанным - поды выходят из Open вразнобой, и нагрузка на поднявшуюся зависимость нарастает постепенно, а не залпом. На синхронное поведение кластера рассчитывать всё равно нельзя.
Общее состояние сделать можно - в pybreaker есть CircuitRedisStorage, но тогда Redis оказывается на пути каждого вызова и становится новой точкой отказа. Обычно брейкер оставляют локальным, а общую картину смотрят в метриках.
Остаётся вопрос, какие числа вписывать в конфиг. Порог стоит подбирать от нормального уровня ошибок, а не от нуля: если сервис в обычном режиме отдаёт 2% ошибок, порог в 10% - это в пять раз выше фона, и брейкер промолчит на реальной деградации. Посмотрите распределение error rate за неделю и ставьте порог выше пика нормального дня, но заметно ниже уровня, на котором зависимость перестаёт быть полезной.
И ставить его нужно на зависимость, а не на сервис целиком. Один брейкер вокруг всех вызовов к чужому API откроется из-за проблем на одном эндпоинте и заблокирует остальные. Тяжёлый отчёт и десяток лёгких чтений - разные характеристики, значит и брейкеры разные.
Fallback и graceful degradation (плавная деградация)
Первый вопрос при сбое - не «чем заменить ответ», а «нельзя ли получить настоящий ответ иначе». Резервный инстанс, вторая зона, другой провайдер того же курса валют - это не деградация, а просто другой путь к тем же данным, и если он есть, до фолбэка дело не доходит.
Деградация начинается там, где настоящего ответа взять негде. Тогда вопрос меняется: что отдать вместо него, чтобы пользователь получил хоть что-то и при этом не был обм анут.

Четыре честных ответа и один нечестный
Устаревшие данные: Курс шестичасовой давности, вчерашний каталог, прошлый час результатов поиска. Годится, когда данные меняются медленно, а решение по ним обратимо. Обязателен предел годности - иначе однажды покажете данные недельной давности - и признак несвежести, уходящий наверх.
Урезанный ответ: Страница товара без блока «Похожие товары», лента без персонализации. Лучший вид деградации: пользователь видит меньше, но всё, что видит - правда.
«Неизвестно»: Явное «данных нет» вместо выдуманного значения. Выглядит хуже заглушки, но только этот ответ вызывающий код может обработать правильно.
Явный отказ: «Платёж сейчас невозможен, попробуйте позже». Единственно верный ответ там, где выдумывать нечего и деградировать некуда.
И нечестный: правдоподобное значение. Нулевой остаток, курс 1.0, пустой список вместо «не смогли посчитать». Такой ответ неотличим от настоящего, проходит все проверки и уходит в бизнес-логику как факт. Правило простое: если по возвращённому значению нельзя понять, что оно из фолбэка, возвращать его нельзя.
Фолбэк не должен маскировать проблему навсегда: каждое его срабатывание обязано попадать в метрику, иначе система на заглушках снаружи выглядит здоровой. Подробнее - в разделе про мониторинг.
Написать fallback легко, это альтернативная ветка в catch. Трудное начинается с вопроса, что именно из неё возвращать. Часто фолбэк цепляют к circuit breaker или retry через fallbackMethod, как в примерах выше, без библиотеки его пишут руками.
Python
import logging import threading from dataclasses import dataclass from datetime import datetime, timedelta, timezone import requests from prometheus_client import Counter logger = logging.getLogger(__name__) rate_degraded = Counter("rate_degraded_total", "Курс отдан из кэша") API = "https://api.exchangerate.host" MAX_STALENESS = timedelta(hours=6) @dataclass(frozen=True) class Rate: value: float fresh: bool as_of: datetime | None = None @dataclass(frozen=True) class Cached: value: float as_of: datetime @property def age(self) -> timedelta: return datetime.now(timezone.utc) - self.as_of class RateUnavailable(Exception): pass class LastKnownGood: def __init__(self) -> None: self._d: dict[str, Cached] = {} self._lock = threading.Lock() def put(self, key: str, value: float) -> None: with self._lock: self._d[key] = Cached(value, datetime.now(timezone.utc)) def get(self, key: str) -> Cached | None: with self._lock: return self._d.get(key) cache = LastKnownGood() def get_exchange_rate(currency: str) -> Rate: key = f"rate:{currency}:USD" try: resp = requests.get(f"{API}/latest?base={currency}", timeout=(1, 2)) resp.raise_for_status() value = resp.json()["rates"]["USD"] cache.put(key, value) # кладём про запас, для фолбэка return Rate(value=value, fresh=True) except requests.exceptions.RequestException as e: logger.warning("exchange rate API failed for %s: %s", currency, e) rate_degraded.inc() cached = cache.get(key) if cached is None or cached.age > MAX_STALENESS: # Ни свежих данных, ни приемлемо старых. Курса нет — и врать не будем. raise RateUnavailable(currency) from e # Старый курс — валидный ответ, но вызывающий должен знать, что он старый return Rate(value=cached.value, fresh=False, as_of=cached.as_of)
У кэша есть предел годности: шесть часов - ещё курс, тридцать - уже выдумка. Если приемлемо старых данных нет, функция не возвращает ничего правдоподобного, а бросает RateUnavailable - пусть вызывающий решает, показать ошибку или скрыть блок с ценой. И признак fresh уходит наверх: по одному только числу отличить кэш от живого ответа невозможно, а по Rate - можно.
Java
import org.springframework.web.client.RestClientException; import java.time.Duration; @Service public class RateService { private static final Duration MAX_STALENESS = Duration.ofHours(6); public Rate getExchangeRate(String currency) { try { ExchangeRates rates = restClient.get() .uri("/latest?base={cur}", currency) .retrieve() .body(ExchangeRates.class); // тело может прийти пустым — тогда следующая строка упала бы NPE if (rates == null) { throw new RestClientException("empty body"); } double value = rates.rates().get("USD"); cache.put(currency + ":USD", value); // кладём про запас, для фолбэка return Rate.fresh(value); } catch (RestClientException e) { log.warn("Exchange rate API failed for {}: {}", currency, e.getMessage()); rateDegraded.increment(); return cache.get(currency + ":USD") .filter(c -> c.age().compareTo(MAX_STALENESS) < 0) .map(c -> Rate.stale(c.value(), c.asOf())) // ни свежих данных, ни приемлемо старых — курса нет .orElseThrow(() -> new RateUnavailableException(currency, e)); } } }
Таймаут у RestClient задаётся на клиенте, а не в цепочке вызова, и действует сразу на все запросы через него:
import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; import java.time.Duration; @Configuration public class RateClientConfig { @Bean public RestClient rateRestClient() { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); requestFactory.setConnectTimeout(Duration.ofSeconds(1)); requestFactory.setReadTimeout(Duration.ofSeconds(2)); return RestClient.builder() .baseUrl("https://api.exchangerate.host") .requestFactory(requestFactory) .build(); } }
Деградировать может далеко не всё. Аналитика, рекомендации, отзывы, счётчики просмотров - здесь устаревшие данные никого не обидят. А вот результат платежа «догадкой» не заменяется ни при каких условиях: там либо настоящий ответ, либо честное «не знаем», и никогда - правдоподобное число.
Фолбэк - самый непроверенный код в системе
Он выполняется только при сбое, то есть на тестах не запускается почти никогда, а срабатывает ровно тогда, когда всё горит.
Фолбэк не должен зависеть от того, что может лежать вместе с основным путём. Кэш в Redis - тоже сетевая зависимость, если Redis отвалился вместе с API, ваш except бросит исключение из обработчика исключения. Локальная копия последнего известного значения в памяти процесса надёжнее внешнего кэша именно потому, что ей нечего ронять.
И холодный старт. Сразу после деплоя кэш пустой, и сработает не ветка «отдаём старое», а ветка «данных нет вовсе».
Проверяется это дёшево: toxiproxy перед зависимостью и три сценария - зависимость лежит, зависимость и кэш лежат вместе, кэш пустой.
Когда деградаций много
Одна деградация незаметна, три деградации сразу - это уже другая страница. Если рекомендации, отзывы и остатки на складе отвалились одновременно, пользователь получит 200 OK и почти пустой экран. Формально сервис работает, фактически нет.
Поэтому долю ответов из фолбэка стоит считать не по каждой зависимости отдельно, а суммарно на запрос - и задать порог, за которым честнее отдать ошибку, чем притворяться работающим.
Отличить этот ответ от настоящего - самый неудобный из вопросов к фолбэку и самый полезный.
Таймауты и ограничения времени ожидания

Сервис из первого абзаца - тот, что стал отвечать за три секунды вместо тридцати миллисекунд, - опасен именно этим. Вызов без таймаута не падает, он висит, занимая поток или соединение из пула. Ошибку видно сразу, зависший вызов не видно, пока не кончатся потоки.
У HTTP-вызова таймаутов два, и они про разное. Connect timeout короткий, 0,5–1 секунда: за это время либо есть TCP-соединение, либо хоста нет, и ждать дольше незачем. Read timeout длиннее, 2–5 секунд по обстоятельствам - это уже время на саму работу. В примерах выше это timeout=(1, 2) в Python и пара setConnectTimeout / setReadTimeout в Java.
С базой сложнее: там таймаутов три, и путать их дорого. connectionTimeout в HikariCP - сколько ждать свободного соединения из пула, срабатывает, когда пул исчерпан, и к длительности запроса отношения не имеет. Statement.setQueryTimeout или statement_timeout на стороне Postgres - сколько выполняется сам запрос. socketTimeout в драйвере - сколько ждать ответа по сети, если сервер умер молча. Задавать нужно все три: без первого поток виснет в очереди за соединением, без второго тяжёлый запрос держит соединение до последнего, без третьего оборванное соединение не заметит никто.
Но самая частая ошибка не в числах, а в том, что таймауты складываются. A ждёт B три секунды, B ждёт C десять - и B продолжает работать, когда A уже ушёл и его ответ никому не нужен. Подбором чисел это не лечится: цепочка длиннее двух звеньев всегда найдёт способ рассогласоваться.
Передавать вниз надо не таймаут, а остаток. Верхний уровень фиксирует дедлайн на всю операцию и с каждым вызовом сообщает, сколько времени осталось. Сервис B, получив «осталось 1,2 с», не станет ждать C десять секунд - поставит таймаут не больше остатка, а если остаток исчерпан, откажет сразу, не тратя вызов.
В gRPC это встроено: deadline едет в метаданных и распространяется по цепочке сам. В голом HTTP приходится делать руками:
Python, asyncio + aiohttp
import asyncio import aiohttp DEFAULT_BUDGET = 3.0 def budget_seconds(request) -> float: header = request.headers.get("X-Request-Deadline-Ms") if header is None: return DEFAULT_BUDGET return min(DEFAULT_BUDGET, int(header) / 1000) async def get_json(session, url: str, budget: asyncio.Timeout) -> dict: left = budget.when() - asyncio.get_running_loop().time() headers = {"X-Request-Deadline-Ms": str(int(left * 1000))} async with session.get(url, headers=headers) as resp: resp.raise_for_status() return await resp.json() async def handle(request, session): async with asyncio.timeout(budget_seconds(request)) as budget: user = await get_json(session, USERS, budget) # параллельные вызовы делят тот же бюджет cart, orders = await asyncio.gather( get_json(session, CART, budget), get_json(session, ORDERS, budget), ) return {"user": user, "cart": cart, "orders": orders}
Считать остатки и сравнивать таймауты вручную не нужно - asyncio.timeout делает это сам. Вложенные блоки складываются правильно: если снаружи бюджет 3 секунды, а внутри вызов со своим таймаутом в 10, сработают внешние 3 - asyncio снимает всё дерево задач, включая запущенные через gather. Отдельный ClientTimeout для aiohttp тоже не нужен: отмена доходит до сокета.
Руками делается ровно одно, чего asyncio знать не может - передача дедлайна по сети. Наружу остаток уходит заголовком, budget.when() даёт абсолютное время окончания. Внутрь - budget_seconds() читает чужой дедлайн и берёт минимум со своим: если вызывающий готов ждать секунду, ждать три бессмысленно.
Java, OkHttp
import java.io.InterruptedIOException; import java.time.Duration; OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(Duration.ofSeconds(1)) .readTimeout(Duration.ofSeconds(3)) .writeTimeout(Duration.ofSeconds(3)) // общий бюджет на весь вызов, включая DNS, редиректы и повторы OkHttp .callTimeout(Duration.ofSeconds(5)) .build(); Request request = new Request.Builder() .url("https://api.github.com/repos/user/repo") .build(); try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { // обработка кода ошибки } String body = response.body().string(); } catch (InterruptedIOException e) { // сюда приходит и SocketTimeoutException, и срабатывание callTimeout log.warn("GitHub API did not respond in time: {}", e.getMessage()); }
callTimeout здесь важнее трёх остальных: без него connect, read и write складываются, и реальное ожидание оказывается больше, чем рассчитывает вызывающий код. Ловить нужно InterruptedIOException, а не SocketTimeoutException - срабатывание callTimeout бросает именно его, и узкий catch его пропустит.
Аналога asyncio.timeout в Java нет: дедлайн приходится считать самому и ставить на каждый вызов отдельно. Ближайшее по смыслу - передавать оставшийся бюджет параметром и вычитать из него потраченное, как в Python-примере, только вручную.
Таймаут обрывает ожидание, а не работу
Когда клиент отваливается по таймауту, сервер об этом не узнаёт. Он продолжает выполнять запрос, дописывает в базу, списывает деньги - просто ответ уходит в никуда. Таймаут это решение перестать ждать, а не отмена операции.
Отсюда следствие, которое ломает больше всего систем: таймаут не означает, что операция не выполнилась. Он означает, что вы не знаете её исхода. Тот же случай с платежом, что и в разделе про брейкер: повторять после таймаута можно только идемпотентные операции - об этом дальше.
И обратное: если клиент ушёл, работать дальше обычно незачем. Именно для этого дедлайн передаётся вниз по цепочке - чтобы нижний сервис мог проверить остаток и не начинать то, чего никто не дождётся.
Таймаут ставится чуть выше девяносто девятого перцентиля нормального дня, обычно это два-три p99. Для сервиса с типичными 100 мс и p99 в 300 мс разумное значение около секунды. Пять секунд при тех же 100 мс - не запас, а решение удерживать поток впятеро дольше, чем нужно, чтобы понять: ответа не будет. Среднее время ответа для этого не годится вовсе, именно хвост распределения определяет, сколько потоков вы потеряете при деградации.
И проверьте произведение. Таймаут умножается на число попыток: три ретрая по три секунды - это девять секунд худшего случая, а не три. Если сверху стоит дедлайн операции, он должен учитывать ретраи, иначе это не дедлайн, а пожелание.
Идемпотентность: операции без повторного эффекта

Предыдущий раздел закончился на том, что после таймаута исход операции неизвестен. Повторить её безопасно можно в одном случае: если повтор ничего не добавляет к первому вызову.
PUT /users/42 {“name”: “Иван”} идемпотентен - сколько ни повторяй, имя станет «Иван» и останется. POST /orders нет: каждый вызов создаёт новый заказ. GET и DELETE тоже идемпотентны по стандарту, но интересен именно POST, потому что списание денег и отправка письма живут там.
Способ сделать POST идемпотентным один: клиент присылает идентификатор операции, сервер запоминает под ним результат. Заголовок Idempotency-Key, ID сообщения из очереди, ID саги - важно только, что он приходит снаружи и при повторе не меняется. Первый запрос создаёт ресурс, второй с тем же ключом получает уже созданный.
Хранить это надёжнее всего уникальным индексом, и в той же транзакции, что и сам эффект. Тогда отказывает база: ON CONFLICT DO NOTHING или пойманное нарушение уникальности читается как «уже сделано». Проверка «а нет ли такого ключа?» отдельным запросом перед вставкой не работает по той же причине, что и всё остальное в этом разделе.
Python
from dataclasses import dataclass import psycopg INSERT_IF_ABSENT = """ INSERT INTO users (username, email) VALUES (%(username)s, %(email)s) ON CONFLICT (username) DO NOTHING RETURNING id, username, email """ FIND_BY_USERNAME = """ SELECT id, username, email FROM users WHERE username = %(username)s """ @dataclass(frozen=True) class User: id: int username: str email: str def insert_if_absent(conn: psycopg.Connection, username: str, email: str) -> User | None: with conn.cursor() as cur: cur.execute(INSERT_IF_ABSENT, {"username": username, "email": email}) row = cur.fetchone() return User(*row) if row else None def find_by_username(conn: psycopg.Connection, username: str) -> User | None: with conn.cursor() as cur: cur.execute(FIND_BY_USERNAME, {"username": username}) row = cur.fetchone() return User(*row) if row else None def create_user(conn: psycopg.Connection, username: str, email: str) -> User: created = insert_if_absent(conn, username, email) if created is not None: return created # Вставка не прошла — значит, пользователь уже есть existing = find_by_username(conn, username) if existing is None: # Запись успели удалить между двумя запросами — редко, но возможно raise RuntimeError(f"user {username!r} vanished between insert and read") return existing
Работу здесь делает не код, а слово UNIQUE в схеме. Раздельные SELECT и INSERT гонку не выдерживают: между ними помещается второй запрос, оба видят пустоту, оба вставляют. На живом Postgres при шестнадцати параллельных вызовах это даёт дубли больше чем в половине случаев. ON CONFLICT DO NOTHING делает и то и другое одним запросом, а решает база на своём индексе, где вклиниться некуда - только у RETURNING при сработавшем DO NOTHING нечего вернуть, поэтому существующую запись читаем вторым запросом.
И оговорка: функция идемпотентна по username, а не по паре с email - повторный вызов с другим адресом вернёт старого пользователя, не обновив его. Это строчка в контракте API, а не мелочь реализации.
Между «не начинали» и «сделали» есть третье
Состояний у операции три, а не два: не начиналась, выполняется, завершена. Флаг «уже сделано?» третьего не различает и ломается ровно там, где идемпотентность нужнее всего.
Что вернуть, если повторный запрос с тем же ключом пришёл, пока первый ещё выполняется? Не «уже сделано» - неправда. И не выполнять второй раз. Остаётся «операция в работе, спросите позже»: 409 Conflict для синхронного API, сохранённый статус для асинхронного.
Поэтому под ключом хранится статус, а не флаг, и меняется он по исходу вызова, а не перед ним.
Java
import org.springframework.dao.DuplicateKeyException; @Service public class PaymentProcessor { public PaymentResult process(String txId, Order order) { // 1. Застолбить txId. Уникальный индекс в БД — единственное, что реально // защищает от гонки: локальный Set не переживает рестарт и не виден // второму инстансу сервиса. try { attempts.insertStarted(txId, order.id()); } catch (DuplicateKeyException duplicate) { // Этот платёж уже начинали. Отдаём его исход, не выполняя повторно. return attempts.resultOf(txId); } // 2. Внешний вызов — с тем же txId как ключом идемпотентности у провайдера try { GatewayReceipt receipt = gateway.charge(txId, order.amount()); attempts.markSuccess(txId, receipt.id()); return PaymentResult.success(receipt.id()); } catch (GatewayDeclined declined) { // Провайдер сказал «нельзя» — это окончательный ответ attempts.markFailed(txId, declined.reason()); return PaymentResult.declined(declined.reason()); } catch (GatewayUnavailable unknown) { // Ответа нет. Прошёл платёж или нет — мы не знаем и не имеем права // ни подтвердить, ни отменить. Запись остаётся STARTED, // её разберёт фоновая сверка с провайдером. attempts.markUnknown(txId, unknown.getMessage()); return PaymentResult.inProgress(); } } }
Зависшие в STARTED записи - не утечка, а способ хранить «не знаю», сверку, которая их разбирает, придётся написать, иначе они копятся навсегда. На Python это та же таблица со статусом, только ON CONFLICT DO NOTHING вместо перехвата DuplicateKeyException.
Bulkhead (ограничение одновременных вызовов, семафоры)

Таймаут ограничивает один вызов, но не их количество. Сервис из первого абзаца отвечал за три секунды вместо тридцати миллисекунд - с таймаутом в пять секунд каждый вызов честно завершится, вот только пока он длится, поток занят. Сто одновременных запросов к такой зависимости - сто занятых потоков, и обслуживать всё остальное уже некому.
Bulkhead ставит границу на число одновременных вызовов, а не на их длительность. Сервису отчётов, который умеет висеть по тридцать секунд, выделяют пять потоков из пятидесяти - и он возьмёт только их, сколько бы запросов к нему ни пришло. Остальные сорок пять продолжают обслуживать то, что работает.
Практически это либо отдельный пул потоков под конкретную зависимость, либо семафор, если код асинхронный. Пул ещё и уводит вызов с основного потока, семафор просто не пускает больше N задач одновременно.
С брейкером роли разные: брейкер решает, идти ли в зависимость вообще, bulkhead - сколько запросов пустить одновременно. Брейкер бесполезен, пока зависимость отвечает, но медленно: ошибок нет, цепь замкнута, а потоки кончаются. Именно этот случай bulkhead и закрывает.
Лимит считается: запросов в секунду умножить на время ответа. 50 rps при 200 мс - это десять запросов внутри зависимости постоянно, и это рабочий предел. Ставить сто бессмысленно: лишние девяносто будут стоять в очереди уже на той стороне.
Python, asyncio + Semaphore:
import asyncio from contextlib import asynccontextmanager import aiohttp REPORTS = asyncio.Semaphore(10) MAX_WAIT = 0.5 class ServiceBusy(Exception): pass @asynccontextmanager async def bulkhead(sem: asyncio.Semaphore, max_wait: float, name: str): try: async with asyncio.timeout(max_wait): await sem.acquire() except TimeoutError: raise ServiceBusy(name) from None try: yield finally: sem.release() async def fetch_report(session: aiohttp.ClientSession, url: str) -> dict: async with bulkhead(REPORTS, MAX_WAIT, "reports"): async with session.get(url) as resp: resp.raise_for_status() return await resp.json()
Ключевая строка здесь не сам семафор, а таймаут вокруг его захвата. Ожидание в очереди ничем не отличается от ожидания ответа: запрос всё так же стоит, держит память и приближается к таймауту клиента. Семафор без ограничения на ожидание даёт ровно половину переборки: параллелизм не превысит лимита, а число ожидающих не ограничено ничем.
Java
import java.time.Duration; import java.util.concurrent.*; // Отдельный пул под медленные отчёты: 5 потоков, очередь на 10. // Очередь ограничена намеренно: неограниченная — это отложенный OOM. static final ExecutorService REPORTS = new ThreadPoolExecutor( 5, 5, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(10)); static CompletableFuture<Object> reportAsync(String id) { try { return CompletableFuture.supplyAsync(() -> fetchReport(id), REPORTS); } catch (RejectedExecutionException busy) { // Переборка заполнена. Это нормальный ответ под нагрузкой, а не авария. throw new ServiceBusyException(Duration.ofSeconds(1)); } }
Пул на 5 потоков с очередью на 10 принимает ровно 15 задач, шестнадцатая получает RejectedExecutionException - синхронно, из supplyAsync, а не отложенно в CompletableFuture. На пятидесяти одновременных запросах это 15 принятых и 35 отказов за 7 мс. Именно ограниченность очереди делает переборку переборкой: без неё лишняя работа не отсекается, а копится.
В Resilience4j аналогичного эффекта можно добиться через модуль Bulkhead. Например:
import io.github.resilience4j.bulkhead.*; import java.time.Duration; import java.util.function.Supplier; BulkheadConfig config = BulkheadConfig.custom() .maxConcurrentCalls(10) // по умолчанию 25, а не 5 .maxWaitDuration(Duration.ZERO) // очереди нет: отказываем сразу .build(); Bulkhead bulkhead = Bulkhead.of("reportService", config); Supplier<Object> decorated = Bulkhead.decorateSupplier(bulkhead, () -> fetchReport("42"));
Одна переборка у вас, скорее всего, уже есть: пул соединений к базе - тот же семафор, maximumPoolSize в HikariCP или limit_per_host в aiohttp. Беда не в отсутствии лимита, а в том, что он один - тяжёлый отчёт и оформление заказа берут соединения из общего пула, и первый выедает его целиком. Отдельный маленький пул под тяжёлые запросы - самая дешёвая переборка, какую можно поставить сегодня.
И проверьте сумму: четыре переборки по 50 при сотне потоков не изолируют ничего, любые две исчерпают ресурс целиком.
Отказ переборки - нормальный ответ, а не авария.BulkheadFullException и RejectedExecutionException означают «сейчас нет места», а не «зависимость сломалась»: наружу 503 с Retry-After, в логи WARN, в метрики отдельный счётчик. Растёт постоянно - лимит занижен или зависимость деградировала. Не растёт никогда - переборка ничего не ограничивает, и её стоит проверить нагрузочным тестом.
Отдельно стоит посмотреть, как отказ переборки учитывается брейкером. По умолчанию Resilience4j считает сбоем любое исключение, включая BulkheadFullException. Лимит 10, сорок одновременных запросов: десять прошли, тридцать отбиты переборкой - доля отказов 75%, брейкер открылся. Следующая волна до зависимости не доходит вообще, хотя та всё это время здорова: её выключило наше собственное ограничение параллелизма. Порядок обёрток при этом верный, переборка и должна быть внутри брейкера - чинить надо учёт:
import io.github.resilience4j.bulkhead.BulkheadFullException; import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; CircuitBreakerConfig config = CircuitBreakerConfig.custom() // отказ переборки — не сбой зависимости, брейкер его не считает .ignoreExceptions(BulkheadFullException.class) .build();
Теперь брейкер остаётся закрытым, и следующая волна обслуживается как обычно.
Повторять отбитый переборкой запрос тоже почти всегда бессмысленно: отказ означает «мощности нет», а не «не повезло», и за 50 мс мощность не появится - повтор только съест бюджет ретраев.
Мониторинг, логирование и алерты

Каждый паттерн из предыдущих разделов делает сбой менее заметным. Ретрай прячет разовую ошибку, брейкер прячет упавшую зависимость, фолбэк прячет отсутствующие данные. Это и есть их работа, и ровно поэтому обычные метрики перестают показывать правду: error rate падает, latency улучшается, аптайм зелёный, а половина ответов собрана из кэша.
Мерить приходится то, чего в обычном мониторинге нет: долю ретраев от общего числа запросов и случаи, когда исчерпался бюджет, переходы брейкера между состояниями и время, проведённое в Open, долю ответов из фолбэка - по каждой зависимости и суммарно на запрос, отказы bulkhead и длину очереди. Ни одна из этих величин не появится сама: счётчик ставится руками в том же коде, где написан фолбэк.
Алертить стоит на долю, а не на событие. Один таймаут ночью это погода, а не инцидент, ретраи для того и нужны, чтобы такое не будило дежурного. Инцидент - это когда доля деградированных ответов держится выше порога несколько минут подряд или когда брейкер не закрывается дольше обычного времени восстановления.
Отдельно стоит проверить, что алерт вообще сработает. Toxiproxy перед зависимостью, те же сценарии, что и для фолбэка, и смотреть не только на поведение кода, но и на то, загорелось ли что-нибудь на дашборде. Метрика, которую не проверяли под сбоем, ничем не лучше её отсутствия.
И сам мониторинг не должен становиться зависимостью. Синхронная отправка логов на удалённый сервер внутри обработки запроса означает, что зависший коллектор затормозит приложение: наблюдаемость уронит то, за чем наблюдает. Логгер асинхронный, буфер локальный, потеря части логов при переполнении лучше блокировки.
Заключение
Ни один из этих паттернов не убирает сбои. Они меняют форму отказа: вместо повисшего запроса - быстрая ошибка, вместо каскада - деградация одного участка, вместо неизвестности - явное «неизвестно» в ответе. Отказ никуда не девается, он становится тем, что можно предусмотреть в коде.
Цена - новые решения, каждое из которых можно принять неверно. Ретраи без бюджета умножают нагрузку ровно на упавший сервис. Фолбэк, возвращающий ноль вместо «не знаю», врёт бизнес-логике убедительнее, чем исключение. Брейкер с порогом от нуля откроется на исправном сервисе, а с порогом наугад промолчит на реальной деградации. Значений по умолчанию, которые подойдут вашей системе, тут нет ни у одной настройки.
Отсюда единственное, что стоит проверить сразу после того, как всё написано: видно ли деградацию в метриках. Ошибок нет, latency отличная, графики зелёные - и это ровно то, как выглядит система, отвечающая заглушками. Чем лучше написаны фолбэки, тем позже вы это заметите, и тем вероятнее, что заметите не вы.