программы
139
2
4 июня 2024
программы

Ужасно подробные ошибки в API: пишем инструмент для работы с ними на Go

Изображение создано
с помощью нейросети
Изображение создано с помощью нейросети
139
2
4 июня 2024

Система хранения данных TATLIN.UNIFIED — сложное устройство, и если в процессе работы произошла ошибка, оно должно своевременно и понятно сообщать пользователю об этом. В большинстве веб-сервисов используют баннер с надписью «Что-то пошло не так», но такой способ уведомления не подходит для СХД.

Чтобы решить проблему, когда переданных сообщений и HTTP-кодов уже не хватает, команда YADRO разработала собственный инструмент для обработки ошибок Terror (TATLIN + error). В результате работа с кодом стала проще, инженеры получили красивый API, а пользователи — понятное описание ошибки и локализацию текста на разные языки. Из этой статьи узнаете, как создавали Terror, чтобы вы смогли повторить решение.

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

Когда нужно сообщать пользователю об ошибке

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

Чтобы понять, когда пользователю необходимо подробное объяснение ошибки, нужно представить, как он пользуется системой. Например, вы разработали сервис для просмотра видео. Пользователь кликнул на один из роликов, но несколько минут назад ряд серверов, обрабатывающих этот регион, по какой-то причине «упали» и стали недоступны.

Запрос пользователя не обработать, и вы показываете ему баннер «Что-то пошло не так». Просмотр ролика — задача некритичная, ситуация неприятная, но пользователь просто подождет, пока вы исправите проблему. В этом случае пользователю достаточно информации с баннера, чтобы решить, что делать дальше: дождаться, пока сервис заработает, или заняться другими делами.

В системах хранения данных, в частности — в TATLIN.UNIFIED, роль пользователя более активная. Можно сказать, он сам себе инженер, который постоянно использует систему: настраивает, мониторит работу и т. д.

Интерфейс TATLIN.UNIFIED
Пользовательский интерфейс нашей СХД

Пользователю СХД важно знать, почему его запрос не может обработаться и как это исправить. Если что-то сломалось, нужно понять, как это быстро починить. Например, один из жестких дисков в серверной вышел из строя. Теперь, если пользователь захочет создать новый том, он обратится по HTTP к сервису, который обрабатывает запросы на создание новых томов — например, VolumeManager. Система должна понять, что диск работает некорректно, и сообщить об этом пользователю.

Ситуации из примеров с видеосервисом и СХД разные, но задача одна: пользователю нужно сообщить об ошибке так, чтобы он определился с последующими действиями.

Объявляем о том, что диск сломался — как это выглядит для пользователя, машины и программиста

Пользователь

В HTTP-протоколе найдутся коды к любой ошибке. Допустим, мы вернули ошибку под номером 409 — статус-конфликт. В данном случае код может обозначать сразу несколько ошибок: как ошибку со стороны пользователя (указано уже занятое имя или слишком большой размер), так и ошибку системы. Пользователь не может понять, что делать: исправить запрос, или срочно менять жесткий диск?

Как программа сообщает пользователю об ошибке

Для удобства к коду ошибки можно добавить сообщение. Становится уже понятнее.

Стандартное сообщение об ошибке

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

Сообщение об ошибке из библиотеки Go

Давайте зафиксируем требования к описанию ошибки со стороны пользователя, чтобы учесть их в решении:

  • Описание ошибки должно быть интуитивно понятно, в сообщении не должно быть внутренней информации, например, из библиотеки.
  • Если среди множества дисков сломался один, пользователю нужно знать, какой именно. В сообщении об ошибке должны быть детали — например, «диск HDD6 в состоянии ошибки».
  • Если пользователь переключает локаль UI, сообщение об ошибке должно выводиться на русском языке.

Машина

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

Для того, чтобы поддержать стандарт, мы создали API-прокси, задача которого — конвертировать запросы из формата Swordfish в формат нашего внутреннего API.

Представим: пользователь обращается к нашему API-прокси для создания тома данных с помощью стандартизированного API, затем запрос переводится на внутренний язык и направляется к VolumeManager.

