🪲 Ошибки надо возвращать (return), а не выкидывать (throw)Короче, я раньше к этой теме подходил деликатно, типа "это полезно", "было бы круто, если бы вы делали так", "ну если вы решитесь" и так далее, а сейчас полностью убедился, что это точно верный подход и другого советовать не хочу.У этого подхода куча преимуществ: (1) вы четко видете какие виды ошибок возвращаете (особенно если создаете кастомные ошибки), (2) нет неявных перехватчиков ошибок, (3) try очень громоздкая и некрасивая конструкция, которая может добавить несколько уровней вложенности и много других полезных пунктов.Ну и всегда был концептуальный вопрос, которому посвящены множество конференций и статей: почему я должен выбрасывать (throw) ошибку, которая является нормальной частью бизнес-логики (например, NotFound на сущность, которой реально может не быть) при том, что это может убить мою программу, если не будет отловлено.Что так резко меня убедило? 1. Исторически так было в C.2. Далее эту фишку подхватил Go.3. В Rust такая же история, НО они возвращают типа монаду Result<T, Error>.4. И тут я узнаю, что в Zig (языке, на который я делаю сейчас огрмную ставку) прям встроенный оператор Union Type для возврата ошибок.Ну, а про функциональные языки даже говорить не буду, там это просто стандарт, тоже через монады.Если использовать TS, то самый адекватный вариант это: type Result<T, E extends Error = Error> = T | E – могу потом рассказать почему.Да, есть некоторые места (например, валидация отдельных свойств), где это может сильно засрать код, но и для этого есть решение (об этом в следующих постах)Мне теперь интересно: насколько вам заходит идея возвращать ошибки, а не выбрасывать их?
Как насчёт трассировки исключений? Если мы делаем return, то ошибки по факту нет
Все зависит от того на каком уровне тебе нужна трассировкаТо есть, если это критическая ошибка (например, отвалилась база), то ее можно выкидывать на самый вверх по стэку в общий Error Handler и там будет трассировкаА на уровне обработки return (например, не прошел constraint) можно, например, залогироватьИли я не понял кейса?
Все зависит от того на каком уровне тебе нужна трассировкаТо есть, если это критическая ошибка (например, отвалилась база), то ее можно выкидывать на самый вверх по стэку в общий Error Handler и там будет трассировкаА на уровне обработки return (например, не прошел constraint) можно, например, залогироватьИли я не понял кейса?
Что в твоей системе глобальный хендлер?Я могу предположить 2 варианта:1. Это что-то рядом с api gateway, что трансформирует ответы на клиента2. Речь про монолит и мы не учитываем распределённую трассировку. Этот принцип может работать в рамках распределённой системы, как например у нас в Moleculer работает regenerator для воссоздания классов по имплементированной логике из сериализатора (если он их не поддерживает из коробки как cbor-x), тогда по транспорту можно гонять эти ошибки как есть. Создавая дополнительный слой логики, накладных расходов и абстракций. А точно ли нужно это с точки зрения архитектуры?Прокидывая вызовы дальше, мы увеличиваем стэк ненужными шагами, а он будет лимитирован в любом случае, т.к. нерационально хранить сильно большой трейс. Метрики и трассировка в ноде развивается своим тредом на уровне v8 через channels и будет в дальнейшем из коробки интегрирована в open-telemetry.
Что в твоей системе глобальный хендлер?Я могу предположить 2 варианта:1. Это что-то рядом с api gateway, что трансформирует ответы на клиента2. Речь про монолит и мы не учитываем распределённую трассировку. Этот принцип может работать в рамках распределённой системы, как например у нас в Moleculer работает regenerator для воссоздания классов по имплементированной логике из сериализатора (если он их не поддерживает из коробки как cbor-x), тогда по транспорту можно гонять эти ошибки как есть. Создавая дополнительный слой логики, накладных расходов и абстракций. А точно ли нужно это с точки зрения архитектуры?Прокидывая вызовы дальше, мы увеличиваем стэк ненужными шагами, а он будет лимитирован в любом случае, т.к. нерационально хранить сильно большой трейс. Метрики и трассировка в ноде развивается своим тредом на уровне v8 через channels и будет в дальнейшем из коробки интегрирована в open-telemetry.
Попытаюсь ответить, если я правильно понял:1. Глобальный Error Handler - это обработчик ошибок, которые мы отлавливаем на уровне триггера (HTTP запрос, Cron, сообщение из MQ, буфер из сокета и т.д.). На этом уровне я могу залогировать / затрейсить ошибку, сериализовать ее и отправить в ответ.2. Любой узел, получивший в ответ ошибку (клиентское приложение / другой сервис) десериализует ее и может понять что это за ошибка засчет общего словаря кодов ошибок.3. Далее этот узел принимает решение что делать с ошибкой: если она для него логична, то продолжать исполнение, если нет, то выкинуть ее же ИЛИ модифицированную (например, когда мы не хотим показывать эту ошибку пользователям)Тем самым, на уровне каждого отдельного узла ты видишь какие у тебя были ошибки и во что они преобразовывались на протяжении сервисов+ именно трейсами я покрываю только IO и CPU intensive кодПример: 5-ый по глубине сервис выкинул ошибку отсутствия транзакции, 1-ый, принявший запрос от клиента получит ее, по словарю поймет что это за ошибка и по второму словарю доступных для клиента ошибок поймет что ее надо преобразовать в 403 / 404, в зависимости от политики безопасности. В трейсах будут видны все IO запросы (БД, MQ, HTTP, etc.) с сериализованными в json ошибками (ну, а точнее, с ссылкой на лог, где можно будет посмотреть на эту ошибку)
Попытаюсь ответить, если я правильно понял:1. Глобальный Error Handler - это обработчик ошибок, которые мы отлавливаем на уровне триггера (HTTP запрос, Cron, сообщение из MQ, буфер из сокета и т.д.). На этом уровне я могу залогировать / затрейсить ошибку, сериализовать ее и отправить в ответ.2. Любой узел, получивший в ответ ошибку (клиентское приложение / другой сервис) десериализует ее и может понять что это за ошибка засчет общего словаря кодов ошибок.3. Далее этот узел принимает решение что делать с ошибкой: если она для него логична, то продолжать исполнение, если нет, то выкинуть ее же ИЛИ модифицированную (например, когда мы не хотим показывать эту ошибку пользователям)Тем самым, на уровне каждого отдельного узла ты видишь какие у тебя были ошибки и во что они преобразовывались на протяжении сервисов+ именно трейсами я покрываю только IO и CPU intensive кодПример: 5-ый по глубине сервис выкинул ошибку отсутствия транзакции, 1-ый, принявший запрос от клиента получит ее, по словарю поймет что это за ошибка и по второму словарю доступных для клиента ошибок поймет что ее надо преобразовать в 403 / 404, в зависимости от политики безопасности. В трейсах будут видны все IO запросы (БД, MQ, HTTP, etc.) с сериализованными в json ошибками (ну, а точнее, с ссылкой на лог, где можно будет посмотреть на эту ошибку)
Про хендлер понял, а вот как ты планируешь трассировать оригинал ошибки в 5 сервисе до преобразования в 1, не очень понял.
А под \"трассировкой\" ты подразумеваешь трейсы например из Джаегера или как понятие отслеживания?
А в чём разница 🤔 трассировка это подход, сейчас мы обсуждаем как раз прохождения trace в этом подходе в твоём приложении, а ещё точнее stack trace. Ты можешь создать множество span и объединить их по trace id с учётом parent id и depth level.На каждом span мы сохраняем информацию, вот в твоём случае наша информация в span только на 1 сервисе, а как собрать ошибку в span на 5 сервисе и прикинуть её ещё в 4 spans до 1.
А в чём разница 🤔 трассировка это подход, сейчас мы обсуждаем как раз прохождения trace в этом подходе в твоём приложении, а ещё точнее stack trace. Ты можешь создать множество span и объединить их по trace id с учётом parent id и depth level.На каждом span мы сохраняем информацию, вот в твоём случае наша информация в span только на 1 сервисе, а как собрать ошибку в span на 5 сервисе и прикинуть её ещё в 4 spans до 1.
Черт, короче, текстом сложно тут до конца понять в чем у нас рассинхрон)Обычно я делаю так: при отправке сообщения в любой транспорт (http, mq, etc.) проставить ему уникальный id + trace id, так, когда мы дошли до 5-го сервиса, у нас у каждого запроса 5 уникальных id, но всего 1 trace id, когда я из 5-го сервиса возвращаю ошибку через тот же транспорт, она сериализуется и я в span-ах могу увидеть, что запись в сокет содержала в себе ошибку и так от 5-го до 1-го сервисаЭто позволяет мне:1. Видеть набор span-ов, со всеми взаимодействиями сервисов + I/O, начиная с какого-то триггера2. Если у меня где-то есть ошибка, я обычно смотрю на графики графаны, собирающиеся из логов, иду смотреть лог, беру из него trace id и иду в систему трассировки смотреть span-ы по этому trace idИ еще раз на всякий случай, возвращаясь к первоначальной теме: если у тебя ошибка бизнес логики (этому пользователю что-то нельзя делать, не нашлась какая-то сущность, нам недостает каких-то данных, валидация и т.д.), тогда мы возвращаем ошибку и отдаем ответ (потому что это не ошибка вовсе), а вот если у нас ошибка, которую мы считаем достойной положить все приложение (отвалилась БД), тогда мы ее выбрасываем
Черт, короче, текстом сложно тут до конца понять в чем у нас рассинхрон)Обычно я делаю так: при отправке сообщения в любой транспорт (http, mq, etc.) проставить ему уникальный id + trace id, так, когда мы дошли до 5-го сервиса, у нас у каждого запроса 5 уникальных id, но всего 1 trace id, когда я из 5-го сервиса возвращаю ошибку через тот же транспорт, она сериализуется и я в span-ах могу увидеть, что запись в сокет содержала в себе ошибку и так от 5-го до 1-го сервисаЭто позволяет мне:1. Видеть набор span-ов, со всеми взаимодействиями сервисов + I/O, начиная с какого-то триггера2. Если у меня где-то есть ошибка, я обычно смотрю на графики графаны, собирающиеся из логов, иду смотреть лог, беру из него trace id и иду в систему трассировки смотреть span-ы по этому trace idИ еще раз на всякий случай, возвращаясь к первоначальной теме: если у тебя ошибка бизнес логики (этому пользователю что-то нельзя делать, не нашлась какая-то сущность, нам недостает каких-то данных, валидация и т.д.), тогда мы возвращаем ошибку и отдаем ответ (потому что это не ошибка вовсе), а вот если у нас ошибка, которую мы считаем достойной положить все приложение (отвалилась БД), тогда мы ее выбрасываем
Да, речь как раз за эту пользовательскую ошибку. Для отладки у нас должен быть в трейсе оригинал ошибки и место её появление. Я вот вижу, что ты пишешь в span где-то у тебя оно создаётся. Но потом думаю, как это будет выглядеть в том же Jaeger, у нас есть родительский вызов и несколько дочерних span в котором 2 ошибки в разных местах. Обычно такого не бывает, всегда одна ошибка и дальше все span передают этот error, так как у нас в том же Jaeger есть возможность написать фильтр error=true и посмотреть все ошибки или уточнить по конкретному сервису.По поводу графаны и поиска логов, то стандарт протокола open-telemetry как раз и был создан, чтобы объединить 3 сущности: metric, log и trace связывая из между собой одним uuid для обогащения данных в UI по каждому инценденту.
Да, речь как раз за эту пользовательскую ошибку. Для отладки у нас должен быть в трейсе оригинал ошибки и место её появление. Я вот вижу, что ты пишешь в span где-то у тебя оно создаётся. Но потом думаю, как это будет выглядеть в том же Jaeger, у нас есть родительский вызов и несколько дочерних span в котором 2 ошибки в разных местах. Обычно такого не бывает, всегда одна ошибка и дальше все span передают этот error, так как у нас в том же Jaeger есть возможность написать фильтр error=true и посмотреть все ошибки или уточнить по конкретному сервису.По поводу графаны и поиска логов, то стандарт протокола open-telemetry как раз и был создан, чтобы объединить 3 сущности: metric, log и trace связывая из между собой одним uuid для обогащения данных в UI по каждому инценденту.
Все, вот теперь понятно)Знаешь, наверное так: на самом деле, реальные \"ошибки\", то есть те, которым мы разрешаем положить наше приложение, надо выкидывать, а все остальные \"ошибки\", на самом деле надо рассматривать скорее как \"неуспех\" / \"неудача\" и мы их возвращаем. То есть, мы пошли по бренчу \"неуспешного\" завершения операции.И вот когда мы говорим про \"неуспех\", то мне кажется, что неуспехов может быть на протяжении одной цепочки действий (трейса) очень даже много, потому что 4-ый сервис, может абсолютно спокойно ожидать неуспех от 5-го, а дальше просто пойти по другой цепочки логикиПоэтому я не соглашусь с фразой: \"Обычно такого не бывает, всегда одна ошибка\" – как раз таки на мой взгляд каждый сервис получив ошибку / неуспех от другого решает что он будет с этим делать: создавать новую ошибку / неуспех (а это отдельная сущность) или нормально обрабатывать эту ситуациюИ да, из-за этого в моих спанах будет куча повторяющихся ошибок, потому что в 70-80% процентов случаев мы просто выбрасываем ошибки все выше и выше до точки входаОпять же, возможно, я так делаю из-за привычки и незнания того, как оно могло бы быть реально лучше и удобнее
Все, вот теперь понятно)Знаешь, наверное так: на самом деле, реальные \"ошибки\", то есть те, которым мы разрешаем положить наше приложение, надо выкидывать, а все остальные \"ошибки\", на самом деле надо рассматривать скорее как \"неуспех\" / \"неудача\" и мы их возвращаем. То есть, мы пошли по бренчу \"неуспешного\" завершения операции.И вот когда мы говорим про \"неуспех\", то мне кажется, что неуспехов может быть на протяжении одной цепочки действий (трейса) очень даже много, потому что 4-ый сервис, может абсолютно спокойно ожидать неуспех от 5-го, а дальше просто пойти по другой цепочки логикиПоэтому я не соглашусь с фразой: \"Обычно такого не бывает, всегда одна ошибка\" – как раз таки на мой взгляд каждый сервис получив ошибку / неуспех от другого решает что он будет с этим делать: создавать новую ошибку / неуспех (а это отдельная сущность) или нормально обрабатывать эту ситуациюИ да, из-за этого в моих спанах будет куча повторяющихся ошибок, потому что в 70-80% процентов случаев мы просто выбрасываем ошибки все выше и выше до точки входаОпять же, возможно, я так делаю из-за привычки и незнания того, как оно могло бы быть реально лучше и удобнее
Разделение ошибок действительно правильная мысль. Обычно это делается в местах с пользовательскими gateway.Там мы определяем допустимые для возврата пользователю типы ошибок. Остальное превращаем в общую ошибку.По поводу разных ошибок в разных span, такое может быть, но это должен быть кейс, когда у нас асинхронно выполнялись несколько действий и каждый из них в своей работе получил собственные ошибки, тогда мы эти ошибки и пишем в их span. А если мы из обоих прокидываем назад по твоей логике, тогда нам нужна на уровне выше ещё одна логика понимания этих ошибок и что отправить дальше. Но, это создаёт более тесную связанность сервисов, что уже противоречит практике малой связанности и разделения ответственности. Так, у нас каждый сервис с которым мы взаимодействием должен знать об этих ошибках сервиса и реализовывать дополнительную обязательную логику, вместо обычного проброса эксепшена выше по стэку.
Разделение ошибок действительно правильная мысль. Обычно это делается в местах с пользовательскими gateway.Там мы определяем допустимые для возврата пользователю типы ошибок. Остальное превращаем в общую ошибку.По поводу разных ошибок в разных span, такое может быть, но это должен быть кейс, когда у нас асинхронно выполнялись несколько действий и каждый из них в своей работе получил собственные ошибки, тогда мы эти ошибки и пишем в их span. А если мы из обоих прокидываем назад по твоей логике, тогда нам нужна на уровне выше ещё одна логика понимания этих ошибок и что отправить дальше. Но, это создаёт более тесную связанность сервисов, что уже противоречит практике малой связанности и разделения ответственности. Так, у нас каждый сервис с которым мы взаимодействием должен знать об этих ошибках сервиса и реализовывать дополнительную обязательную логику, вместо обычного проброса эксепшена выше по стэку.
В целом согласен, но по-поводу связанности сервисов: мы сейчас обсуждаем именно кейс \"Request-Response\", где наш сервис отправляет куда-то запрос и ждет ответа.Представим, что это сторонний API (например, Stripe) и мы используем для него SDK. Разве мы не должны знать какие ошибки он может вернуть? Скажу больше, скорее всего, в самом SDK должен быть отдельный файл с перечислением ошибок, которые мы можем отловить и что-то сделать.Для нашего сервиса неважно (в контексте данного разговора) стучиться он во внешний или в соседний сервис, для него все это API, у API есть описание, в рамках описания должны быть указаны ошибки.Поэтому как бы то ни было, если мы можем из сервиса вернуть ошибку, то все, кто с ним общаются должны знать о ней (будь то документация, SDK, готовый словарь ошибок в JSON и так далее)Соответственно, я бы назвал это \"натуральным\" связанностью, потому что она логически выходит из паттерна Request-Response, который уже сам по себе предпологает знание о точке, куда ты делаешь запрос (и например Event, как противоположность Request-Response)
Разделение ошибок действительно правильная мысль. Обычно это делается в местах с пользовательскими gateway.Там мы определяем допустимые для возврата пользователю типы ошибок. Остальное превращаем в общую ошибку.По поводу разных ошибок в разных span, такое может быть, но это должен быть кейс, когда у нас асинхронно выполнялись несколько действий и каждый из них в своей работе получил собственные ошибки, тогда мы эти ошибки и пишем в их span. А если мы из обоих прокидываем назад по твоей логике, тогда нам нужна на уровне выше ещё одна логика понимания этих ошибок и что отправить дальше. Но, это создаёт более тесную связанность сервисов, что уже противоречит практике малой связанности и разделения ответственности. Так, у нас каждый сервис с которым мы взаимодействием должен знать об этих ошибках сервиса и реализовывать дополнительную обязательную логику, вместо обычного проброса эксепшена выше по стэку.
По-поводу дальнейшего проброса: так это и есть то, как например, у себя это реалозвал Go: ты будешь писать foo, err := bar() /n if (err != nil) return nul, err и это как раз таки про то, чтобы не "выбрасывать дальше по стэку", а "явно обработать (даже если это просто проверка на nil) и сделать то, что тебе нужно (даже если это просто return)"В Zig это реализовано приятнее, где можно сделать try bar() и он сам в случае ошибки просто сделает return из функции, где был вызван bar
В целом согласен, но по-поводу связанности сервисов: мы сейчас обсуждаем именно кейс \"Request-Response\", где наш сервис отправляет куда-то запрос и ждет ответа.Представим, что это сторонний API (например, Stripe) и мы используем для него SDK. Разве мы не должны знать какие ошибки он может вернуть? Скажу больше, скорее всего, в самом SDK должен быть отдельный файл с перечислением ошибок, которые мы можем отловить и что-то сделать.Для нашего сервиса неважно (в контексте данного разговора) стучиться он во внешний или в соседний сервис, для него все это API, у API есть описание, в рамках описания должны быть указаны ошибки.Поэтому как бы то ни было, если мы можем из сервиса вернуть ошибку, то все, кто с ним общаются должны знать о ней (будь то документация, SDK, готовый словарь ошибок в JSON и так далее)Соответственно, я бы назвал это \"натуральным\" связанностью, потому что она логически выходит из паттерна Request-Response, который уже сам по себе предпологает знание о точке, куда ты делаешь запрос (и например Event, как противоположность Request-Response)
Взаимодействие сервиса со сторонним, это изолированная ответственность. Только этот сервис и будет знать.Но, я понял твой подход, тут каждому свои паттерны.
По-поводу дальнейшего проброса: так это и есть то, как например, у себя это реалозвал Go: ты будешь писать foo, err := bar() /n if (err != nil) return nul, err и это как раз таки про то, чтобы не "выбрасывать дальше по стэку", а "явно обработать (даже если это просто проверка на nil) и сделать то, что тебе нужно (даже если это просто return)"В Zig это реализовано приятнее, где можно сделать try bar() и он сам в случае ошибки просто сделает return из функции, где был вызван bar
Callback так и реализован, в async/await придумали удобный для него подход через throw/catch/finally
Callback так и реализован, в async/await придумали удобный для него подход через throw/catch/finally
Кстати, про callback – да, это самое смешное, что до async / await для нас была норма иметь первым аргументом ошибку, что было ближе к тому, о чем я говорю в посте))throw/catch/finally можно не писать (забыть или намеренно), а значит это невное поведение, как раз почему я предлагаю возвращать ошибки, где ты не можем не обработать ее, ты прям должен это сделатьЯ надеюсь, что появится типа tryasync, который будет возвращать ошибку, если был throw и тогда все будут довольны (только, хрен его знает как тут красивый finally сделать)