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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

06f49d9f491f99dcfbbcd26665fb9987.png

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

789c38370d32c3cfa2f3c6180d560f91.png

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

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

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

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

Машина

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

В блоге YADRO есть статья о том, как написать Ansible-модули для управления разными системами хранения данных через Swordfish — читайте по ссылке.

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

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

d61a225fe44ed3aab4305241518ff568.png

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

45e613c1a9620753e6b9ea947e1da699.png

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

"BadDiskState": {
  ...
  "Message": "Request cannot be performed, state for disk '%1' is '%2'",
  "NumberOfArgs": 2,
  "ParamTypes":  ["string", "string"]
  ...
}

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

248a2c609b0c302261b65a3224dd87e0.png

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

  • Он должен уметь обрабатывать ошибку и определять ее тип в коде.

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

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

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

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

func GetRedfishError(c echo.Context, err error) error {
   	if strings.Contains(strings.ToLower(err.Error()), "not found") ||
      	strings.Contains(err.Error(), "Unable to find") {
      	return NotFound(c)
   	}
   	if strings.Contains(err.Error(), "401") ||
      	strings.Contains(err.Error(), "Failed to do operation on other system") {
      	return CouldNotEstablishConnection(c)
   	}
   	if strings.Contains(err.Error(), "timeout") {
      	return OperationTimeout(c)
   	}
   	if strings.Contains(err.Error(), "already exist") {
      	return PropertyValueResourceConflict(c, name, value, c.Path())
   	}
    ...
    return InternalServerError(c)
 }

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

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

if strings.Contains(err.Error(), "already exist") {
 	...
  	return PropertyValueResourceConflict(...)
 }

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

if strings.Contains(err.Error(), "not found") || 
    strings.Contains(err.Error(), "Unable to find") {
 	...
  	return NotFound(...)
}

И так далее.

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

func GetRedfishError(c echo.Context, err error) error {
    if response.IsInternalServerError(err) {
   	...
   	if strings.Contains(err.Error(), "401") ||
      	strings.Contains(err.Error(), "Failed to do operation on other system") {
      	return CouldNotEstablishConnection(c)
   	}
    ...
    }
    ...
    return InternalServerError(c)
 }

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

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

  1. Запретим отправку пакетов с одной системы хранения данных на другую.

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

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

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

func GetRemoteSystemInfo(link Link) (Info, error) {
    info, err := getHttp("...")
    if err != nil {
     	return Info{}, fmt.Errorf("Can't get response from remote cluster: %s", err.Error())
    }
    ...
    return info, nil
 }

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

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

func GetRedfishError(c echo.Context, err error) error {
    ...
    if strings.Contains(err.Error(), "401") ||
      strings.Contains(err.Error(), "Failed to do operation on other system") ||
      strings.Contains(err.Error(), "Can't get response from remote cluster") {
        return CouldNotEstablishConnection(c)
    }
    ...
 }

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

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

t.Run("Should not create volume on non-existent disk", func(t *testing.T) {
    response := httpPut(...)
    assert.Equal(response.HttpStatus, http.StatusNotFound)
    assert.Contains(t, response.Body.String(), "disk")
    assert.Contains(t, response.Body.String(), "not found")
 })

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

bb1fdbc92f6c9dded86240c6abcbb36e.png

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

  • Инженеру нужен удобный интерфейс для создания обработки.

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

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

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

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

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

var (
    BadObjectStateErr = errors.New("bad object state")
    ObjectNotFoundErr = errors.New("object not found")
 )

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

  • простой вариант без хардкода,

  • можно определить ошибку по тексту.

Минус:

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

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

type BadObjectState struct {
    ObjectId string
    State  string
 }

 func (e BadObjectState) Error() string {
    return fmt.Sprintf(...)
 }

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

func checkErr(err error) {
    ...
    var bos BadObjectState
    if errors.As(err,&bos) {
        log(bos)
        changeState(bos.ObjectId, bos.State)
    }
    ...
 }

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

{
  "objectType": "volume",
  "objectId": "12345",
  "state": "error"
}

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

func doSmth() error {
   ...					
   body := postRequest(otherService)
   var err ???
   _ := json.Unmarshal(body, &err)
   return err
}

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

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

  • удобная работа со структурой и аргументами в коде.

Минус:

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

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

const (
    CodeObjectBadState = "ObjectBadState"
    CodeObjectNotFound = "ObjectNotFound"
 )
type MegaError struct {
    Code    string
    Message string
 }

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

type MegaError struct {
   	Code      string
   	Message   string
   	Arguments map[string]interface{}
 }

После сериализации это превратится в такой JSON. Наша мапа пустых интерфейсов превращается в объект JSON, и, когда он уже прилетел в другой сервис по сети, мы его десериализуем в ту же структуру MegaError. Все работает. 

{
  "code": "ObjectBadState",
  "message": "disk with ID 12345 is in bad state: error",
  "arguments": {
	"objectType": "disk",
	"objectId": "12345",
	"state": "error"
  }
}

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