Схема общения пользователя через API-прокси

Мы также можем продолжать обращаться к сервису напрямую, если это удобно.

Схема общения пользователя с сервисом напрямую

Swordfish документирует разные сущности, в том числе ошибки. Как эта ошибка выглядит? Это структура, в которой можно выделить основные параметры: код ошибки, сообщение, аргументы.

Напомню, что диск все еще находится в состоянии ошибки. На запрос от пользователя наш API-прокси получает такое сообщение:

Сообщение от API-прокси

Это сообщение нужно переложить в формат Swordfish. API-прокси нужно понять и распарсить строку, чтобы потом отдать пользователю уже в формате Swordfish. Из этого вытекают новые условия, которые нужно соблюсти в инструменте обработки ошибок:

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

Программа и программист

Как научить наш API-прокси понимать ошибки, чтобы перекладывать их в другой формат? Самым простым вариантом кажется поиск подстроки в строке. Например, если в ошибке «Disk HDD6 not found» найдено ключевое слово «not found», то программа должна понять, что это тип ошибки DiskNotFound.

Но со временем такой подход превращает код во что-то страшное:

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

Если в нашем сообщении от внутреннего сервиса содержится «already exist», мы возвращаем одну структуру:

Если нам прислали что-то, где содержится «not found» или «Unable to find», мы возвращаем структуру NotFound:

И так далее.

Проблема подхода в том, что решение будет сложно расширять и поддерживать. Его придется постоянно чинить, потому что код сломается после первой новой формулировки. Например:

Видим в строке HTTP-код 401 или формулировку Failed to do operation on other system и понимаем, что, скорее всего, сервис не смог установить соединение. Возвращаем структуру CouldNotEstablishConnection.

Для проверки напишем тест с такими условиями:

  1. Запретим отправку пакетов с одной системы хранения данных на другую.
  2. Пошлем запрос на подключение к другой СХД для репликации.

Ожидание: через API вернулось красивое сообщение о том, что система не может установить соединение.

Реальность: тест свалился с internal server error.

Чтобы понять причины, посмотрим в CI, выгрузим логи, распакуем архив — и через 15−20 минут найдем такое место в коде:

Здесь, если у нас не получилось подключиться к другой системе, то мы вернем ошибку Can’t get response from remote cluster. Ситуация та же, а формулировка другая — отсюда и internal server error.

Давайте починим и добавим еще одну формулировку.

В какой-то момент придет понимание, что каждый раз добавлять еще один поиск по подстроке в строке — не вариант, код снова сломается. Даже в стандартной библиотеке для такого типа ошибки есть разные формулировки, например, i/o timeout, connection refused. И так будет ломаться каждый тип ошибки.

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

В итоге весь код превращается в поиск по подстроке в строке.

Чтобы этого избежать, в нашем будущем решении описания ошибок должны выполняться еще и такие условия:

  • Инженеру нужен удобный интерфейс для создания обработки.
  • Инструмент не должен ломаться «на каждый чих».

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

Три решения, которые могли подойти, но не подошли

Константные ошибки

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

Плюсы решения:

  • простой вариант без хардкода,
  • можно определить ошибку по тексту.

Минус:

  • недостаточно деталей, так как нет поддержки аргументов.

Структуры для ошибок

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

Чтобы проверить, что случилось, нам нужно сделать typecast: посмотреть, какой тип лежит в этом интерфейсе, и достать его. Для работы со структурами также подходит стандартная библиотека Go: можно использовать методы errors Is, As, Wrapped и так далее.

Тем не менее, сервисы у нас все еще общаются друг с другом, поэтому ошибку в какой-то момент придется сериализовать. Чтобы это сделать, мы выполняем marshal в JSON, тело пишем в HTTP ответ. Как это выглядит:

Такой JSON летит по сети, и мы принимаем body. Нам нужно распарсить его в другом сервисе. Чтобы распарсить данные, мы должны передать, в какую структуру десериализовать JSON. И что здесь указывать? Структура может быть одна, а может быть пять или сто структур. Выполнять попытку unmarshal для каждой из возможных структур не хочется. Появляется проблема: с использованием множества структур мы не можем выполнить передачу ошибки через JSON.

