Паттерн написания универсальной системы ошибок приложения
Всем доброго времени суток.
1. Мотивация к написанию данной статьи
За свою карьеру написал больше 100 микросервисов и около 30 брал на сопровождение, рефакторинг и доработку. Среди них были сервисы аутентификации, криптографии, адаптеры, прокси, эмитенты токенов, DataStore/DataMart, калькулирующие измерения к срезам статистики на холодных данных и на потоке, оркестраторы с широким спектром смежных систем (пример на хабре) etc. Писал на таких языках, как С#, Java, Kotlin, Scala, Node.js. И некоторое время проходил «день сурка» в момент проектирования или рефакторинга полученного в наследство кода, когда руки доходят до аспекта логирования, мониторинга, обработки ошибок etc. В этой статье опишу с какими реализациями слоя обработки ошибок я сталкивался или находил в качестве best practice, как обычно ее интегрируют в SLA, метрики и логи, почему стал изобретать велосипед и к чему пришел, а также сравню собирательный образ классических подходов с выбраным в по итогу проб и ошибок.
2. Собирательный образ классической реализации
2.1 В объектно-ориентированных языках создается целая система кастомных ошибок, в фундамент которой выбира[ею]тся наиболее подходящи[йе] из предложенных языком в качестве суперкласса.
Минусы:
— если система наследования ошибок не прямая как гвоздь, то усложнится и система проверок и рефлексии в логике обработки ошибок;
— чем длиннее жизнь сервиса и/или обширней его бизнес логика (особенно актуально для оркестраторов), тем больше кастомных классов ошибок, что замусоревает код и структуру проекта, а полную карту ошибок можно увидеть только в аналитике к сервису или бегая по пакетам проекта с помощью возможностей IDE (этот минус особенно стреляет в ноги новым разработчикам или тебе, когда забудешь о написанном и решишь себе экскурсию устроить);
— аналитика сервиса требует актуализации при добавлении нового класса-ошибки;
— трата времени на поиск наиболее подходящего причине возникновения ошибки суперкласса, если выбор самого базового в языке не устраивает (неоднократно фиксировал такую дотошность в pr-ах и грумминге);
2.2 В других языках обычно через глобальные константы создаются те или иные представления ошибок, которые используются в проекте хаотично и часто как составная часть финальной сущности с данными об ошибке.
Минусы:
— хоть наследования и нет, а первые три минуса выше имеются и у такого решения;
2.3 Система ошибок подразумевает объединение нескольких причин возникновения не целевого поведения в рамках одной сущности или класса-ошибки.
Минусы:
— усложняется мониторинг через метрики, на потоке непонятно скольки каких причин возникает;
— troubleshooting будет отъедать больше времени, а отдел сопровождения — частенько задавать тебе вопросы приходя с боевыми кейсами;
— отслеживать — какие причины объединены случайно, а какие специально, порой неочевидно, особенно спустя n-месяцев;
2.4 Система ошибок является не сквозной и ограничена контуром твоего сервиса. Например сервису-клиенту отдаются другие представления ошибок, в худшем варианте реализации перекрывающие целые пулы причин не целевого поведения под одним кодом (например если сервис имеет REST API, то ограничиваются HTTP кодом и строковым описанием).
Минусы:
— не прозрачность, добавляет лишний уровень абстракции без сохранения изначальной информации о причине (если отдел сопровождения твоего сервиса и сервиса-клиента не один и тот же, то в случае проблем интеграции между сервисами жди вопросы от обоих отделов и команды разработки сервиса-клиента, ну, а тебе вспоминать связи между двумя системами ошибок);
— доработки бизнес-логики в сервисе-клиенте на не целевые поведения твоего сервиса с течением времени может потребовать изменений системы ошибок на твоей стороне или контракта между вами для уточнения причины ошибки;
2.5 Ошибку можно идентифицировать только по текстовой информации (названию, stack-trace, description, message etc.).
Минусы:
— обработка таких ошибок, особенно если их вариаций будет много, приведет к «зубной боле» при взгляде на код их парсинга и условий рефлексии на них на клиентской части, что скажется на вероятности багов с клиентской стороны и вовлечение тебя в их разборы на живых кейсах из прома;
— отделу сопровождения и клиентам сервиса будет сложно привыкнуть к ошибкам и запомнить, что является причиной каждой из них (тем более учитывая ротацию персонала в их рядах), тем сложнее, чем больше текстовое описание ошибки;
2.6 Ошибки пишутся в систему мониторинга в несколько метрик или не пишутся вообще.
Минусы:
— чем больше видов метрик ошибок, тем сложнее в этой системе ориентироваться, сложно разобраться сколько негативных исходов с неизвестной причиной на боевом стенде за целевой интервал;
Есть и другие черты таких систем. Думаю на этом этапе посыл уже ясен, что есть масса нюансов, которые стоит учесть в процессе создания описанного слоя.
3. Пилим идеальную систему ошибок
+ у каждой ошибки должен быть свой code типа int;
+ если есть необходимость использовать в архитектуре сквозную систему кодов ошибок и управляющих статусов, то -int будет ошибкой, а +int управляющим статусом;
+ если есть необходимость в сквозной системе ошибок между сервисами, то обычно шаг < 10000 достаточный на сервис;
+ ошибки объединены в интервалы code:
1 — 99 // зарезервированы под внутренние ошибки кода твоего сервиса
100 — 199 // зарезервированы под первую интеграцию, например ошибки клиентских сторон
100 — 199 // зарезервированы под первую интеграцию, например ошибки клиентских
сторон
200–299 // под вторую интеграцию, например базы данных
300–399 // под третью интеграцию, например кафки
400–499 // под четвертую интеграцию, например смежный сервис 1
500–599 // под третью интеграцию, например смежный сервис 2
+ в объектно-ориентированных языках можно ограничиться созданием Enum с кастовым полем int code, тогда Enum.name будет давать суть причины, ёмко и в текстовом виде, a code — станет якорем в справочнике ошибок твоего приложения, далее создай один кастомный класс ошибки от самого базового класса (например Throwable для Java), добавь ему поле Enum и готово;
+ система ошибок должна прошивать насквозь код твоего сервиса→метрики→логи→ответы клиентам:
* код твоего сервиса — все ошибки в месте возникновения должны оборачиваться в твою кастомную
* метрики — например в Prometheus можно создать единственную метрику errors с лэйблом code, что объединит все исходы в одной метрике, круглые (с 2-мя нулями) границы интервалов позволят коллегам из сопровождения и смежникам ориентироваться в интервалах определяя сторону к которой нужно идти с вопросами или в целом в каком направлении расследовать причины ошибки (если code равен первому значению из интервала);
* в логах также желательно выделить code, например если ELK, то в отдельное поле индекса;
* клиентам в ответ также необходимо отдавать code (если требования к безопасности воспрещают такое поведение, то хотя бы первое значение из интервала в который данный код входит);
Первый code ошибки из любого интервала и процент его возникновения от общего количества ошибок является процентом ТВОЕЙ лени или незнания. Например если в метрики приложения вынести частотность возникновения той или иной ошибки по code, то стремиться нужно будет к будущему в котором нет исходов с 1, 100, 200, 300, 400, 500 ошибками, а значит все возможные негативные исходы тебе известны и обработаны тобой в частном порядке;
И да, это все. Такая система лишена всех выше описанных недостатков. Прошла проверку временем.