Всем доброго времени суток.

Мотивация к написанию данной статьи

За свою карьеру написал больше 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 достаточный на сервис, т.е. у каждого сервиса будет свой интервал в рамках которого он будет разбивать коды на меньшие интервалы по какому нибудь принципу, например на каждую смежную систему, с которой интегрирован сервис будет приходиться свой интервал кодов:
1 - 99 // зарезервированы под внутренние ошибки кода твоего сервиса;
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 ошибками, а значит все возможные негативные исходы тебе известны и обработаны тобой в частном порядке.

И да, это все. Такая система лишена всех выше описанных недостатков. Прошла проверку временем на разных языках, разных бизнес задачах, при разработке в духе стартапа и интерпрайз.

Комментарии (15)


  1. lair
    16.01.2024 19:05

    у каждой ошибки должен быть свой code типа int;

    Удачи в согласовании этих кодов в продукте на десятки и сотни раработчиков.

    ошибки объединены в интервалы code:
    1 - 99 // зарезервированы под внутренние ошибки кода твоего сервиса

    А если у меня ошибок сильно больше?

    200-299 // под вторую интеграцию, например базы данных

    А если у меня "интеграций" неизвестное количество, и они определяются в рантайме?

    Такая система лишена всех выше описанных недостатков.

    А как же вот с этим недостатком:

    Система ошибок подразумевает объединение нескольких причин возникновения не целевого поведения в рамках одной сущности или класса-ошибки. [...] отслеживать - какие причины объединены случайно, а какие специально, порой неочевидно, особенно спустя n-месяцев;

    У вас заведомо какие-то ошибки будут объединяться в группы, иначе вам кодов не хватит при первой же интеграции.


    1. pprriisstt Автор
      16.01.2024 19:05
      +3

      1 - с числом можно работать интервалами, если смежнику достаточно знать, что ошибка произошла и только, то он просто зашьется на факт получения кода.
      2 - размер интервала приведен в качестве примера.
      3 - главное иметь свой интервал на каждую, чтобы они были сгруппированы по смежникам, иначе ориентироваться в причинах будет сложно, особенно если смежников "неизвестное количество", что тоже странно, при написании кода ошибки можно сгруппировать по какому либо принципу, главное чтобы он был.
      4 - см. 2 и 3.


      1. lair
        16.01.2024 19:05

        с числом можно работать интервалами

        Чтобы работать с интервалами, нужно их знать. А чтобы их знать, нужно их согласовать.

        размер интервала приведен в качестве примера.

        Так откуда ж мне его знать на момент начала работы тогда?

        главное иметь свой интервал на каждую

        Чтобы иметь интервал на каждую, нужно знать, какие они могут быть. А откуда мне это знать, если они в рантайме настраиваются, и не обязательно мной?

        4 - см. 2 и 3.

        Нет, не решает. Более того, вы явно пишете "при написании кода ошибки можно сгруппировать по какому либо принципу" - т.е. вы привносите эту проблему.


        1. pprriisstt Автор
          16.01.2024 19:05
          +3

          1 - все зависит от бизнес потребностей, повторюсь, такой подход позволяет выбирать, смежник может ограничиться ошибками уровня протокола (GRPC/HTTP/WSS opt коды etc.), может отталкиваться от деталей твоего приложения на уровне интервала или на уровне интервала + совпадения по каким либо конкретным кодам.
          2 - в современной парадигме микросервисной архитектуры сложно представить сервис, у которого будет больше 1000 ошибок (к примеру если клиент прислал поле с неверным типом - не обязательно каждому полю в огромном json-е при несовпадении типа присваивать свой код).
          3 - для этого и нужен первый код в рамках интервала, он так сказать "слив" для всех неизвестных ошибок с каким либо смежником (опять же "смежник" это один из принципов объединения).
          4 - объединение требуется только для того, чтобы привнести порядок в хаос, например если 1 - ошибка данных поля входящего json, 2 - таймаут на базе, 3 - невалидный сертификат от твоего сервиса в качестве ошибки от Кафки, 4 - клиент прислал не json, в то время как ожидается обратное, 5 - SELECT запрос на базе содержит неожиданное для таблицы поле (если к примеру клиент может присылать sql) etc. как видно все перемешано и в этом крайне сложно ориентироваться всем, тебе в том числе, тем сложнее, чем больше вариантов ошибок.


          1. lair
            16.01.2024 19:05

            такой подход позволяет выбирать, смежник может ограничиться ошибками уровня протокола

            А при чем тут "смежник"? Я про одну систему говорю, в которой много разработчиков.

            в современной парадигме микросервисной архитектуры сложно представить сервис, у которого будет больше 1000 ошибок

            Не все работают в микросервисной архитектуре. У вас статья про "универсальную систему ошибок приложения", без уточнений про микросервисы (а для микросервисов все еще более отдельно).

            для этого и нужен первый код в рамках интервала, он так сказать "слив" для всех неизвестных ошибок с каким либо смежником (опять же это один из принципов объединения).

            Еще раз говорю: неизвестно, сколько у меня интеграций. Не-из-вест-но. Как мне выбрать интервал для каждой?

            объединение требуется только для того, чтобы привнести порядок в хаос

            У разных людей разное представление о порядке. Это автоматически приводит к проблеме "разные люди систематизируют по-разному" - которую ваша система не решает, несмотря на ваши заверения об обратном.


            1. pprriisstt Автор
              16.01.2024 19:05
              +3

              1 - любая система состоит из аспектов, модулей и т.д. главное, что не спагетти, есть какая то архитектура, объединение по какому либо принципы.
              2 - повторюсь, интервалы могут быть больше, Вы архитектор, основываясь на своем обширном опыте не угадать с размером интервала сложно (даже если ошиблись по итогу 10 лет эксплуатации и развития на порядок, больше необходимого, это не проблема).
              3 - см. 2
              4 - принцип агрегации необходим, в противном случае будет хаос (в первом комментарии Вам привел пример), а когда "правила игры" известны, можно от чего то отталкиваться.


              В качестве примера, писал оркестратор, 40+ интеграций со смежными системами, где за каждой единицей - отдельная система со своим протоколом и API (скрывающая 10+ других интеграций), придерживаясь описанного в статье принципа все сложилось в стройную картину, 5 лет прошло, сервис продолжает развиваться, заложились в интервалах с запасом, все четко, как часы, отдел сопровождения знает без обращения к разработке - что к чему и почему, быстро локализует проблему, клиентские решения тоже продолжают приростать - заложенная гибкость позволяет им писать рефлексию как описал выше, клиентами выступают как 1 разработчик со своим решением, так и целая компания в 100+ разработчиков, исключение - новые интеграции, в интервалах кодов негативных исходов с которыми не все еще прокопано.


              1. lair
                16.01.2024 19:05

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

                И что?

                повторюсь, интервалы могут быть больше, Вы архитектор, основываясь на своем обширном опыте не угадать с размером интервала сложно

                Легко можно ошибиться, особенно учитывая, что я не контролирую все места системы.

                см. 2

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

                принцип агрегации необходимо

                Значит, озвученная вами проблема останется нерешенной, вопреки вашим же заверениям.


                1. pprriisstt Автор
                  16.01.2024 19:05
                  +2

                  1 - единственно как я могу ответить на такое возражение - продумывание архитектуры и написание кода ее реализующего не должно порождать хаос второго порядка, в противном случае компании нужно что то делать с архитекторами и разработчиками, которые его породили.
                  2 - хм, есть много способов рабочий процесс вынести за рамки ручного управления.
                  3 - основное это внести определенность в принцип выбора интервала, от этого зависит его размер, если принцип - внешняя зависимость (облако, сервис, база, шина обмена событиями между сервисами etc.), то это один порядок, если - бизнес (не отработал сценарий 1, сценарий 2, сценарий 3 обработки пользовательского запроса и вся логика, пусть и вне сервиса, объединена по этому принципу), то другой. Степень неизвестности тем выше, чем менее ясно, что и зачем команда реализует в коде для бизнеса и что за архитектуру в решении планируется/получается заложить.


                  1. lair
                    16.01.2024 19:05

                    единственно как я могу ответить на такое возражение - продумывание архитектуры и написание кода ее реализующего не должно порождать хаос второго порядка

                    Что такое "хаос второго порядка"?

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

                    Есть, но далеко не все из них применимы во всех случаях (а вы претендуете именно на универсальное решение).

                    основное это внести определенность в принцип выбора интервала, от этого зависит его размер, если принцип - внешняя зависимость (облако, сервис, база, шина обмена событиями между сервисами etc.), то это один порядок, если - бизнес (не отработал сценарий 1, сценарий 2, сценарий 3 обработки пользовательского запроса и вся логика, пусть и вне сервиса, объединена по этому принципу), то другой.

                    Мне кажется, вы даже не понимаете, о чем я спрашиваю.

                    Вот вы пишете в статье:

                    ошибки объединены в интервалы code:
                    1 - 99 // зарезервированы под внутренние ошибки кода твоего сервиса;
                    100 - 199 // зарезервированы под первую интеграцию, например ошибки клиентских сторон;
                    200-299 // под вторую интеграцию, например базы данных;
                    300-399 // под третью интеграцию, например кафки;
                    400-499 // под четвертую интеграцию, например смежный сервис 1;
                    500-599 // под третью интеграцию, например смежный сервис 2;

                    В каком интервале должен быть код вызова ошибки сервиса X, который (вызов) расположен в динамически подключаемом модуле Y?


                    1. pprriisstt Автор
                      16.01.2024 19:05
                      +1

                      Мне вот интересно модуль Y вообще не поддается спецификации в Вашей архитектуре? Что угодно подключили? И делает что угодно?

                      Ваши доводы приводят меня к такой мысли: когда ничего неизвестно, то и архитектура похожа на метод result doEverything(arg1), где arg1 все, что угодно, result - тоже, в таких условиях действительно невозможно написать сколько нибудь осмысленную систему ошибок, кроме как "что то делали, почему то не вышло из за какой то ошибки."


                      1. lair
                        16.01.2024 19:05

                        Мне вот интересно модуль Y вообще не поддается спецификации в Вашей архитектуре?

                        Почему же, поддается. Он специфицирован по АПИ взаимодействия.

                        И делает что угодно?

                        А вот сделать он действительно может почти что угодно.

                        в таких условиях действительно невозможно написать сколько нибудь осмысленную систему ошибок

                        Да нет, вполне возможно. Просто не в вашей парадигме.


    1. novoselov
      16.01.2024 19:05

      Да, там после пункта сделать сontrol flow на exception'ах нужно бежать с такого проекта

      если есть необходимость использовать в архитектуре сквозную систему кодов ошибок и управляющих статусов, то -int будет ошибкой, а +int управляющим статусом

      Диапазон значений не нужен если явно добавить поле category (только теперь у разработчиков 2 проблемы, т.к нужно согласовать значения 2 полей)


      1. lair
        16.01.2024 19:05

        Да, там после пункта сделать сontrol flow на exception'ах нужно бежать с такого проекта

        Я что-то пропустил, это где?


  1. Deosis
    16.01.2024 19:05
    +5

    Предлагаю добавить функцию, которая возвращает код последней возникшей ошибки, и назвать её, например, errno.


  1. funca
    16.01.2024 19:05
    +1

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

    В первом случае реализация сильно зависит от конкретного языка программирования, фреймворка, принятых соглашений и способов интеграции. Способы, принятые в Java встанут по перек горла, если вы пишите на Golang. Кастомные способы обработки ошибок открывают разработчикам большой простор наделать новых на неизвестном им поле.

    Во втором лучше ориентироваться на требования ваших систем мониторинга. Если заранее про них ни чего не известно, то можно брать OpenTelemetry как универсальное решение - сейчас худо-бедно его поддерживают практически все. Здесь же терминология (event, log, span, trace) и необходимые атрибуты, и с большой степенью вероятности- готовые SDK под вашу платформу.