Ошибка — часть контракта, а не случайность
Почему «бросим исключение, наверху разберутся» — это решение не проектировать контракт, и чем ожидаемый отказ отличается от дефекта.
Коротко. Вызывающий должен уметь перечислить, чем вызов может закончиться. Если не может — он напишет 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 на всё абсолютно
уместен, а падение с трассировкой и есть желаемое поведение. Всё
написанное относится к коду, который вызывает кто-то другой.