Плюсы решения:

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

Минус:

  • передача структуры между сервисами затруднительна.

Один формат для ошибок

Можно сделать одну структуру. Введем реестр кодов ошибок, по которым будем определять, что произошло. Кладем в нашу структуру MegaError. Там есть код и сообщения, которые планируем показывать.

Сразу вспоминаем то, что мы хотели детали, — нужно поддержать аргументы. И тут встает вопрос: как бы нам добавить в структуру MegaError «то — не знаю что», «столько — не знаю сколько», чтобы потом все эти аргументы неизвестного типа достать. Мы можем ввести мапу, у которой ключ — это строка (название аргумента), а внутри лежит пустой интерфейс, с которым мы уже делаем, что нам нужно.

Теперь, чтобы в ошибке ObjectBadState понять, с каким объектом что-то не то, мы достаем из этой мапы ID объекта. Пустой интерфейс приводим к типу string и используем, как нам нужно.

Отмечу, что ok тоже нужно проверять, потому что в arguments может не быть такого аргумента. И если привести интерфейс к string без проверки ok, в случае неудачи возникнет паника. Проблема в том, что чем больше объектов, тем больше кода.

Два аргумента:

Если аргумент — слайс строк:

Сначала нужно достать слайс пустых интерфейсов, создать новый слайс строк, каждый из лежащих внутри интерфейсов привести к типу string и положить в новый слайс.

Что будет происходить, если у вас аргумент — это структура, я не буду показывать. Просто поверьте: это плохо, нам не понравилось. Придумали, как это обойти. Чтобы не писать кучу if-ok, мы не будем проверять, вызовет ли панику неверный typecast. Если паника случится, то мы выполним recover в конце функции, которая занимается обработкой ошибки, и вернем InternalServerError. Логика следующая: если что-то не так, где-то лежит не тот тип или его нет, это нарушение протокола, значит, internal server error.

Решение получилось рабочее. Плюсы:

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

Минусы:

  • аргументы надо доставать по ключам,
  • аргументы надо приводить от пустого интерфейса к конкретному типу.

Наше решение — Terror

Мы внедрили решение, описанное выше, в один из сервисов. Когда позже пришла задача реализовать то же самое для множества других сервисов, мы решили подумать, как можно улучшить API для работы с ошибками. Нам хотелось работать с разными ошибками как с разными структурами, но сериализация все портила. Мы искали решение, которое поможет обуздать эту сериализацию, и нашли его.

В итоге сделали свой package и назвали его Terror — сокращенно от tatlin-errors.

В библиотеке выделена структура Base. В каждой структуре ошибки должна быть база — код и месседж. А дальше в своем сервисе вы импортируете пакет terror и заводите под каждый нужный вам тип ошибки свою структуру. В нее встраиваете базу и накидываете сверху все необходимые аргументы.

Лучше всего создавать эти структуры через функции, в которые мы будем передавать все нужные аргументы и возвращать готовую структуру.

Одна из функций может выглядеть так:

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

Чтобы определить тип ошибки, мы делаем typecast, достаем структуру из интерфейса error, и в compile time нам уже доступны все типы аргументов. Нам не нужно искать их по ключам в мапе, приводить пустой интерфейс к конкретному типу, «паниковать» — всё просто работает.

В JSON code и message — это база, всё остальное — это ваши аргументы.

Структур ошибок у нас всё ещё много, и написать обычный unmarshal не получится, так как мы не знаем, какую из структур нам передали. Мы написали вспомогательную функцию:

В package terror есть функция Unmarshal, которая этим занимается. Мы передаем ей body — массив байт с JSON нашей ошибки — и все инстансы ошибок, которые хранятся в мапе. Это можно представить как протокол, который сервис обозначает: в этих инстансах он передает, какие ошибки может выдавать.

