03 апр. 2026 г.·7 мин чтения

Обработка ошибок в Go после 50 пакетов: что использовать и где

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

Обработка ошибок в Go после 50 пакетов: что использовать и где

Почему после пятидесяти пакетов ошибки превращаются в хаос

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

Запрос на регистрацию может пройти через несколько слоёв, прежде чем сломаться:

  • HTTP-обработчик
  • код валидации
  • сервис пользователей
  • код базы данных или отправки email

Каждый слой знает своё. База данных знает, что нарушено уникальное ограничение. Сервис пользователей знает, что email уже занят. Обработчику нужно только вернуть понятный ответ API. Если каждый слой переписывает ошибку своими словами, никто уже не видит полную картину.

Хаос становится ещё сильнее, когда разные пакеты описывают одну и ту же проблему по-разному. Один пакет возвращает "не найдено". Другой — "пользователь отсутствует". Третий использует свою структуру. Люди понимают, что речь об одном и том же, а код — нет. Потом команды добавляют особые случаи повсюду, и правила со временем расползаются.

Слишком ранняя перепаковка ошибок приносит много проблем. Если репозиторий слишком рано превращает ошибку базы данных в "save failed", в логах пропадает причина, которая действительно нужна инженерам. Позже кто-то видит всплеск ответов 500, но не может понять, была ли причина в таймауте, плохих данных, дублирующей записи или сбое во внешнем сервисе.

У клиентов API обратная потребность. Им не нужна цепочка внутренних деталей. Им нужен короткий, стабильный текст и правильный статус-код. "Email уже существует" — полезно. Сырой SQL-ошибки — нет. Из-за этого у растущей команды всегда есть одно противоречие: оставить достаточно деталей для логов и отладки, но вернуть клиентам что-то простое и безопасное.

Как только Go-сервис дорастает до пятидесяти пакетов, ошибки перестают быть просто значениями, которые возвращают функции. Они становятся частью того, как команда разбирает инциденты, отслеживает повторяющиеся сбои и делает поведение API предсказуемым. Если общих правил нет, текст ошибок начинает выполнять слишком много работы — и делает он это плохо.

Что на самом деле даёт каждый стиль ошибок

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

  • Сентинельная ошибка даёт вызывающему коду одно общее значение, которое можно распознать. Если пакет экспортирует ErrNotFound, другие пакеты могут проверить именно это состояние через errors.Is. Это удобно для простых ветвлений. Но для отладки такой подход слаб: один ErrNotFound не говорит, что именно не нашли и где это произошло.
  • Обёрнутая ошибка сохраняет исходную причину и добавляет локальный контекст. Репозиторий может вернуть fmt.Errorf("load account %s: %w", id, err). Такой текст делает логи читаемыми, а errors.Is и errors.As всё ещё могут проверить внутреннюю ошибку. Для повседневной отладки команды чаще всего опираются именно на этот подход.
  • Типизированная ошибка хранит поля. В ней можно держать внутренний код, имя операции, ресурс или безопасное сообщение для клиента. Это полезно, когда HTTP-обработчику нужно превратить сбой в понятный ответ API, не угадывая по строке лога.

Баланс здесь довольно прямой. Сентинельные ошибки легко передавать и проверять, но в них почти нет деталей. Обёрнутые ошибки хорошо смотрятся в логах, но добавленный контекст остаётся всего лишь текстом, если под ним не лежит известная ошибка. Типизированные ошибки понятны и структурированы, но требуют больше кода и больше правил для команды.

Небольшой пример делает разницу понятнее. Если поиск пользователя не находит запись, слой данных может вернуть ErrUserNotFound. Слой сервиса может обернуть её вместе с ID пользователя и именем операции. Затем слой API может сопоставить это состояние с 404, а в лог записать полную цепочку для команды.

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

Где уместны сентинельные ошибки

Сентинельные ошибки лучше всего работают для небольшого набора бизнес-состояний, которые со временем не меняются. Думайте о результатах, которые API должен обрабатывать одинаково: например, ErrEmailTaken, ErrForbidden или ErrRateLimited. В обработке ошибок Go именно здесь сентинелы выглядят естественно: они отвечают на вопрос "что произошло для бизнеса", а не "что сломалось внутри стека".

Они хорошо работают и между пакетами, потому что вызывающий код может использовать errors.Is, а не проверять сырые строки. Пакет пользователей может обернуть ErrEmailTaken дополнительным контекстом, а HTTP-обработчик всё равно распознает его и вернёт нормальный ответ 409. В логах останутся дополнительные детали, а клиент получит короткое и понятное сообщение.

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

