Как правильно использовать и обрабатывать исключения в программе (для бэкенда)
Автор: S0ER
Постоянно сталкиваюсь с глубоким непониманием того как должны обрабатываться исключения в современных приложениях, реализующих клиент-серверный подход. Поэтому решил собрать краткий гайд об основных моментах, которые нужно учесть при обработке исключений для серверной части приложения.
Введение
Все, что может пойти не так, — пойдет не так. Закон Мерфи.
В условиях, когда времени на разработку и проектирование не хватает, решения приходится принимать в отсутствие полных данных, а задачи должны были быть выполнены ещё вчера, - ошибки неизбежны.
Одно из самых неприятных проявлений в работе программы - это замалчивание проблем и «тихий» сбой без какой-либо отладочной информации. Часто это происходит потому, что разработчики не позаботились о выводе необходимых сведений в логи для диагностики.
Если вы создаёте серверное ПО, то вот краткий гайд о том, как правильно обрабатывать ошибки и тем самым повысить качество вашей программы.
Отделите ошибки от исключений
В первую очередь нужно определить о каком варианте проблемы вы хотите позаботиться, первый вариант - "ошибка", второй "исключение". Это две ситуации, которые имеют различную логику обработки.
Исключение (Exception) - это ожидаемое, но нежелательное событие в работе алгоритма, которое можно и нужно обработать. Файл не найден? Сеть упала? Пользователь ввел ерунду? Это исключение. Алгоритм должен быть готов к таким сценариям и иметь план Б. Если исключение нельзя обработать, то она становится ошибкой.
Ошибка (Error) - это фатальная ситуация, которая ломает всю систему и из которой невозможно восстановиться в рамках текущего процесса. Часто это следствие проблем среды исполнения (OutOfMemoryError, StackOverflowError, SyntaxError). Ошибки обязательно нужно логировать, и либо перезапускать систему (если она спроектирована для этого), либо давать ей умереть, чтобы инженеры разобрались.
Проще говоря: Исключение - это "что-то пошло не так". Ошибка - это "SOS - все пропало".
Как проинформировать об ошибке или исключении
Есть простое правило - исключения исправляются максимально близко к месту их возникновения или становятся ошибками. При этом существует несколько вариантов оформления ошибок:
- try/catch
- Option/Result
- Возврат кодов ошибок
Try/Catch
Это вариант который часто используется в языках для аварийной остановки процесса выполнения программы и передачи информации об ошибке вверх по стеку вызовов. Наиболее часто именно этот вариант связывают с понятием исключения, но как было сказано выше, исключение - это событие, а не способ проинформировать о нем.
Result/Option
Это механика которая помогает принять решение о том, стоит ли информировать о возникшей ошибке создавая либо Option, либо Result.
Option - это объект, в котором либо что-то лежит, либо пусто. Таким образом обработка ошибки заключается в том, что мы возвращаем значение только если смогли его вычислить, в противном случае возвращаем "ничего". Решение о допустимости такого варианта лежит выше по стеку и соответственно там же принимается решение об исключении.
Result - это объект, в котором либо правильный ответ, либо запись с описанием ошибки. В данном случае исключение не было обработано в месте возникновения и возникла ошибка которая должна быть обработана выше. Но мы не используем при этом метод throw или аналогичный.
Возврат кодов ошибок
Это старый, добрый (и часто ужасный) способ сообщить об ошибке. В данном варианте обычно используется код возврата, который сообщает выполнена ли обработка данных успешно, либо завершилась кодом ошибки. Сейчас такой способ используется редко, но до сих пор встречается, например, в протоколах передачи данных.
Как обработать исключение
Поймать исключение — это только полдела. Главное - что вы с ним сделаете.
- Подавить - самый плохой вариант. Вы прячете проблему, и система продолжает работать в невалидном состоянии. Так рождаются неуловимые баги.
- Попробовать снова (Retry) - отлично работает для временных проблем: нет сети, сервер перегружен. Но всегда нужен лимит попыток и backoff.
- Преобразовать и отправить выше по стеку - поймали низкоуровневое исключение (например,
IOException
), обернули его в свое доменное исключение (UserRepositoryException
) с более понятным контекстом и пробросили на уровень выше. - Предоставить запасной вариант (Fallback) - Не удалось получить свежие данные? Верните закэшированные. Не удалось отправить уведомление? Положите задачу в очередь на повторную отправку. Система становится устойчивой.
- Аварийно завершиться (Fail Fast) - Иногда это единственно верный ответ. Если повреждена целостность данных или система пришла в неконсистентное состояние, лучше прекратить работу.
Главный принцип: Обрабатывайте только те исключения, которые вы понимаете и можете изменить. Не ловите общие Exception
просто чтобы программа не падала и сохраняла видимость работы.
Как унифицировать обработку ошибок для команды
Для того чтобы все члены команды реагировали на ошибки и исключения одинаковым образом, нужно выработать общий принцип поведения. Наиболее популярны два подхода, которые могут использоваться как вместе, так и по отдельности:
- Fail Fast, Fail Hard
- Let it crash
Fail Fast, Fail Hard
Это принцип, при котором программа немедленно сообщает о ошибке при первом же подозрении на невалидное состояние.
- Зачем? Данные с ошибкой не должны пройти через десять сервисов, чтобы в конце концов упасть, это сильно осложнит поиск корневой причины и устранение проблемы..
- Как? Это строгая валидация на входе в каждый метод, модуль или сервис. Проверяйте аргументы, контракты, пред- и пост- условия. Не можете исправиь ошибку, значит бросайте исключение сразу, не пытайтесь «угадать» или «подправить».
- Результат: Баги обнаруживаются сразу и близко к их источнику, что упрощает дебаг. Система ведет себя предсказуемо.
Let It Crash
Этот принцип, популяризованный Erlang/OTP, звучит парадоксально: позвольте отдельным частям вашей системы падать.
Зачем?: Вы не можете предугадать все ошибки. Поэтому вы создаете изолированные дочерние процессы (акторы, воркеры, микросервисы), которые отвечают за свою маленькую часть работы. Если внутри такого процесса происходит исключительная ситуация, он аварийно завершается.
Как? Чтобы аварийное завершение не приводило к глобальным проблемам, над процессом есть «Наблюдатель» (Supervisor). Чистый, новый процесс со здоровым состоянием, который реализует одну из стратегий запуска нового процесса в случае исключения:
- One-for-One: Упал один процесс — перезапусти только его, используется в случае независимых изолированных процессов.
- All-for-One: Упал один процесс — перезапусти все процессы за которыми смотрит супервизор, используется в случае зависимых дочерних процессов.
Результат - наблюдатель может быть настроен с учетом специфики задачи, он заботится о том, чтобы падение процессов не приводило к глобальным проблемам и поставленная задача решалась в конечном итоге.
Как грамотно собирать информацию по исключениям
Когда в программе возникают исключительные ситуации, которые нельзя устранить (ошибки), то остается только один вариант - информацию о них нужно сохранить для анализа, с последующим устранением корневой причины сбоя. В современных программных системах для фиксации проблем активно используется подход: контейнеризация + Observability.
Контейнеризация - это способ при котором запускаются процессы-контейнеры, содержащие серверную часть приложения, контейнеры запускаются с помощью средств управления и все информация об ошибках должна попадать в stdout контейнера (рекомендую почитать про 12-Factor Apps для лучшего понимания). Контейнеры должны быть одноразовыми и быстро запускаться. Здесь также применим принцип Let It Crash - Упавший контейнер удаляется и запускается новый.
Observability (Наблюдаемость): - помогает фиксировать ошибки, собирать их вместе для анализа и объединять все контейнеры общей политикой сбора и анализа логов. Ваша обязанность — не просто логировать ошибки (
console.log
— это не observability). Вы должны создавать структурированные логи, которые легко агрегировать. Вы должны иметь метрики. Вы должны иметь трейсы (в сообщениях должны быть сквозные идентификаторы, которые помогают объединять информацию из логов разных контейнеров), показывающие, как ошибка прошла через всю систему.
Совмещая контейнеры и идею Observability, а так же обрабатывая ошибки и выдавая исключения - вы получаете управлямый и надежный бэкенд, который не умалчивает о проблемах, а помогает их решать.
Проверьте свое решение
- Можете ли вы по вашим логам понять, сколько различных исключений произошло в прошлом месяце и какое самое частное?
- Упадет ли ваше приложение сразу, если, например, в конфигурационный файл засунуть нечисловое значение там, где ожидается число? Или оно попытается работать дальше? Будет ли зафиксирована информация о причине падения?
- Что произойдет с пользовательским запросом, если база данных «легла» на полпути его обработки? Получит ли он таймаут, ошибку 500 или просто «висит» в неведении?
- Есть ли в вашем коде
catch (e) {}
без последующих действий? - Как быстро ваша система восстанавливается после падения критической зависимости? Делает ли она это сама, или требуется ручное вмешательство инженера?
Если в процессе вы затрудняетесь дать ответ на приведенные вопросы, или ответы не согласуются с изложенным в гайде материалом, необходимо подумать о том как улучшить обработку ошибку и исключений в вашем софте.