Если в вашем сервисе есть две ошибки — ObjectNotFound и ObjectBadState, вы заводите мапу, в нее по ключу — коду ошибки, которому соответствует структура — кладете структуры и передаете ее в функцию terror unmarshal.

Функция сначала берет базу, десериализует JSON в нее и смотрит по коду в базе, есть ли такой инстанс ошибки. Если есть, мы его получаем и рефлектом создаем новую структуру такого же типа. Затем десериализуем тело в нее и возвращаем готовую ошибку, которая лежит в интерфейсе.

Теперь при обработке ошибки вместо полотна у нас получается аккуратный type switch.

Было:

Стало:

Вместо такого же полотна в юнит-тестах у нас получается сравнение со структурой.

Сравнение со структурой на Go

Результаты нашего решения:

  • Можно подробно описать ошибку и работать с ней как со структурой в любом Go-сервисе.
  • Пользователь видит переведенные ошибки.
  • Текст ошибок согласован и не содержит внутренней информации из библиотек.
  • Машина понимает, что произошло с системой, и может переложить ошибку в API-прокси, а также свободно работать с аргументами.
  • У программиста есть инструмент, который позволяет удобно работать со структурированными ошибками в разных сервисах.

Готовимся к внедрению Terror

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

  1. Согласовать изменения с руководителем и командой
    Уточните, точно ли вам нужен такой инструмент. Возможно, вам хватает вашего решения или у вас в коде тысячи хардкодных строк, на проверку которых уйдет много времени и инженерных сил, и вы не готовы начинать такой рефакторинг.
  2. Проанализировать ошибки
    Найдите все ошибки, которые могут возникнуть, формализуйте каждую: заведите код, сообщение, подумайте, какие нужны аргументы. Анализ ошибок удобно делать в виде таблицы, в которую вы заводите коды, сообщения, аргументы. На основе этих данных у вас получится готовый реестр.
  3. Исправить в коде места создания ошибок
    Заведите для каждой ошибки код и функцию для создания. На месте каждого sprintf или errorf будет функция для создания структуры. Итоговый список будет большой, как на картинке ниже.
Список функций и кодов ошибок

Самое главное — ориентируйтесь на требования к вашей системе. Если для корректной работы достаточно баннера или нескольких HTTP-кодов, не стоит ее усложнять. Если решили, что аналог Terror нужен — мужайтесь, задача сложная, но решаемая.

Если есть вопросы о работе Terror — пишите в комментариях, отвечу на все.

Наверх
2 комментария
  • Привет. Спасибо за статью!
    Захотелось обратить внимание на две вещи.

    Во-первых, похоже, в листинге функции `Unmarshal` из пакета `terror` ошибка? Может, так получилось, когда функцию упрощали для этой статьи и выкидывали из неё лишнее. Там есть следующий фрагмент:
    ```go
    var instance interface{}
    if _, ok := errInstances[base.Code]; ok {
    instance = errInstances[base.Code]
    }

    ```
    и он, кажется, в точности эквивалентен следующему
    ```go
    var instance interface{}
    instance, ok := errInstances[base.Code]

    ```

    Во-вторых, местами статью было тяжело читать из-за транслитерированных английских слов, использованных кое-где вместо аналогичных русских, например, сообщение об ошибке назвали «месседж», экземпляр назвали «инстанс» и, что меня совершенно добило, словарь (ассоциативный массив) назвали «мапа». Читать предложения с такими словами (мне) было весьма неприятно…

    • Привет, спасибо за внимательность. Да, функцию немного упрощали, на самом деле там дальше еще есть ветка if-else, которая обрабатывает вариант, когда кода ошибки нет в errInstances, как-то так:

      var instance interface{}
      if _, ok := errInstances[base.Type]; ok {
      instance = errInstances[base.Type]
      } else if _, ok := commonErrInstances[base.Type]; ok {
      instance = commonErrInstances[base.Type]
      }

      Мы сделали отдельные общие коды ошибок типа «InternalError» и «ValidationError» общими, чтобы не дублировать их для каждого сервиса. Но такое не обязательно и решили не усложнять 🙂