Часто хватает короткого списка:

  • аккаунт или email уже существует
  • доступ запрещён
  • ресурс не найден
  • превышен лимит запросов
  • недопустимое состояние для этого действия

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

Сентинелы плохо подходят для низкоуровневых деталей. Не создавайте общепакетные ошибки вроде ErrPostgresTimeout, ErrTLSHandshakeFailed или ErrRedisPoolEmpty только затем, чтобы другие слои могли их сравнивать. Такие детали слишком часто меняются, и они подталкивают обработчики к тому, чтобы показывать внутренние сбои в ответах API.

Для SQL-, сетевых или специфичных для поставщика проблем лучше обернуть исходную ошибку контекстом и записать её в лог. Потом уже сопоставляйте её со стабильным результатом для клиента, например 500 или 503. Если новая сентинельная ошибка не меняет то, что сделает вызывающий код дальше, она не нужна.

Где обёртки помогают читать логи

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

Каждую обёртку делайте короткой. Используйте название операции, а не абзац. "load account", "save invoice" или "parse signup form" — этого достаточно. Когда ошибка доходит до лога, эти маленькие метки складываются в цепочку, по которой видно, где именно всё пошло не так.

func (r *Repo) FindUser(ctx context.Context, id string) error {
    if err := r.db.QueryRowContext(ctx, "...").Scan(...); err != nil {
        return fmt.Errorf("find user: %w", err)
    }
    return nil
}

func (s *Service) Signup(ctx context.Context, id string) error {
    if err := s.repo.FindUser(ctx, id); err != nil {
        return fmt.Errorf("signup: %w", err)
    }
    return nil
}

Здесь важен %w. Он сохраняет исходную причину, поэтому вызывающий код всё ещё может использовать errors.Is или errors.As. Если слой базы данных оборачивает sql.ErrNoRows, слой API всё равно может это определить и вернуть 404 или 400, а не 500.

Логи становятся грязными, когда каждый пакет пишет своё сообщение. Репозиторий логирует сбой, сервис логирует его ещё раз, а обработчик делает это в третий раз. И вот один неудачный запрос выглядит как три инцидента. Лучше логировать ближе к границе системы — в HTTP-обработчике, воркере задач или consumer'е очереди, где уже есть request ID, user ID и достаточно контекста, чтобы лог был полезен.

Простой поток регистрации показывает, почему это помогает. Репозиторий возвращает find user: sql: no rows in result set. Сервис оборачивает это в signup: .... Обработчик проверяет причину, возвращает спокойное сообщение вроде "user not found" и пишет один структурированный лог с полной обёрнутой ошибкой. Клиент получает нормальный ответ. Команда получает полную цепочку.

Когда типизированные ошибки оправдывают лишний код

Получите поддержку Fractional CTO
Разберите дизайн бэкенда и рабочие практики вместе с опытным CTO.

Типизированные ошибки нужны там, где вызывающему коду требуются факты, а не просто "да" или "нет". Если вашему HTTP-слою нужно выбирать между 400, 404, 409 и 503, одной сентинельной ошибки часто уже недостаточно. Обёртка помогает отследить сбой, но не даёт вызывающему коду удобного способа решить, какой ответ отправить.

Обычно небольшой типизированной ошибке хватает нескольких полей:

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

Такой формат закрывает много реальных задач API. Например, если запрос на регистрацию пытается создать аккаунт с email, который уже существует, сервис может вернуть ошибку со статусом 409, публичным сообщением "email already registered" и причиной из базы данных. API отправит клиенту безопасное сообщение, а в логах останется низкоуровневая деталь. Никому не нужно разглядывать текст ошибки и надеяться, что он совпадёт.

Держите тип небольшим. Если постоянно добавлять ID запроса, флаги повтора, сырой SQL, заметки для отладки и половину тела запроса, ошибка перестаёт быть полезной. Она превращается в ящик для хлама. Большинству команд хватает одного общего типа ошибки приложения с методами Error() и Unwrap(), плюс нескольких вспомогательных конструкторов.

Слишком много структур ошибок создают другой хаос. NotFoundError, ConflictError, DuplicateEmailError и UserConflictError сначала кажутся аккуратными, но часто содержат одни и те же три поля под разными названиями. Это разносит логику по кодовой базе и усложняет общую обработку.

Создавайте новый тип ошибки только тогда, когда действительно меняется форма данных. Хороший пример — валидация. Ошибка валидации может требовать деталей на уровне поля: какое именно поле не прошло проверку и почему. Это отличается от обычного конфликта или таймаута, так что отдельный тип здесь оправдан. Если структура данных остаётся той же, переиспользуйте один тип и двигайтесь дальше.