func checkErr(err MegaError) {
   if err.Code == "ObjectBadState" {
  	 // get interface{}
     objectId, ok := err.Arguments["object_id"]
     // cast it
  	 objectIdStr, ok := objectId.(string)
     // use it!
     changeState(objectIdStr, objectId)
   }
   ...
 }

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

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

objectId, ok := err.Arguments["object_id"]
if !ok {
   ...
}
objectIdStr, ok := objectId.(string)
if !ok {
   ...
}
otherObjectId, ok := err.Argumets["other_object_id"]
if !ok {
   ...
}
otherObjectIdStr, ok := otherObjectId.(string)
if !ok {
   ...
}

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

otherIds, ok := err.Arguments["other_ids"]
if !ok {
   ...
}
otherIdsSlice, ok := otherIds.([]interface{})
if !ok {
   ...
}
var otherIdsStrs []string
for _, id := range otherIdsSlice {
   str, ok := id.(string)
   if !ok {
       ...
   }
   otherIdsStrs = append(otherIdsStrs, str)
}

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

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

defer func() {
   if r := recover(); r != nil {		
     result = InternalServerError()					
   }
}()						
...						
objectId = getStringOrPanic(err.Arguments, "object_id")
otherIds = getSliceOrPanic(err.Arguments, "other_ids")
...

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

  • однозначная идентификация ошибки по коду,

  • сериализация и десериализация хорошо работают,

  • есть аргументы.

Минусы:  

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

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

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

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

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

package terror // tatlin-errors
type Base struct {
    Сode	string
    Message string
}

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

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

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

package some_service
func NewObjectBadState(objectType, objectId, state string) ObjectBadState {
    msg := fmt.Sprintf("%s in bad state: %s", ...)

    ...
    return ObjectBadState{
     	Base: terror.Base{
        	Code:	"ObjectBadState",
        	Message: msg,
     	},
     	ObjectType: objectType,
    	ObjectId: objectId,
    	State: state,
    }
 }

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

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

func checkErr(err error) {
   ...
   if bos, ok := err.(BadObjectState); ok {
   	  log(bos)
   	  changeState(bos.ObjectId, bos.State)
   }
   ...
 }

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

{
  "code": "ObjectBadState",
  "message": "volume with ID 12345 is in bad state: error",
  "objectType": "volume",
  "objectId": "12345",
  "state": "error"
}

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

package terror
func Unmarshal(
   body []byte,
   errInstances map[string]interface{}
) error {
    ...
}

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

package some_service
var errInstances = map[string]interface{}{
    "ObjectNotFound": ObjectNotFound{},
    "ObjectBadState": ObjectBadState{},
 }
func Unmarshal(body []byte) error {
   return terror.Unmarshal(body, errInstances)
}

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

func Unmarshal(...) error {
  var base Base
  json.Unmarshal(body, &base)

  var instance interface{}
  if _, ok := errInstances[base.Code]; ok {
      instance = errInstances[base.Code]
  }
  ...					
  target = reflect.New(reflect.TypeOf(instance))
  json.Unmarshal(body, target.Interface())					
  err := target.Elem().Interface().(error)
  return err	
}

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

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

Было:

func GetRedfishError(c echo.Context, err error) error {
   	if strings.Contains(strings.ToLower(err.Error()), "not found") ||
        strings.Contains(err.Error(), "Unable to find") {
      	return NotFound(c)
   	}
   	if strings.Contains(err.Error(), "401") ||
        strings.Contains(err.Error(), "Failed to do operation on other system") {
      	return CouldNotEstablishConnection(c)
   	}
   	if strings.Contains(err.Error(), "timeout") {
      	return OperationTimeout(c)
    }
   	if strings.Contains(err.Error(), "already exist") {
      	return PropertyValueResourceConflict(c, name, value, c.Path())
   	}
    ...
    return InternalServerError(c)
 }

Стало:

package proxy_api_service
func GetRedfishError(c echo.Context, err error) error {
    switch err := err.(type) {
   	case ObjectNotFound:
      	return NewRedfishNotFound(err.ObjectType, ...)
   	case ObjectBadState:
      	return NewRedfishBadState(err.State, ...)
    }
}

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

Было

Было

Стало

Стало

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

  • Можно подробно описать ошибку и работать с ней как со структурой в любом Go-сервисе.

  • Пользователь видит переведенные ошибки.

  • Текст ошибок согласован и не содержит внутренней информации из библиотек.

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

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

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

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

Согласовать изменения с руководителем и командой

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

Проанализировать ошибки

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

Исправить в коде места создания ошибок

Заведите для каждой ошибки код и функцию для создания. На месте каждого sprintf или errorf будет функция для создания структуры. Итоговый список будет большой, как на картинке ниже. 

a9db9288a3fa22ae64ee3ded1d96f44d.png

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

4d8317d72f424c2aaf66dfeba82cedac.png

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

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

© Habrahabr.ru