Принципы разработки Обработка ошибок, контракты и отказоустойчивое мышление
0%

Обработка ошибок, контракты и отказоустойчивое мышление

Обработка ошибок, контракты и отказоустойчивое мышление

Практически весь предыдущий трек говорил о счастливом пути: как назвать функцию, как разделить модули, как не продублировать знание. Но в проде код проводит в счастливом пути далеко не всё время. Диск заканчивается, сеть моргает, соседний сервис деплоится, клиент присылает 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 непустой», а пришла пустая строка, значит выше по стеку кто-то сломался. Продолжать работу нельзя: вы не знаете, какие ещё инварианты нарушены.

«Не смог достучаться до платёжного шлюза» — это сбой. Ваш код корректен, домен корректен, сломался мир. Это единственная из трёх категорий, где имеют смысл ретраи.

Практический тест для отнесения к категории: «может ли эта ситуация возникнуть, если весь мой код написан правильно?» Нет → баг → падаем. Да → это исход или сбой → обрабатываем.


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)  // наружу — без деталей
	}
}

Три правила из этого примера стоят отдельного упоминания, потому что они переносятся на любой язык:

  1. Добавляй контекст при подъёме, но не дублируй его. Сообщение "ship %s: получение заказа: %w" отвечает на «что делали» и «с чем». Не пиши "ошибка: %w" — это шум. Хорошая цепочка читается как маршрут: ship ord-42: получение заказа: query orders: dial tcp: connection refused.
  2. Решения принимаются по типу/сентинелу, никогда по подстроке. strings.Contains(err.Error(), "not found") — это связанность с текстом сообщения, которое кто-нибудь однажды переведёт на русский.
  3. Детали — в лог, наружу — категория. Отдавать наружу стектрейс или 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):

  1. сеть надёжна; 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: если ответ потерялся, отправитель не знает, выполнилась операция или нет.

Реализация — таблица с уникальным индексом по ключу и сохранённым ответом:

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 ошибок подряд» на сервисе с 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 превращает надёжность в арифметику.

Главная мысль в одну строку: надёжная система — это не система без отказов, а система, в которой отказ части не становится отказом целого и не остаётся незамеченным.

Что почитать


Что дальше

Мы прошли весь путь от именования переменной до поведения системы под отказом. Осталось главное: как сделать, чтобы всё это соблюдалось не одним энтузиастом, а командой, — и как договориться, что работа считается сделанной. Об этом — в следующей статье: Код-ревью, командные стандарты и Definition of Done.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов