9. Error handling в API
Обработка ошибок в API: от архитектуры до RFC 7807
«Не просто проверяйте ошибки — обрабатывайте их достойно».
— Роб Пайк
Анатомия ошибки: От базы данных до JSON-ответа
В предыдущей статье мы разобрали 6. Статусы HTTP — числовые коды протокола, которые служат первичным грубым светофором для браузеров, прокси-серверов и систем мониторинга. Однако числового статуса 400 Bad Request совершенно недостаточно, чтобы Frontend-разработчик понял, какое именно поле формы заполнено неверно и почему валидатор отверг пароль. Статуса 500 Internal Server Error совершенно недостаточно, чтобы дежурный SRE-инженер в три часа ночи понял причину аварии и локализовал сбой.
Проектирование обработки ошибок в сетевом API — это тонкое инженерное искусство балансирования между двумя противоречивыми требованиями:
- Для клиента (Security & UX): Ответ должен быть предельно ясным, предсказуемым, строго структурированным, но при этом категорически не раскрывать внутреннее устройство системы (названия таблиц базы данных, фрагменты SQL-запросов, внутренние IP-адреса сервисов, переменные окружения и трассировку стека).
- Для разработчика (Observability & Debugging): Ошибка в логах и трейсах обязана содержать максимальный диагностический контекст (полный Stack Trace, уникальный Trace/Request ID, аргументы вызова и причину сбоя), чтобы проблему можно было локализовать за пару минут без гадания на кофейной гуще.
В этой статье мы разберем, как фундаментальная философия Go «Errors are values» помогает строить надежные, безопасные и высокопроизводительные сетевые интерфейсы.
Парадигма Go: Ошибки vs Исключения
Разработчики, приходящие в Go из Java, C#, Python или PHP, в первые недели испытывают культурный шок от повсеместной конструкции if err != nil. Они привыкли к исключениям (Exceptions) и невидимым блокам try/catch/finally. Почему создатели Go в Bell Labs осознанно отвергли механизм исключений в пользу явного возврата ошибок?
RFC 9457: Стандарт контракта ошибки
Исторически каждый бэкенд-разработчик изобретал собственный формат JSON-ответа при сбоях:
- Вариант 1:
{"error": "message"} - Вариант 2:
{"success": false, "data": null, "message": "error"} - Вариант 3:
{"err_code": 104, "description": "fail"}
Такой зоопарк превращал клиентскую разработку в кошмар: для каждого микросервиса приходилось писать уникальные парсеры ошибок.
Чтобы стандартизировать формат, комитет IETF утвердил спецификацию RFC 9457 (Problem Details for HTTP APIs), которая официально аннулировала и заменила исходный стандарт RFC 7807. Идиоматичный Go-бэкенд должен возвращать ошибки именно в этом формате с заголовком Content-Type: application/problem+json.
{
"type": "https://api.mycompany.com/errors/insufficient-funds",
"title": "Недостаточно средств на балансе",
"status": 422,
"detail": "Ваш текущий баланс 30 монет, а операция требует 50.",
"instance": "/users/123/transactions/xyz",
"extensions": {
"current_balance": 30,
"required_balance": 50
}
}
Разберем назначение ключевых полей RFC 9457:
type: URI-идентификатор, определяющий категорию проблемы. Клиентский код может программно проверять тип ошибки черезif (err.type === '...'), не занимаясь хрупким парсингом человекочитаемых строк.title: Краткая текстовая сводка проблемы на понятном человеку языке (одинаковая для всех ошибок данного типа).status: Числовой HTTP-статус (дублирует код из заголовка пакета на случай, если корпоративный прокси или CDN исказил заголовки ответа).detail: Развернутое объяснение возникшей проблемы, относящееся к конкретно этому запросу.instance: URI ресурса или уникального вхождения операции, где произошел инцидент (помогает сопоставить запрос с клиентскими действиями).extensions: Дополнительные объектные метаданные (например, балансы, списки невалидных полей формы).
Архитектура: Слоистая обработка ошибок
Главный грех неопытных разработчиков — «протекание» технической ошибки инфраструктурного слоя на уровень HTTP-клиента. Если база данных не нашла запись или упала по таймауту, отдавать наружу сырое сообщение pq: relation "orders" does not exist — грубейшая брешь в безопасности.
Архитектурно обработка ошибок строится по принципу изоляции слоев:
flowchart TD
DB["База данных (PostgreSQL)"] -->|"sql.ErrNoRows"| Repo["Слой Репозитория (Repository)"]
Repo -->|"Враппинг: fmt.Errorf(%w)<br>или errors.Join"| Usecase["Слой Бизнес-логики (Usecase)"]
Usecase -->|"Доменная ошибка:<br>ErrUserNotFound"| Handler["Слой API (HTTP Handler)"]
Handler -->|"Логирование деталей в stdout<br>Маппинг в RFC 9457"| Client["Клиент API<br>(HTTP 404 Not Found)"]
classDef db fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
classDef comp fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
classDef out fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#e2e8f0;
class DB db;
class Repo,Usecase,Handler comp;
class Client out;
1. Слой репозитория (Враппинг)
Когда драйвер базы данных возвращает техническую ошибку вроде sql.ErrNoRows, бизнес-логика не должна зависеть от SQL-пакетов. Мы оборачиваем исходную ошибку, добавляя контекст текущей операции:
err = fmt.Errorf("user repository - get by id: %w", err)
Спецификатор %w создает цепочку оборачивания («матрешку»), сохраняя исходную техническую ошибку внутри для последующего анализа через errors.Is и errors.As. В Go 1.20+ для объединения нескольких параллельных ошибок также доступен errors.Join.
2. Слой бизнес-логики (Доменные ошибки)
Бизнес-логика оперирует четко типизированными сигнальными ошибками — Sentinel Errors:
var (
ErrInsufficientFunds = errors.New("domain: insufficient funds")
ErrUserNotFound = errors.New("domain: user not found")
)
3. Слой API (Маппинг в http.Handler)
В обработчике (http.Handler) мы извлекаем доменную ошибку и транслируем её в соответствующий HTTP-статус и DTO стандарта RFC 9457.
Идиоматичный паттерн: Кастомная ошибка приложения (AppError)
Чтобы избежать дублирования громоздких конструкций switch в десятках обработчиков, зрелые Go-проекты создают структурированный тип ошибки приложения (AppError), инкапсулирующий HTTP-статус и код ошибки:
package apperrors
import "fmt"
// AppError реализует стандартный интерфейс error
type AppError struct {
Err error // Исходная техническая ошибка (для внутренних логов)
HTTPStatus int // Числовой HTTP-статус ответа (например, 404)
Code string // Машинно-читаемый код для клиента (например, "user_not_found")
Message string // Безопасное сообщение для клиента
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
// Позволяет стандартным функциям errors.Is и errors.As разворачивать ошибку
func (e *AppError) Unwrap() error {
return e.Err
}
// Вспомогательный конструктор для ошибки 404 Not Found
func NewNotFound(code, message string, err error) *AppError {
return &AppError{
Err: err,
HTTPStatus: 404, // http.StatusNotFound
Code: code,
Message: message,
}
}
Централизованный диспетчер ошибок в Middleware или вспомогательной функции выглядит лаконично:
func HandleError(w http.ResponseWriter, r *http.Request, err error) {
var appErr *apperrors.AppError
// Если ошибка типизирована как AppError — отдаем управляемый ответ
if errors.As(err, &appErr) {
// 1. Логируем ВНУТРЕННЮЮ техническую ошибку (appErr.Err) со стектрейсом
log.Printf("[ERROR] req_id=%s: %v", r.Context().Value("req_id"), appErr)
// 2. Отдаем клиенту БЕЗОПАСНЫЙ JSON (без деталей БД)
sendJSON(w, appErr.HTTPStatus, ProblemDetails{
Type: "/errors/" + appErr.Code,
Title: appErr.Message,
Status: appErr.HTTPStatus,
})
return
}
// Фолбэк: если ошибка неизвестна (паника, баг) - это 500 Internal Server Error
log.Printf("[CRITICAL] unknown error: %v", err)
sendJSON(w, 500, ProblemDetails{
Type: "/errors/internal",
Title: "Внутренняя ошибка сервера",
Status: 500,
})
}
Паники (Panics) в HTTP API
Что произойдет, если в горутине HTTP-хендлера случится разыменование нулевого указателя (nil pointer dereference) или выход за границы среза? Рантайм выбросит panic. В обычной CLI-утилите неперехваченная паника немедленно завершает весь процесс.
Однако сервер net/http спроектирован чрезвычайно устойчиво. На каждую входящую горутину сервер автоматически устанавливает отложенный перехватчик:
`defer func() { recover() }()`
Если хендлер паникует, стандартный сервер Go перехватывает сбой, выводит аварийный Stack Trace в os.Stderr, закрывает TCP-соединение с клиентом и продолжает бесперебойно обслуживать соседние параллельные запросы.
Самая коварная ошибка на собеседованиях: Nil Interface Trap
Один из самых популярных вопросов на собеседованиях уровня Senior Go Developer — поведение интерфейса error, содержащего типизированный nil.
type CustomError struct {
Msg string
}
func (e *CustomError) Error() string { return e.Msg }
func doSomething() error {
var err *CustomError = nil // Явно nil
// Ветвление не сработало, ошибки нет
// Возвращаем nil? Спойлер: НЕТ!
return err
}
func main() {
err := doSomething()
if err != nil {
fmt.Println("Произошла ошибка!") // Этот код выполнится!
}
}
Почему проверка err != nil возвращает true, если мы явно положили в переменную nil?
Итог
- Не транслируйте технические ошибки наружу: Внутренние детали (SQL-запросы, трейсы, таймауты) должны оставаться в логах, а клиент API должен получать безопасный стандартизированный ответ.
- RFC 9457 (Problem Details): Используйте стандартизированный JSON (
application/problem+json) с полямиtype,title,statusиdetail(заменивший RFC 7807). - Паттерн AppError: Применяйте централизованные типы ошибок приложения, чтобы избавиться от дублирования маппинга статусов в хендлерах.
- Враппинг: Связывайте ошибки по цепочке через спецификатор
%wвfmt.Errorfили функциюerrors.Join, позволяя функциямerrors.Isиerrors.Asработать корректно.
Научившись проектировать надежную обработку негативных сценариев, мы возвращаемся к эффективной передаче данных при нормальной работе сервиса. Как передавать клиенту списки из сотен тысяч записей, не перегружая оперативную память сервера, не создавая блокировок в базе данных и обеспечивая стабильный быстрый отклик? Ответ кроется в архитектурных паттернах выборки, которые мы изучим в следующей лекции: 10. Pagination, filtering, sorting.