Набор правил, которому может следовать команда

Команды начинают путаться, когда каждый пакет придумывает свой стиль ошибок. Выберите один путь и сделайте его скучным. В Go обработка ошибок выигрывает от скуки: так код легче читать, проще ревьюить и легче держать одинаковым в десятках пакетов.

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

Дальше оборачивайте на каждом переходе. Обёртка должна называть пакет или операцию, а не пересказывать всю историю. fmt.Errorf("userrepo.Create: %w", err) часто достаточно. Через несколько слоёв логи всё ещё читаются хорошо, потому что в них виден путь сбоя, а не одна туманная строка вроде "db error".

Слой сервиса должен быть точкой сортировки. Именно здесь вы решаете, какие сбои являются нормальными бизнес-ситуациями, а какие означают, что система сломалась. Дублирующийся email, истёкший токен или отсутствующий пользователь могут маппиться на сентинельную или типизированную ошибку, потому что обработчикам нужен стабильный способ их распознать. Таймаут базы данных обычно должен остаться внутренней обёрнутой ошибкой.

У обработчика другая задача. Он должен переводить известные сбои в статус-коды API и короткие, понятные ответы. Клиентам нужно чистое сообщение вроде "email already in use" с 409. Им не нужны сообщения драйвера, SQL-текст или детали стека.

Запишите одно правило команды для логирования и следите за ним на ревью: логировать один раз, на границе. Для большинства команд это HTTP-обработчик, воркер задач или consumer очереди. Нижние слои должны возвращать ошибки и молчать. Если логирует каждый слой, одна ошибка превращается в кучу одинакового шума.

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

Поток регистрации от формы до ответа API

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

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

Допустим, пользователь отправляет форму с email, который уже существует. Репозиторий обращается к базе, а база возвращает свою ошибку дубликата email. Эта сырая ошибка часто содержит текст драйвера, имена таблиц или индексов. Для отладки — полезно. Для ответа API — нет.

Сервис пользователей должен обернуть этот сбой с контекстом, пока ошибка поднимается вверх по стеку. Сообщение вроде create user: insert account: duplicate key value violates unique constraint показывает команде, где именно запрос сломался. Позже, читая лог, не нужно гадать, на каком шаге всё рухнуло.

Сервис также должен перевести причину в ошибку уровня приложения. Для такого частого случая ErrEmailTaken часто достаточно. Если вашему API нужна более строгая структура, используйте небольшой конфликтный тип, который всё равно распаковывается в исходную ошибку базы данных. Любой из этих вариантов даёт остальному приложению одно стабильное значение вместо специфичного для поставщика текста базы данных.

На уровне обработчика проверка остаётся простой. Используйте errors.Is(err, ErrEmailTaken) или errors.As для конфликтного типа, а затем верните HTTP 409 с коротким сообщением вроде "email already in use". Не отправляйте SQL-ошибку обратно клиенту. Она создаёт шум, меняется от драйвера к драйверу и раскрывает детали, которые клиенту не нужны.

В логах должна сохраниться вся цепочка. Запишите обёрнутую ошибку, request ID и маршрут. Если службе поддержки нужен email, логируйте его в скрытом или хешированном виде, а не всю форму регистрации. Так разработчики получат достаточно данных, чтобы исправить проблему, не раскрывая личную информацию.

Такой подход скучен — и это хорошо. База данных остаётся конкретной, сервис говорит языком бизнеса, а API остаётся спокойным и предсказуемым. В большом коде такой стиль обработки ошибок Go помогает держать логи понятными и ответы API — в здравом смысле.

Ошибки, которые ломают логи или путают клиентов

Плохая обработка ошибок в Go обычно бьёт сразу по двум группам. Разработчики теряют настоящую причину в логах, а пользователи получают сообщения, которые либо слишком общие, либо слишком подробные.

Частая ошибка — сравнивать обёрнутые ошибки через ==. Это работает только для одного и того же значения. Как только нижний пакет добавляет контекст через fmt.Errorf("create user: %w", err), == перестаёт помогать. Используйте errors.Is для сентинельных ошибок и errors.As для типизированных, иначе обработчик может пропустить известный случай и вернуть неправильный статус-код.

Ещё одна ошибка — отправлять клиенту сырой текст базы данных. Сообщение вроде pq: duplicate key value violates unique constraint users_email_key помогает backend-команде, а не человеку, который заполняет форму регистрации. Полную причину логируйте, а затем отображайте её как простой ответ вроде email already in use с 409.

