Алексей Меленчук
вся записка
узел 01 · Ядро
дата
объём 6 мин чтения

Ошибка — часть контракта, а не случайность

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

Коротко. Вызывающий должен уметь перечислить, чем вызов может закончиться. Если не может — он напишет catch на всё, и этот catch проглотит не только отказ, но и дефект.

У большинства кодовых баз нет стратегии обработки ошибок. У них есть привычка бросать исключения.

Разница видна по одному признаку: может ли автор вызывающего кода перечислить, чем вызов способен закончиться, не открывая реализацию. Если ответ «ну, чем-нибудь» — контракта нет, и дальше всё предсказуемо.

Два разных зверя под одним словом

Слово «ошибка» склеивает две вещи, которые требуют противоположного обращения.

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

Дефект — это состояние, которого быть не должно. Разыменование null, выход за границу, нарушенный инвариант, недостижимая ветка, в которую попали. Продолжать работу нельзя, потому что вы больше не знаете, в каком состоянии находитесь.

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

Когда оба летят одним механизмом и ловятся одним catch, происходит худшее из возможного: дефект проглатывается, система продолжает работать в испорченном состоянии, а инцидент всплывает через два часа и в другом месте.

Почему catch (Exception) появляется сам

Не от лени. От того, что перечислить исходы невозможно.

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

Оба плохи, и оба — следствие одного: сигнатура ничего не обещает.

Языки решали это по-разному, и ни одно решение не выиграло целиком.

Java попробовала проверяемые исключения. Идея была верной: часть отказов попадает в сигнатуру, и компилятор не даст их проигнорировать. Эргономика оказалась такой, что отрасль в основном проголосовала за RuntimeException, а throws осталась в библиотеках ввода-вывода как напоминание. Я считаю, что провалилась реализация, а не идея.

PHP и Python сигнатуре ничего не сообщают вовсе. Что бросает функция — знание, которое живёт в документации, если она есть, и в памяти автора, если он ещё в компании.

C++ ушёл в другую сторону: std::expected в C++23 делает отказ обычным возвращаемым значением, которое нельзя случайно не заметить. Go и Rust пришли к тому же с разных сторон.

Направление общее и, по-моему, правильное: ожидаемый отказ должен быть виден в типе результата, а не в комментарии.

Что это значит на практике

Ожидаемые отказы моделируются явно. Либо возвращаемым значением, либо маленькой иерархией исключений, которая и есть контракт: три-четыре типа, перечисленные в одном месте, документированные, стабильные.

Ключевое слово — маленькая. Иерархия на сорок классов исключений не является контрактом, потому что её нельзя удержать в голове. Вызывающий всё равно напишет catch на базовый тип, и вы вернулись к началу.

Дефекты не ловятся. Совсем. Единственное место, где стоит общий обработчик, — граница процесса: HTTP-обработчик, потребитель очереди, точка входа задачи. Там дефект превращается в 500, запись в журнал с трассировкой и, если нужно, в остановку обработки. Всё, что глубже, дефект не трогает.

Отдельно про перевод между слоями. Ошибка драйвера базы не должна доезжать до HTTP-контроллера в исходном виде. Не из эстетики, а потому что контроллер не может принять по ней решение: он не знает, что такое 23505, и не должен знать. Слой доступа к данным обязан перевести это в «нарушение уникальности такого-то бизнес-правила», и вот с этим контроллер уже работает.

Где ошибку ловить нельзя

Есть два места, где обработка выглядит уместной и почти всегда вредит.

В конструкторе или фабрике. Объект, который не смог собраться, не должен существовать наполовину. Ловля исключения и возврат объекта с пустыми полями откладывает падение до момента, когда причина уже не видна.

Внутри цикла, молча. Классика: обрабатываем тысячу записей, на каждой try/catch, в catch — запись в журнал и continue. Выглядит устойчиво. На деле задача отрабатывает «успешно», обработав двести записей из тысячи, и никто об этом не узнаёт, потому что итог операции — «выполнено».

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

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

Что делать с чужими исключениями

Библиотека бросает своё. Драйвер бросает своё. Клиент HTTP бросает своё. Если пропустить это дальше как есть, тип чужой библиотеки становится частью вашего публичного контракта — со всеми последствиями при её замене.

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

try {
    $this->http->post($url, $payload);
} catch (ConnectException | TimeoutException $e) {
    throw new PaymentUnavailable(previous: $e);   // ожидаемый отказ
} catch (ClientException $e) {
    throw new PaymentRejected($e->getCode(), previous: $e);
}

Здесь важны две вещи. Первая: исходное исключение сохраняется как причина, иначе трассировка теряется и отладка становится гаданием. Вторая: перечислены конкретные типы, а не Throwable — иначе внутрь попадёт и дефект вашего же кода, замаскированный под «платёж недоступен».

Связь с повторами, о которой обычно забывают

Есть следствие, которое делает всё это не теоретическим.

Решение «повторять или нет» может принять только тот, кто знает природу отказа. Таймаут сети — повторять, скорее всего, стоит. Отказ банка из-за недостатка средств — повторять бессмысленно, ответ не изменится. Нарушение уникальности — повторять вредно, вы получите тот же результат и потратите ресурс.

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

Контракт ошибок — это то, из чего вырастает вся остальная устойчивость. Пока его нет, повторы, предохранители и таймауты настраиваются наугад.

Когда можно не заморачиваться. Скрипт, который вы запускаете руками раз в месяц и смотрите на вывод глазами, — там catch на всё абсолютно уместен, а падение с трассировкой и есть желаемое поведение. Всё написанное относится к коду, который вызывает кто-то другой.