Обработка ошибок, контракты и отказоустойчивое мышление
Практически весь предыдущий трек говорил о счастливом пути: как назвать функцию, как разделить модули, как не продублировать знание. Но в проде код проводит в счастливом пути далеко не всё время. Диск заканчивается, сеть моргает, соседний сервис деплоится, клиент присылает null там, где по документации null невозможен, а пользователь дважды жмёт «Оплатить».
Есть эмпирическое наблюдение, которое стоит принять до начала статьи. В исследовании Simple Testing Can Prevent Most Critical Failures (OSDI'14) авторы разобрали 198 критических отказов в Cassandra, HBase, HDFS, MapReduce и Redis и обнаружили: 92% катастрофических отказов вызваны неправильной обработкой ошибок, которые система уже поймала. Причём в 35% случаев обработчик был откровенно тривиально плох: пустой catch, TODO/FIXME вместо кода, или логирование вместо реакции. То есть падают системы не от неизвестных сбоев — они падают от того, что мы поленились дописать пять строк в блоке, который уже сами и открыли.
Отсюда тезис статьи: обработка ошибок — это не «дописать try/catch в конце», а самостоятельный слой проектирования, у которого есть свои принципы, свой словарь и свои паттерны. Разберём его снизу вверх: сначала контракты внутри процесса, потом механика передачи ошибок в языке, потом отказоустойчивость на границах сети.
1. Три вида «плохих исходов», которые нельзя смешивать
Главная путаница в этой теме — слово «ошибка» покрывает три принципиально разные вещи, требующие противоположных реакций.
| Вид | Кто виноват | Ожидаем ли | Правильная реакция |
|---|---|---|---|
| Ожидаемый альтернативный исход | никто, это часть домена | да, это норма | вернуть значение, обработать в бизнес-логике |
| Нарушение контракта (баг) | программист | нет, это невозможно при корректном коде | упасть громко и как можно раньше |
| Внешний сбой | среда: сеть, диск, соседний сервис | да, но не здесь и не сейчас | ретрай / деградация / отказ с понятным ответом |
Разберём границу подробнее, потому что именно на ней ошибаются чаще всего.
«Пользователь не найден» — это не ошибка. Если функция называется find_user(email), то отсутствие пользователя — легитимный, ожидаемый, частый исход. Он должен быть выражен в типе результата (Optional[User], User | None, (User, bool)), а не исключением. Исключение здесь ломает две вещи: оно дороже (в CPython порядка микросекунд на raise+traceback против наносекунд на возврат None) и, что важнее, оно прячет ветку из сигнатуры — вызывающий не видит, что ему нужно что-то решить.
«Идентификатор пустой строки» — это ошибка. Если контракт функции говорит «id непустой», а пришла пустая строка, значит выше по стеку кто-то сломался. Продолжать работу нельзя: вы не знаете, какие ещё инварианты нарушены.
«Не смог достучаться до платёжного шлюза» — это сбой. Ваш код корректен, домен корректен, сломался мир. Это единственная из трёх категорий, где имеют смысл ретраи.
в точке кода"] --> B{"Это может произойти
при полностью корректном
коде и корректных данных?"} B -- "нет" --> C["БАГ: нарушен контракт"] C --> C1["assert / panic / необрабатываемое исключение
Падать немедленно, не маскировать"] C1 --> C2["На границе процесса: 500,
алерт, трассировка, тикет"] B -- "да" --> D{"Причина внутри домена
или снаружи процесса?"} D -- "внутри домена" --> E["ОЖИДАЕМЫЙ ИСХОД"] E --> E1["Вернуть значением: Optional / Result / enum
Никаких исключений"] E1 --> E2["Вызывающий обязан обработать —
это видно в типе"] D -- "снаружи: сеть, диск, чужой сервис" --> F["ВНЕШНИЙ СБОЙ"] F --> G{"Операция идемпотентна
и ошибка временная?"} G -- "да" --> H["Ретрай с экспоненциальной
задержкой и джиттером,
в пределах бюджета"] G -- "нет" --> I["Не ретраить.
Деградировать или вернуть
честную ошибку клиенту"] H --> J["Исчерпали бюджет →
circuit breaker, fallback,
очередь на потом"] I --> J
Практический тест для отнесения к категории: «может ли эта ситуация возникнуть, если весь мой код написан правильно?» Нет → баг → падаем. Да → это исход или сбой → обрабатываем.
2. Контракты: предусловия, постусловия, инварианты
Понятие контракта пришло из Design by Contract Бертрана Мейера (язык Eiffel, книга Object-Oriented Software Construction). Идея простая до банальности, но невероятно продуктивная: вызов функции — это сделка между двумя сторонами.
- Предусловие (precondition) — что обязан обеспечить вызывающий.
withdraw(amount)требуетamount > 0иamount <= balance. - Постусловие (postcondition) — что обязана обеспечить функция, если предусловие выполнено. После
withdrawбаланс уменьшился ровно наamount. - Инвариант (invariant) — что истинно про объект до и после любого публичного вызова. Баланс счёта никогда не отрицателен.
Ключевое следствие, которое переворачивает интуицию новичка: если предусловие нарушено, функция не обязана делать вообще ничего осмысленного. Она не должна «вежливо вернуть 0» или «залогировать и продолжить». Нарушенное предусловие — это доказательство наличия бага у вызывающего, и лучшее, что можно сделать, — сделать этот баг максимально громким и близким к месту возникновения.
Assert против валидации: разные вещи с похожим синтаксисом
Это различие стоит зазубрить.
| Валидация | Assert / проверка контракта | |
|---|---|---|
| Против чего защищает | недоверенный вход (пользователь, сеть, файл) | собственные баги |
| Может ли сработать в корректной системе | да, постоянно | нет, никогда |
| Что делать при срабатывании | вернуть 400 / сообщение пользователю | упасть, разбудить дежурного |
| Можно ли выключить в проде | нет, никогда | иногда да (но лучше не надо) |
| Где живёт | на границе системы | внутри, глубоко |
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class Account:
id: str
balance: Decimal # инвариант: balance >= 0
def _check_invariant(self) -> None:
# Проверка инварианта — это assert: в корректной системе не срабатывает никогда.
assert self.balance >= 0, f"инвариант нарушен: баланс {self.id} = {self.balance}"
def withdraw(self, amount: Decimal) -> None:
"""Списывает amount со счёта.
Предусловие: amount > 0 и amount <= balance (обеспечивает вызывающий).
Постусловие: balance уменьшен ровно на amount.
"""
# Предусловия — assert, а НЕ ValueError: их нарушение означает баг вызывающего.
assert amount > 0, f"amount должен быть положительным, получен {amount}"
assert amount <= self.balance, "недостаточно средств — вызывающий обязан был проверить"
old = self.balance
self.balance -= amount
assert self.balance == old - amount # постусловие
self._check_invariant()
А вот граница системы — там, где данные приходят снаружи, — работает иначе:
def handle_withdraw_request(payload: dict, account: Account) -> Response:
"""HTTP-обработчик: недоверенный вход, поэтому валидация, а не assert."""
raw = payload.get("amount")
try:
amount = Decimal(str(raw))
except (TypeError, ArithmeticError):
return Response(400, {"error": "invalid_amount", "message": "amount должен быть числом"})
if amount <= 0:
return Response(400, {"error": "invalid_amount", "message": "amount должен быть > 0"})
if amount > account.balance:
# Ожидаемый доменный исход, а не ошибка программиста.
return Response(409, {"error": "insufficient_funds", "available": str(account.balance)})
account.withdraw(amount) # предусловия здесь уже гарантированы
return Response(200, {"balance": str(account.balance)})
Обратите внимание на разделение труда: валидация происходит один раз, на границе; дальше внутрь системы попадают только корректные данные, и внутренние функции проверяют контракт assert’ами, а не дублируют валидацию. Это прямое продолжение разговора о границах модулей из статьи Связанность, связность и закон Деметры и о «глубоких модулях» — узкий валидированный вход, широкая гарантия внутри.
⚠️ Ловушка Python:
assertвырезается флагом-O. Поэтому никогда не пишите наassertпроверки безопасности или валидацию входа (assert user.is_admin— классическая уязвимость). Для assert’ов, которые обязаны работать в проде, используйте явныйif ... : raise AssertionError(...)или библиотеку вродеicontract.
Ослабление и усиление: связь с LSP
Контракты дают точное определение подстановочности из SOLID. Подкласс корректно замещает родителя, если:
- предусловия не усилены — наследник не требует от вызывающего больше, чем базовый класс (контравариантность требований);
- постусловия не ослаблены — наследник обещает не меньше, чем базовый класс (ковариантность обещаний);
- инварианты сохранены.
Практический перевод для повседневного кода: если базовый метод объявлял, что кидает только StorageError, наследник не имеет права кидать TimeoutError наружу — он обязан завернуть его. Расширение множества выбрасываемых ошибок — это усиление требований к вызывающему и, значит, нарушение LSP. Именно это ломается чаще всего при добавлении «ещё одной реализации репозитория».
Толерантный ридер против строгого валидатора
Отдельно стоит robustness principle (принцип Постела): «будь консервативен в том, что отправляешь, и либерален в том, что принимаешь». Классика сетевых протоколов — и одновременно то, за что его нещадно критикуют. Черновик IETF The Harmful Consequences of the Robustness Principle показывает механизм вреда: если приёмники прощают отклонения, отправители накапливают баги незаметно, спецификация де-факто расходится с де-юре, и через несколько лет починить это невозможно без слома половины экосистемы (история HTML до HTML5 — ровно про это).
Рабочий компромисс на 2020-е: игнорируй неизвестные поля (это forward compatibility), но строго валидируй известные. Не додумывай смысл кривых значений и не «чини» их молча.
3. Два канала ошибок: исключения против значений
Любой язык передаёт информацию об ошибке одним из двух способов, и у каждого своя цена.
Почему checked exceptions в Java «не взлетели»
Java — единственный мейнстрим-язык, который попытался поместить ошибки в сигнатуру через throws. Идея правильная, реализация оказалась негибкой, и результат хорошо описан в интервью Андерса Хейлсберга (создателя C#): проблема не в проверяемости как таковой, а в версионировании и масштабируемости. Добавление нового throws в интерфейс ломает всех наследников и всех вызывающих; а когда цепочка вызовов длинная, программисты сдаются и пишут throws Exception либо, что хуже:
try {
doSomething();
} catch (IOException e) {
// проглотили и поехали дальше — та самая треть отказов из исследования OSDI'14
}
Урок не «типизированные ошибки — плохо». Урок: типизированные ошибки работают, когда их можно композировать дешёво — как Result<T, E> в Rust с оператором ? и трейтом From для автоконверсии, или как теговые кортежи в Elixir с with.
Как это выглядит на практике: Go, Elixir, Python
Go делает ошибку обычным значением и требует явного протаскивания. С Go 1.13 у него есть оборачивание, сохраняющее цепочку:
// Сентинельные ошибки — часть публичного контракта пакета.
var ErrNotFound = errors.New("order: не найден")
// Типизированная ошибка — когда нужны детали для решения вызывающего.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("order: поле %s невалидно: %s", e.Field, e.Reason)
}
func (s *Service) Ship(ctx context.Context, id string) error {
order, err := s.repo.Get(ctx, id)
if err != nil {
// %w сохраняет исходную ошибку в цепочке, добавляя контекст «где» и «что делали».
return fmt.Errorf("ship %s: получение заказа: %w", id, err)
}
if order.Address == "" {
return &ValidationError{Field: "address", Reason: "пустой"}
}
if err := s.carrier.Book(ctx, order); err != nil {
return fmt.Errorf("ship %s: бронирование доставки: %w", id, err)
}
return nil
}
// Вызывающий принимает решение по СМЫСЛУ ошибки, а не по тексту.
func handler(w http.ResponseWriter, r *http.Request) {
err := svc.Ship(r.Context(), chi.URLParam(r, "id"))
var ve *ValidationError
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, ErrNotFound): // разворачивает всю цепочку %w
http.Error(w, "заказ не найден", http.StatusNotFound)
case errors.As(err, &ve): // достаёт конкретный тип из цепочки
http.Error(w, "поле "+ve.Field+": "+ve.Reason, http.StatusBadRequest)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "таймаут", http.StatusGatewayTimeout)
default:
log.Error("ship failed", "err", err) // полный контекст — в лог
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) // наружу — без деталей
}
}
Три правила из этого примера стоят отдельного упоминания, потому что они переносятся на любой язык:
- Добавляй контекст при подъёме, но не дублируй его. Сообщение
"ship %s: получение заказа: %w"отвечает на «что делали» и «с чем». Не пиши"ошибка: %w"— это шум. Хорошая цепочка читается как маршрут:ship ord-42: получение заказа: query orders: dial tcp: connection refused. - Решения принимаются по типу/сентинелу, никогда по подстроке.
strings.Contains(err.Error(), "not found")— это связанность с текстом сообщения, которое кто-нибудь однажды переведёт на русский. - Детали — в лог, наружу — категория. Отдавать наружу стектрейс или SQL-запрос — это утечка информации, входящая в OWASP Top 10 как часть A05: Security Misconfiguration.
Elixir доводит идею до её логического предела — теговые кортежи плюс with для композиции:
# Ожидаемые исходы: теговые кортежи. Композиция без вложенных case.
def ship(order_id) do
with {:ok, order} <- Repo.fetch(order_id),
:ok <- validate_address(order),
{:ok, label} <- Carrier.book(order) do
{:ok, label}
else
{:error, :not_found} -> {:error, :not_found}
{:error, :no_address} -> {:error, {:validation, :address}}
{:error, reason} -> {:error, {:carrier_failed, reason}}
end
end
# Баг — не через кортежи. Функция с ! падает, и это правильно:
# supervisor перезапустит процесс в известное хорошее состояние.
def ship!(order_id) do
{:ok, label} = ship(order_id)
label
end
Подробнее про супервизоры и «let it crash» — в треке Elixir; там же разбирается, почему эта модель работает только при наличии изолированной памяти и дешёвого перезапуска.
В Python идиоматичен гибрид: исключения для сбоев и багов, возвращаемые значения для доменных исходов — и обязательно своя иерархия исключений, чтобы вызывающие ловили категорию, а не Exception:
class OrderError(Exception):
"""Корень иерархии домена: позволяет вызывающему поймать всё наше одним except."""
class OrderNotFound(OrderError): ...
class CarrierUnavailable(OrderError):
"""Временный сбой — retryable."""
retryable = True
def ship(order_id: str) -> Label:
try:
order = repo.get(order_id)
except psycopg.OperationalError as exc:
# Переводим ошибку чужого слоя в СВОЙ словарь: вызывающие не должны
# знать, что под репозиторием живёт psycopg. Это утечка абстракции.
raise CarrierUnavailable("база недоступна") from exc # from сохраняет причину
...
Ключевая деталь — raise ... from exc. Она сохраняет __cause__, и traceback покажет обе ошибки. Потеря причины при перезаворачивании — самый частый способ превратить пятиминутный разбор инцидента в двухчасовой.
4. Fail fast: почему падать полезно
Интуиция подсказывает: «упавший процесс — это плохо, надо любой ценой продолжать». Это ошибка, и вот почему.
Программа после нарушения инварианта находится в неизвестном состоянии. Она может списать деньги дважды, отправить письмо не тому адресату, записать в базу мусор, который потом будут разбирать месяцами. Джим Грей ещё в 1985 году в работе Why Do Computers Stop and What Can Be Done About It? ввёл понятие fail-fast компонента: такой компонент либо работает корректно, либо немедленно останавливается. Промежуточного «работает неправильно, но продолжает» не существует.
Практическая формула: цена мгновенного падения ограничена и известна; цена продолжения в повреждённом состоянии не ограничена.
Из этого вырастает архитектурный подход crash-only software (Candea & Fox, HotOS'03): если единственный способ остановки — падение, то путь восстановления тестируется каждый раз, а не раз в год во время инцидента. Отсюда прямая связь с двенадцатифакторным приложением (Twelve-Factor App и cloud-native принципы): фактор IX, «disposability», — это ровно требование crash-only, а stateless-процессы делают его выполнимым.
Где падать нельзя: на границе, обслуживающей пользователей. Веб-обработчик обязан поймать всё, вернуть 500, залогировать и не уронить весь пул воркеров. Отсюда правило:
Падай глубоко, лови на границе. Внутри домена — assert и необработанные исключения. На границе процесса (HTTP-хендлер, consumer очереди, тик воркера) — ровно один «bulkhead»-catch, который логирует полный контекст и отвечает клиенту категорией ошибки. Ровно один — не в каждом слое.
5. Отказоустойчивость: восемь заблуждений и производная от них
Как только вызов уходит за границу процесса, действуют Fallacies of Distributed Computing (Питер Дойч, Джеймс Гослинг, Sun Microsystems):
- сеть надёжна; 2. задержка нулевая; 3. полоса бесконечна; 4. сеть безопасна; 5. топология не меняется; 6. администратор один; 7. транспорт бесплатен; 8. сеть однородна.
Из первых двух заблуждений следуют почти все паттерны ниже. И главный принцип: в распределённой системе отказ — не событие, а режим работы. Система должна быть спроектирована так, чтобы отказ части не превращался в отказ целого.
5.1 Таймауты: самое дешёвое и самое забываемое
Вызов без таймаута — это утечка ресурса с неограниченным сроком. Один зависший вызов держит поток/горутину/соединение, при нагрузке они кончаются, и падает всё.
Правило проектирования: таймауты должны образовывать убывающую вложенность вдоль цепочки вызовов — то, что называют deadline propagation или timeout budget.
Правильная реализация передаёт не «таймаут», а дедлайн — абсолютный момент времени, — потому что таймаут при каждом хопе надо пересчитывать, а дедлайн переносится как есть:
// Клиент кладёт дедлайн в контекст; он едет вниз по всей цепочке.
ctx, cancel := context.WithTimeout(r.Context(), 1800*time.Millisecond)
defer cancel()
// Нижний слой сам считает, сколько у него осталось, и не начинает
// работу, на которую заведомо не хватит времени.
if deadline, ok := ctx.Deadline(); ok {
if time.Until(deadline) < 150*time.Millisecond {
return ErrNotEnoughTimeBudget // fail fast вместо гарантированного таймаута
}
}
Выбор значения: берите p99.9 нормального времени ответа, а не среднее. Таймаут по среднему превратит нормальную флуктуацию в шторм ошибок. Если p99.9 неизвестен — вы не готовы выставлять таймаут, сначала поставьте измерения.
5.2 Ретраи: самый опасный из «простых» паттернов
Ретрай выглядит как бесплатное повышение надёжности. На деле он единственный из паттернов способен сам вызвать отказ. Механику видно на картинке:
Разбор арифметики. Пусть каждый из n слоёв делает k попыток. Число обращений к самому нижнему компоненту равно k^n — экспоненциально по числу слоёв. Три слоя по 3 попытки = 27×. Именно поэтому в момент лёгкой деградации базы нагрузка на неё не падает, а взлетает: это положительная обратная связь, классический метастабильный отказ (см. Metastable Failures in Distributed Systems, HotOS'21) — система не восстанавливается даже после того, как исходная причина исчезла.
Отсюда четыре обязательных ограничителя:
(1) Ретраить только идемпотентное и только retryable. GET, PUT, DELETE идемпотентны по RFC 9110; POST — нет. Ретрай POST /payments без ключа идемпотентности — это способ списать деньги дважды. Ошибки валидации (4xx) не ретраятся никогда: повтор того же запроса даст тот же ответ, вы просто сожжёте бюджет.
(2) Экспоненциальная задержка с джиттером. Без джиттера клиенты, отвалившиеся одновременно, вернутся тоже одновременно — синхронизированное стадо. Эталонный разбор — Exponential Backoff and Jitter из AWS Builders’ Library, где эмпирически показано, что full jitter даёт наименьшее число вызовов при наименьшем разбросе времени завершения.
import random, time
def full_jitter_delay(attempt: int, base: float = 0.1, cap: float = 20.0) -> float:
"""Full jitter: sleep = random(0, min(cap, base * 2**attempt)).
Матожидание задержки вдвое меньше, чем у «backoff + равномерный шум»,
а дисперсия момента возврата максимальна — именно это и размазывает стадо.
"""
return random.uniform(0, min(cap, base * (2 ** attempt)))
def call_with_retry(fn, *, max_attempts: int = 3, deadline: float):
"""Ретрай в пределах ДЕДЛАЙНА, а не «просто 3 раза»."""
last_exc = None
for attempt in range(max_attempts):
if time.monotonic() >= deadline:
raise TimeoutError("бюджет исчерпан") from last_exc
try:
return fn()
except Exception as exc:
# Ретраим только то, что помечено как временное.
if not getattr(exc, "retryable", False):
raise
last_exc = exc
delay = full_jitter_delay(attempt)
# Не спим дольше, чем осталось до дедлайна.
delay = min(delay, max(0.0, deadline - time.monotonic()))
time.sleep(delay)
raise last_exc
Стоимость: время до финального отказа при k попытках и базе b — сумма геометрической прогрессии, то есть O(b · 2^k) в худшем случае. Именно поэтому число попыток должно быть маленьким (2–3), а ограничителем служит дедлайн, а не счётчик.
(3) Ретраить на одном слое. Обычно — на самом внешнем, который знает полный бюджет. Все внутренние HTTP-клиенты выставляются в retries = 0. Это скучная организационная работа (аудит конфигов всех библиотек), и она регулярно спасает прод.
(4) Retry budget вместо лимита попыток. Подход из Google SRE Book, глава Handling Overload: клиент ведёт скользящее окно и не допускает, чтобы ретраи составляли более ~10% от общего числа запросов. Тогда при массовой деградации ретраи автоматически прекращаются — вместо того чтобы добивать сервис. Память: O(w) для окна из w записей, на практике — просто два счётчика с экспоненциальным затуханием, O(1).
5.3 Идемпотентность: то, что делает ретраи безопасными
Идемпотентность — свойство операции давать тот же наблюдаемый результат при повторном применении. Это фундамент всей отказоустойчивости, потому что сеть даёт нам ровно at-least-once: если ответ потерялся, отправитель не знает, выполнилась операция или нет.
Idempotency-Key: 7f3a-... S->>D: INSERT idempotency_keys(key, state='in_progress') D-->>S: OK (индекс свободен) S->>P: charge(card, 100 RUB) P-->>S: ok, txn_id=555 S->>D: UPDATE key SET state='done', response={txn:555} S--xC: 201 Created (ОТВЕТ ПОТЕРЯН в сети) Note over C,S: Клиент не знает исхода и повторяет запрос —
ровно та ситуация, ради которой всё это строилось C->>S: POST /payments
Idempotency-Key: 7f3a-... (тот же!) S->>D: INSERT idempotency_keys(...) D-->>S: конфликт уникального индекса S->>D: SELECT state, response WHERE key='7f3a-...' D-->>S: state='done', response={txn:555} S-->>C: 201 Created, txn_id=555 (тот же ответ, второго списания НЕТ) Note over S,P: Если бы state было 'in_progress',
сервис вернул бы 409 Conflict / 425 Too Early,
а не запустил второе списание
Реализация — таблица с уникальным индексом по ключу и сохранённым ответом:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY, -- ключ, сгенерированный КЛИЕНТОМ
request_hash TEXT NOT NULL, -- защита от переиспользования ключа с другим телом
state TEXT NOT NULL, -- in_progress | done | failed
response JSONB, -- сохранённый ответ для повторной отдачи
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL -- ключи не хранятся вечно: обычно 24ч–7 суток
);
CREATE INDEX ON idempotency_keys (expires_at); -- для фонового вычищения
Три нюанса, которые обычно упускают:
- Ключ генерирует клиент, а не сервер: иначе повтор не будет узнан. UUIDv4 на попытку бизнес-операции, а не на HTTP-запрос.
request_hashобязателен. Иначе клиент с багом переиспользует ключ для другого платежа и получит чужой ответ. При несовпадении хэша —422, а не «выполнить».- Вставка ключа и бизнес-операция должны быть в одной транзакции, иначе между ними есть окно, в котором операция выполнена, а ключ не сохранён. Если бизнес-операция во внешней системе (PSP) — используйте паттерн
in_progress+ сверку, как на диаграмме. Именно так устроен Idempotency-Key в Stripe и стандартизируемый IETF draft: The Idempotency-Key HTTP Header Field.
5.4 Circuit breaker: перестать долбить труп
Когда зависимость лежит, каждый вызов к ней — это потраченный таймаут, занятый поток и добитая зависимость. Предохранитель (Майкл Найгард, Release It!; разбор у Фаулера — CircuitBreaker) переводит «медленный отказ» в «мгновенный отказ».
при объёме >= минимального
(10 вызовов за 10 с) Open --> HalfOpen: истёк cooldown
(напр. 30 с, с джиттером) HalfOpen --> Closed: пробные вызовы успешны
→ сброс счётчиков HalfOpen --> Open: хотя бы один пробный упал
→ новый cooldown (можно удваивать) note right of Open Пока OPEN, вызывающий обязан иметь план Б: кэш, деградация, очередь, честная 503. Breaker НЕ делает систему доступной — он ограничивает ущерб. end note note right of Closed Минимальный объём обязателен: 1 ошибка из 2 вызовов — это не 50%, это статистический шум. end note
Тонкости, из-за которых предохранители чаще всего работают неправильно:
- Порог по доле, а не по количеству. «10 ошибок подряд» на сервисе с 50k RPS — это доля 0.02%, размыкать нельзя. Считайте отношение в скользящем окне и требуйте минимальный объём.
- Гранулярность — на зависимость, а лучше на «зависимость + инстанс». Один общий breaker на весь HTTP-клиент разомкнётся из-за одного больного узла и отрежет здоровые.
- Не считайте 4xx ошибками.
400/404— это корректная работа зависимости; учитывать их — значит размыкать предохранитель из-за багов клиента. - Half-open нужно ограничивать по конкурентности, иначе после cooldown в едва оживший сервис влетит вся накопленная нагрузка.
Готовые реализации: resilience4j (JVM), Polly (.NET, см. также трек C#), gobreaker (Go), а на уровне инфраструктуры — outlier detection в Envoy. Инфраструктурный вариант обычно лучше: он единообразен и не требует правок в каждом сервисе.
5.5 Bulkhead, load shedding и graceful degradation
Bulkhead (переборка). Название — из судостроения: корпус делится на отсеки, пробоина в одном не топит судно. В коде это раздельные пулы ресурсов на зависимость: если «рекомендации» съели свои 10 соединений — оформление заказа, у которого свой пул, продолжает работать. Без переборок один медленный вызов заполняет общий пул, и падает всё сразу — это и есть каскадный отказ.
Load shedding (сброс нагрузки). Когда запросов больше, чем система может обработать, худшая стратегия — принимать все и всем отвечать медленно: очередь растёт, каждый ответ приходит после клиентского таймаута, полезная работа = 0. Правильно — быстро отказывать избыточной части с 429/503 и Retry-After. Практический приём из AWS Builders’ Library: отбрасывать запросы, у которых оставшийся дедлайн меньше ожидаемого времени обработки, — они всё равно никому не нужны.
Graceful degradation. Ранжируйте функциональность по критичности заранее, а не во время инцидента. Пример для маркетплейса:
| Уровень | Функция | Поведение при отказе зависимости |
|---|---|---|
| P0 | оформление и оплата заказа | не деградирует; отказ = инцидент |
| P1 | карточка товара, поиск | отдать из кэша, пусть чуть устаревшее |
| P2 | персональные рекомендации | скрыть блок / показать топ-продажи |
| P3 | «с этим товаром покупают», баннеры | молча выключить |
Ключевой критерий проектирования: отказ зависимости уровня P2 не должен влиять на путь P0. Если рекомендации способны уронить оформление заказа — у вас нет переборок.
6. Ошибки как часть API: контракт наружу
Внутренняя иерархия исключений — ваше дело. Наружу должен идти стабильный, документированный, машиночитаемый словарь ошибок. Минимальный набор требований:
- Машиночитаемый код (
insufficient_funds), стабильный навсегда. Не текст сообщения, не номер HTTP. - Человекочитаемое сообщение — для логов и разработчика, но не для показа пользователю дословно.
- Признак повторяемости — клиент должен понимать, имеет ли смысл ретрай.
- Идентификатор запроса (
trace_id) — чтобы связать жалобу пользователя с логами.
Стандартный формат для HTTP есть — RFC 9457, Problem Details for HTTP APIs:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Недостаточно средств",
"status": 409,
"detail": "На счёте 250.00 RUB, требуется 1000.00 RUB",
"instance": "/accounts/42/withdrawals",
"balance": "250.00",
"trace_id": "3f9a1c8e-..."
}
Выбор HTTP-статуса — тоже часть контракта, и здесь массово ошибаются:
| Ситуация | Статус | Почему |
|---|---|---|
| Тело не проходит валидацию | 400 / 422 |
ошибка клиента, ретрай бессмысленен |
| Нужна аутентификация | 401 |
не 403 |
| Аутентифицирован, но нет прав | 403 |
|
| Состояние ресурса не позволяет | 409 |
«недостаточно средств», «заказ уже отменён» |
| Перегрузка, рейт-лимит | 429 + Retry-After |
ретрай осмыслен, но позже |
| Свой баг | 500 |
без деталей наружу |
| Зависимость недоступна | 503 + Retry-After |
явный сигнал «попробуй позже» |
| Дедлайн исчерпан | 504 |
Отдельно: 200 OK с полем {"error": ...} в теле — антипаттерн. Он ломает всю инфраструктуру, которая считает ошибки по статусам: мониторинг, ретрай-политики прокси, circuit breaker, кэши. Если у вас gRPC — используйте коды состояния gRPC, они ровно для этого.
7. Наблюдаемость ошибок: логи, метрики, бюджет
Ошибка, которую никто не увидел, эквивалентна ошибке, которой не было — до момента, когда её увидит клиент.
Логируйте один раз. Самая частая патология — «логируй и пробрасывай» на каждом уровне: один сбой порождает семь записей, дежурный видит шторм и не понимает масштаб. Правило: либо обработал, либо пробросил. Логирует тот, кто принял решение — обычно граница процесса.
Логируйте структурно и с контекстом. log.error("ошибка") бесполезен. Нужны trace_id, идентификаторы сущностей, имя операции, категория ошибки, длительность. И никаких PII/секретов: номера карт, токены, пароли в логах — это инцидент безопасности, а логи живут дольше, чем кажется.
Метрики важнее логов для принятия решений. Минимальный набор на каждую исходящую зависимость: RPS, доля ошибок по категориям, гистограмма латентности (p50/p95/p99), состояние breaker’а, использование пула, число ретраев. Ретраи выделяйте в отдельную метрику — их рост это ранний индикатор надвигающегося каскада.
Error budget. Из Google SRE Book: если SLO доступности 99.9% в месяц, то бюджет ошибок — 0.1%, около 43 минут. Это переводит спор «релизить или стабилизировать» из плоскости мнений в арифметику: бюджет есть — катим фичи, бюджет сожжён — замораживаем релизы и чиним надёжность. И заодно избавляет от иллюзии, что цель — 100%: 100% недостижимо и, главное, невыгодно.
8. Как это тестировать
Код обработки ошибок — самый малотестируемый код в системе, потому что счастливый путь тестируют все, а except — почти никто. Именно поэтому он и содержит 92% причин катастроф.
(1) Тесты на путях ошибок обязательны. Каждый catch/if err != nil должен быть покрыт как минимум одним тестом. Простой измеримый критерий на ревью: если диф добавил ветку обработки ошибки и не добавил тест — это замечание. Про то, как встроить это в процесс, — в Код-ревью, командные стандарты и Definition of Done.
(2) Fault injection на уровне тестов. Подменяйте зависимость дублёром, который умеет отказывать (подробнее о видах дублёров — в Принципы тестирования):
class FlakyCarrier:
"""Дублёр, воспроизводящий реальные режимы отказа, а не только 'бросает Exception'."""
def __init__(self, script: list[str]):
self.script, self.calls = script, []
def book(self, order):
self.calls.append(order.id)
mode = self.script[min(len(self.calls) - 1, len(self.script) - 1)]
if mode == "timeout":
raise CarrierUnavailable("timeout")
if mode == "slow":
time.sleep(5) # проверяем, что НАШ таймаут срабатывает
if mode == "lost_response":
# Самый коварный режим: операция ВЫПОЛНИЛАСЬ, а ответ не дошёл.
self._really_book(order)
raise CarrierUnavailable("connection reset")
return Label(order.id)
def test_lost_response_does_not_double_book():
"""Главный тест идемпотентности: повтор после потерянного ответа."""
carrier = FlakyCarrier(["lost_response", "ok"])
ship(order_id="ord-1", carrier=carrier)
assert carrier.booked_count("ord-1") == 1 # не 2
Режим lost_response — то, что забывают почти всегда, а именно он ловит отсутствие идемпотентности.
(3) Chaos engineering в проде. Принципы chaos engineering формулируются как эксперимент: выдвигаем гипотезу об устойчивом состоянии, вносим реалистичное возмущение (убить инстанс, добавить 200 мс латентности, отрезать зону), минимизируем радиус поражения, проверяем гипотезу. Первоисточник — Chaos Monkey от Netflix. Начинать имеет смысл не с Chaos Monkey, а с game day: собрать команду, руками выключить реплику в стейджинге и посмотреть, что произойдёт. Обычно этого достаточно, чтобы найти три отсутствующих таймаута.
9. Каталог типичных ошибок
| Антипаттерн | Чем плох | Как правильно |
|---|---|---|
Пустой catch |
сбой исчезает; система работает в неизвестном состоянии | обработать, пробросить или явно задокументировать, почему игнор безопасен |
catch (Exception) на каждом уровне |
ловит и баги (NullPointerException), маскируя их под сбои |
ловить конкретные типы; широкий catch — только на границе процесса |
| Ретрай без идемпотентности | двойные списания, дубли писем | ключ идемпотентности, ретраить только безопасные операции |
| Ретрай на каждом слое | умножение нагрузки k^n, метастабильный отказ |
ретрай на одном слое + retry budget |
| Вызов без таймаута | исчерпание пула, каскадный отказ | таймаут везде, дедлайн через контекст |
| Таймаут по среднему времени | шторм ложных ошибок при обычной флуктуации | p99.9 + запас |
| Решение по тексту ошибки | ломается при рефакторинге и локализации | сентинелы, типы, коды |
| Стектрейс наружу клиенту | утечка внутреннего устройства, вектор атаки | детали в лог, наружу — код и trace_id |
| Логирование на каждом уровне | шторм в логах, потеря масштаба сбоя | логировать один раз, на границе |
200 OK с ошибкой в теле |
ломает мониторинг, ретраи, кэш, breaker | корректный HTTP-статус |
| Потеря причины при заворачивании | инцидент разбирается часами вместо минут | raise ... from, %w, InnerException |
| Assert для валидации входа | вырезается оптимизацией; уязвимость | явная валидация на границе |
| Fallback, скрывающий отказ | «всё зелёно», а данные устарели на 3 дня | fallback + метрика + алерт на долю fallback’ов |
| Breaker без half-open | не восстанавливается автоматически | half-open с ограниченной конкурентностью |
| Общий пул на все зависимости | отказ P3-функции роняет P0 | bulkhead: пул на зависимость |
Отдельно про предпоследнюю строку: тихий fallback опаснее явного отказа. Если при недоступности сервиса тарифов вы молча берёте прошлогодние цены и никого не будите — вы продаёте по неправильной цене, и узнаете об этом от бухгалтерии через месяц. Fallback обязан быть наблюдаемым.
10. Как это выглядит в проде: сборка воедино
Соберём один исходящий вызов со всеми слоями защиты в правильном порядке. Порядок важен: обёртки применяются снаружи внутрь.
# Порядок вложенности (снаружи внутрь):
# бюджет ретраев → circuit breaker → bulkhead (семафор) → таймаут → сам вызов
#
# Почему именно так:
# - breaker должен видеть исход ПОСЛЕ таймаута (таймаут — это тоже отказ);
# - breaker снаружи семафора, иначе отклонённые вызовы будут занимать слоты;
# - ретрай — самый внешний: он повторяет всю конструкцию целиком.
def call_carrier(order: Order) -> Label:
deadline = time.monotonic() + 1.8 # общий бюджет операции
def attempt() -> Label:
if breaker.is_open(): # мгновенный отказ, без сетевого вызова
raise CarrierUnavailable("breaker open")
with carrier_pool.acquire(timeout=0.05): # bulkhead: свои 10 слотов, не общие
try:
label = http.post(
"/book",
json=order.to_dict(),
headers={"Idempotency-Key": order.idempotency_key},
timeout=0.6, # < оставшегося бюджета
)
breaker.record_success()
return Label.parse(label)
except (Timeout, ConnectionError) as exc:
breaker.record_failure()
raise CarrierUnavailable(str(exc)) from exc
except HTTPStatusError as exc:
if 400 <= exc.status < 500:
breaker.record_success() # 4xx — зависимость ЗДОРОВА, виноват запрос
raise ValidationError(exc.body) from exc
breaker.record_failure()
raise CarrierUnavailable(str(exc)) from exc
if not retry_budget.allow(): # не более 10% ретраев от трафика
return attempt() # одна попытка без повторов
return call_with_retry(attempt, max_attempts=3, deadline=deadline)
Полезно понимать, что в 2020-х этот код всё чаще не пишут руками. Таймауты, ретраи, breaker, outlier detection и load shedding выносятся в service mesh (Istio, Linkerd) или в sidecar-прокси Envoy: политика описывается декларативно и применяется единообразно ко всем сервисам, включая написанные на языках, где хорошей библиотеки просто нет.
# Istio VirtualService: политика надёжности как конфигурация, а не как код в 40 репозиториях.
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: carrier
spec:
hosts: ["carrier"]
http:
- timeout: 0.6s # жёсткий таймаут на попытку
retries:
attempts: 2 # ретраи ТОЛЬКО здесь; в сервисах — нули
perTryTimeout: 0.6s
retryOn: 5xx,reset,connect-failure,retriable-status-codes
route:
- destination: { host: carrier }
Но: mesh не заменяет доменную часть. Идемпотентность, graceful degradation, выбор кодов ошибок, ранжирование функциональности по критичности — это решения о смысле, их нельзя вынести в конфиг прокси. Инфраструктура закрывает транспортный уровень; контракты и деградация остаются задачей проектирования.
Ещё один продовый слой, о котором стоит помнить, — асинхронная граница. Для consumer’ов очередей действуют те же принципы, но в других декорациях: обработчик обязан быть идемпотентным (доставка at-least-once), «ядовитые» сообщения после N неудач уезжают в dead letter queue вместо бесконечного цикла, а ретраи выполняются с задержкой через отдельную retry-очередь, а не блокирующим sleep в обработчике (иначе один поток встанет и партиция перестанет двигаться).
11. Мини-итог
- «Ошибка» — это три разные вещи. Ожидаемый исход возвращают значением, баг приводит к падению, внешний сбой обрабатывают паттернами отказоустойчивости. Смешивание категорий — корень большинства проблем.
- Контракт (предусловия, постусловия, инварианты) переводит «функция как-то работает» в проверяемое утверждение. Валидация — на границе против недоверенного входа; assert — внутри против собственных багов.
- Fail fast: цена немедленного падения ограничена, цена работы в повреждённом состоянии — нет. Падай глубоко, лови на границе, ровно один раз.
- Ошибка — часть публичного API: стабильные машиночитаемые коды, корректные HTTP-статусы, никаких внутренних деталей наружу.
- Таймаут — обязателен всегда, дедлайн вкладывается по цепочке. Ретрай — только идемпотентное, с джиттером, на одном слое, в пределах бюджета. Идемпотентность — то, что делает ретраи безопасными. Breaker и bulkhead ограничивают радиус поражения. Деградация решает, чем именно жертвуем.
- Всё это не работает, если не наблюдается и не тестируется: путь ошибок покрывается тестами, отказы инжектируются, error budget превращает надёжность в арифметику.
Главная мысль в одну строку: надёжная система — это не система без отказов, а система, в которой отказ части не становится отказом целого и не остаётся незамеченным.
Что почитать
- Michael Nygard. Release It! Design and Deploy Production-Ready Software — исходник паттернов circuit breaker, bulkhead, timeout; лучшая книга по теме.
- AWS Builders’ Library — разделы про timeouts/retries/backoff, load shedding, идемпотентность; практика гипермасштаба, изложенная человеческим языком.
- Google SRE Book — главы Embracing Risk, Handling Overload, Addressing Cascading Failures.
- Yuan et al. Simple Testing Can Prevent Most Critical Failures, OSDI'14.
- Candea, Fox. Crash-Only Software, HotOS'03.
- Bertrand Meyer. Applying Design by Contract, IEEE Computer, 1992.
- Martin Fowler. CircuitBreaker.
- RFC 9457: Problem Details for HTTP APIs.
Что дальше
Мы прошли весь путь от именования переменной до поведения системы под отказом. Осталось главное: как сделать, чтобы всё это соблюдалось не одним энтузиастом, а командой, — и как договориться, что работа считается сделанной. Об этом — в следующей статье: Код-ревью, командные стандарты и Definition of Done.