Команды также засоряют логи, если записывают один и тот же сбой на каждом уровне. Если репозиторий логирует его, сервис логирует ещё раз, а HTTP-обработчик повторяет это снова, одна сломанная попытка превращается в три шумные записи. Выберите одну границу для финальной записи лога — обычно HTTP- или RPC-слой, где уже есть request ID и код ответа. Нижние слои должны возвращать ошибки с контекстом, а не печатать их.

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

Последняя плохая привычка — переписывать ошибку и терять причину. fmt.Errorf("save failed") выбрасывает важную деталь. fmt.Errorf("save user: %w", err) сохраняет цепочку.

Быстрый обзор помогает поймать почти всё это:

  • Проверяйте обёрнутые ошибки через errors.Is или errors.As
  • Проверяйте ответы клиенту на внутренний системный текст
  • Проверяйте, что финальную запись лога делает только один уровень
  • Проверяйте, что свои типы ошибок несут данные, которые вы реально используете

Если запрос на регистрацию падает из-за того, что email уже существует, API может вернуть чистый 409 и короткое сообщение, а в логе всё равно останутся вызов репозитория, контекст запроса и исходная ошибка драйвера. Именно такое разделение помогает держать клиентов спокойными, а отладку — короткой.

Что проверить перед merge

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

Небольшое изменение в ошибках может испортить логи даже тогда, когда тесты зелёные. Перед merge прочитайте один путь ошибки от места, где она возникает, до места, где API отвечает клиенту.

Используйте такой короткий список проверки:

  • Вызывающий код может распознать ошибку по одному правилу. Если обработчику нужны три проверки строк, или type switch плюс карта статусов, дизайн ошибки делает слишком много.
  • В логе видно, где именно произошёл сбой. Строка вроде "save user: insert row: context deadline exceeded" полезна. "request failed" — нет.
  • API возвращает один стабильный статус и одно стабильное публичное сообщение для одного класса сбоя. Клиенты не должны гадать, является ли "email already used" кодом 400 в понедельник и 409 в пятницу.
  • Личные детали остаются в логах, а не в ответах. SQL-текст, имена хостов, сырые ошибки провайдера и стек вызовов помогают разработчикам. Пользователей они только путают и раскрывают лишнее.
  • Новый коллега может проследить путь за одно чтение. Должно быть видно, где вы добавляете контекст, где классифицируете ошибку и где переводите её в JSON.

Если хотя бы одна проверка не проходит, остановитесь и упростите. В обработке ошибок Go скучный код обычно побеждает. Сентинел для частого бизнес-правила, обёртка для места возникновения и типизированная ошибка только там, где вызывающему коду действительно нужны поля, — это и есть понятный путь.

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

Следующие шаги для растущей Go-команды

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

Начните с одного endpoint'а, с которым команда работает каждую неделю. Поток регистрации, запрос на биллинг или job импорта подойдёт. Один загруженный путь даст вам реальные логи, реальные крайние случаи и небольшое место, где можно согласовать правила, прежде чем переносить их на весь сервис.

Затем пересмотрите старые обработчики на этом пути. Многие команды до сих пор отправляют сырой текст базы данных, сообщения SDK или ошибки хранилища прямо в ответы. Клиентам от этого мало пользы, а логи читать становится сложнее. Оставляйте внутреннюю причину в обёрнутых ошибках, но возвращайте публичное сообщение и статус-код, которые не меняются даже если слой хранения поменяется.

Короткий проход по ревью обычно окупается:

  • замените строковые проверки на errors.Is или errors.As
  • добавьте тесты, которые подтверждают, что каждый обработчик возвращает ожидаемый статус-код
  • добавьте тесты, которые проверяют, что обёрнутые ошибки всё ещё совпадают с нужной сентинельной или типизированной ошибкой
  • проверьте логи на дублирующиеся сообщения и отсутствие контекста запроса

Когда один endpoint станет аккуратным, перенесите те же правила на ещё два-три пути. Если шаблон всё ещё кажется неудобным, значит, руководство, скорее всего, слишком умное. Хорошая обработка ошибок Go должна быть достаточно скучной, чтобы новый коллега мог следовать ей без догадок.

Если вашей команде нужен один общий подход сразу для нескольких сервисов, Oleg's Fractional CTO advisory может проверить обработку ошибок, логи и ответы API, а затем помочь превратить этот обзор в простой набор правил, которым команда сможет пользоваться дальше. Такой внешний взгляд особенно полезен, когда разные сервисы уже возвращают один и тот же сбой тремя разными способами.