1. Помилки вводу — частина контракту API
Коли ви пишете сервер «для себе», дуже хочеться думати так: «Якщо клієнт накоїв — нехай сам і страждає». Але щойно в API з’являється хоча б один реальний споживач — фронтенд, скрипт, інтеграція чи мобільний застосунок — з’ясовується неприємна річ: помилки — це теж інтерфейс, так само як і успішні відповіді.
Клієнтський код не читає ваших думок. Він читає HTTP-статус, заголовки та JSON. Якщо сьогодні на пошкоджений JSON ви віддаєте рядок, завтра — HTML, а післязавтра — JSON з іншим полем, то ламаєте клієнта так само ефективно, якби перейменували поле title на taskTitle. У Go загалом дуже цінують зворотну сумісність і не люблять ламати споживачів API без крайньої потреби.
Тому домовмося про таке: для помилок декодування та валідації клієнт завжди отримує один і той самий error envelope, і йому не потрібно гадати, у якому вигляді сьогодні прилетить помилка.
2. Чому http.Error ламає єдиний JSON-формат API
http.Error виглядає як суперзручна кнопка «зроби мені помилку». І вона справді зручна — доки ви не будуєте JSON API. Історично в Go є багато прикладів, де handler робить щось на кшталт http.Error(w, err.Error(), 500), і здається: «Ну нормально ж».
Проблема в тому, що http.Error — особливо http.Error(w, err.Error(), 500) — майже гарантовано призводить до трьох неприємностей одночасно.
Перша неприємність — формат. http.Error пише звичайний текст (часто як text/plain; charset=utf-8, а ще додає переведення рядка). Якщо ваш API в інших випадках повертає JSON, то в клієнта починається «вгадай мелодію»: на успіх — JSON, на помилку — рядок.
Друга неприємність — витік деталей. Якщо ви віддаєте err.Error() назовні, ви часто показуєте нутрощі: назви полів структур, деталі парсингу, інколи шматки конфігурації та інші «секрети Полішинеля». Це не завжди прямо небезпечно, але майже завжди неохайно.
Третя неприємність — копіпаста. Щойно обробників стає більше двох, http.Error розмножується швидше за кроликів: у кожному місці трохи інший текст, трохи інша логіка, трохи інші статуси. Класична мотивація до централізації обробки помилок якраз і з’являється через таку повторюваність.
Від цього моменту ми домовляємося: http.Error у нашому JSON API не використовуємо. Узагалі. Навіть «на хвилиночку».
3. Єдиний error envelope: формат помилок
Щоб далі говорити однією мовою, зафіксуємо формат, який клієнт очікує завжди, коли щось пішло не так:
{
"error": {
"code": "validation",
"message": "некоректний запит",
"fields": {
"title": "не має бути порожнім"
}
}
}
Де code — короткий машинний код (зручно для автоматичної обробки), message — людське загальне повідомлення, а fields — об’єкт із помилками за конкретними полями (використовуємо лише для помилок валідації, інакше поле відсутнє).
У Go це зазвичай виглядає так:
package main
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
Зверніть увагу на omitempty: якщо Fields == nil, то fields у JSON не з’явиться. Це робить відповідь охайнішою та зрозумілішою: немає помилок за полями — немає й поля fields.
4. Декодування та валідація: дві різні проблеми
Дуже поширена помилка новачка — зводити все до одного: «Якщо щось не так — 400». Формально це часто працює. Але коли ви налагоджуєте клієнт або пишете документацію, різниця стає принциповою.
У нашому сервері є два окремі етапи перевірки вводу.
Перший етап — декодування: ми читаємо r.Body і намагаємося зрозуміти, чи це валідний JSON потрібної структури. Тут помилки зазвичай технічні: пошкоджений синтаксис, передчасне завершення, зайві поля, неправильний тип — наприклад, рядок замість числа.
Другий етап — валідація: JSON коректний, структура збіглася, але дані не відповідають правилам предметної області. Наприклад, title порожній або складається з пробілів. Це вже не «битий JSON», а «погані дані».
Зручно уявляти це як конвеєр:
flowchart TD
A[HTTP-запит] --> B[Декодування JSON]
B -->|декодування успішне| C[Валідація полів]
B -->|помилка декодування| E[400 + error envelope: body/...]
C -->|валідація успішна| D[Бізнес-логіка]
C -->|помилка валідації| F[400 + error envelope: fields]
Тут важлива ідея: і помилки декодування, і помилки валідації — це 400, але fields і сенс повідомлень відрізняються.
5. Приклад: POST /tasks і поганий ввід
Щоб не обговорювати абстракції у вакуумі, продовжимо наше навчальне API для задач. Сьогодні нас цікавить створення задачі через JSON.
Запит виглядатиме так:
{ "title": "Buy milk" }
Для нього заведемо DTO — структуру запиту:
package main
type createTaskRequest struct {
Title string `json:"title"`
}
І ось які ситуації ми маємо вміти відрізняти, не втрачаючи самовладання:
- тіло порожнє (клієнт не надіслав нічого);
- JSON пошкоджений ({ "title": "Buy milk");
- JSON коректний, але поле має неправильний тип ({ "title": 123 });
- JSON містить зайві поля ({ "title": "Buy milk", "lol": true });
- JSON коректний, але title порожній або складається з пробілів ({ "title": " " });
- тіло надто велике (клієнт вирішив надіслати «Війну і мир» як назву задачі).
Якщо ви навчитеся стабільно обробляти це в одній кінцевій точці, інші кінцеві точки будуватимуться за тим самим шаблоном. А якщо ні — ваші клієнти почнуть «вчитися» методом спроб і помилок… і зазвичай вчаться вони на вашому проді.
6. Як класифікувати помилки json.Decoder
encoding/json — доволі чесний пакет: він не приховує, що саме пішло не так. Ба більше, багато помилок там є типізованими — наприклад, *json.SyntaxError. І Go прямо заохочує такі речі: помилки — це значення, вони можуть мати типи, і це нормально використовувати.
Але є нюанс: щоб охайно відрізняти види помилок, нам потрібна мінімальна «таблиця відповідностей» з внутрішніх помилок декодера у наш зовнішній контракт — error envelope.
Ми перевірятимемо помилки через errors.As/errors.Is. Це важливо, тому що помилки можуть бути обгорнуті: ви додали контекст через fmt.Errorf("decode: %w", err), а початковий тип помилки все одно хочеться дізнатися. Саме для цього в Go існують errors.Is і errors.As.
Мінітаблиця: що сталося і що повертаємо
Стисло зведімо основну ідею в таблицю. Це не «єдино правильні формулювання», але добрий стабільний початок.
| Ситуація (всередині) | Як розпізнати в Go | Що відповідаємо клієнту |
|---|---|---|
| Порожнє тіло | err == io.EOF при першому Decode | 400 + fields.body = "не має бути порожнім" |
| Пошкоджений JSON (синтаксис) | var e *json.SyntaxError; errors.As(err, &e) | 400 + fields.body = "некоректний JSON" |
| Неправильний тип поля | var e *json.UnmarshalTypeError; errors.As(err, &e) | 400 + fields.<field> = "неправильний тип" |
| Зайві поля | DisallowUnknownFields() + перевірка тексту "json: unknown field ..." | 400 + fields.body = "невідоме поле" (або конкретніше) |
| Кілька JSON-об’єктів підряд | другий Decode не повернув io.EOF | 400 + fields.body = "має містити лише один JSON-об’єкт" |
| Надто велике тіло | var e *http.MaxBytesError; errors.As(err, &e) | 400 + fields.body = "занадто велике" |
Сенс у тому, що клієнт завжди отримує один і той самий зовнішній формат, але «начинка» (fields) дає йому зрозуміти, що саме виправляти.
7. decodeJSON: безпечно читаємо тіло запиту і повертаємо помилку клієнту
Зараз ми зберемо функцію, яка робить одразу три речі: обмежує розмір тіла запиту, строго декодує JSON і перевіряє правило «рівно один JSON-об’єкт». Важливо: ця функція поки що сама не формує відповідь. Вона повертає помилку, яку обробник зможе перетворити на error envelope.
Спочатку заведемо свою маленьку помилку для «проблем вводу»:
package main
type inputError struct {
Fields map[string]string
}
func (e inputError) Error() string {
return "некоректний запит"
}
Ця помилка не намагається бути «розумною»: її призначення — зберігати Fields. А рядок Error() нам потрібен лише тому, що інакше це не буде error.
Тепер сама функція декодування:
package main
import (
"encoding/json"
"net/http"
)
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
const maxBody = 1 << 20 // 1 MiB
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := decodeOne(dec, dst); err != nil {
return classifyDecodeError(err)
}
return nil
}
Декодуємо та перевіряємо «хвіст»:
package main
import (
"encoding/json"
"errors"
"io"
)
func decodeOne(dec *json.Decoder, dst any) error {
if err := dec.Decode(dst); err != nil {
return err
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
return errors.New("зайві дані після JSON")
}
return nil
}
Так, ми свідомо розбили на дві функції: щоб шматки коду були маленькими й читалися без лупи.
Тепер «перекладач» помилок декодування в inputError:
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
)
func classifyDecodeError(err error) error {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return inputError{Fields: map[string]string{"body": "занадто велике"}}
}
if errors.Is(err, io.EOF) {
return inputError{Fields: map[string]string{"body": "не має бути порожнім"}}
}
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) && typeErr.Field != "" {
return inputError{Fields: map[string]string{typeErr.Field: "неправильний тип"}}
}
return inputError{Fields: map[string]string{"body": "некоректний JSON"}}
}
Тут є два «навчальні компроміси», і це нормально.
- По-перше, ми не намагаємося ідеально розпарсити «unknown field X». Можна зробити точніше, але на першому наближенні достатньо загального повідомлення "некоректний JSON" або "невідоме поле".
- По-друге, ми не видаємо назовні err.Error() — і це принципово. Навіть якщо дуже хочеться «допомогти клієнту». Справжня допомога має бути в стабільному контракті, а не у випадковому рядку з внутрішньої бібліотеки.
8. Валідація полів після декодування
Валідація — це момент, коли ви перестаєте бути «парсером JSON» і стаєте «охоронцем на вході в предметну область». Предметній області зазвичай байдуже, чи був JSON гарним. Їй важливо, щоб значення мали сенс.
Тому валідовуємо окремо й явно. Для createTaskRequest правило просте: title не має бути порожнім або складатися з пробілів.
package main
import "strings"
func validateCreateTask(req createTaskRequest) error {
title := strings.TrimSpace(req.Title)
if title == "" {
return inputError{Fields: map[string]string{"title": "не має бути порожнім"}}
}
return nil
}
Чому ми знову повертаємо inputError? Тому що для обробника не важливо, де саме зламався ввід — на декодуванні чи на валідації. Якщо це «помилка клієнта», обробник повертає 400 з error envelope.
9. Обробник POST /tasks: завжди відповідаємо через envelope
Тепер зберемо обробник POST /tasks, який використовує наші функції. Для простоти: якщо все добре, повернемо 201 Created і маленький JSON { "ok": true }. У реальному проєкті ми б повернули DTO створеної задачі, але зараз наша мета — обробка помилок вводу.
Спочатку — мінімальна функція для запису error envelope. Так, це невеликий helper: ми не будуємо «універсальний комбайн», а просто не хочемо тричі повторювати однакову структуру JSON:
package main
import (
"encoding/json"
"net/http"
)
func writeValidationError(w http.ResponseWriter, fields map[string]string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(ErrorEnvelope{
Error: APIError{
Code: "validation",
Message: "некоректний запит",
Fields: fields,
},
})
}
Тепер сам обробник:
package main
import (
"encoding/json"
"errors"
"net/http"
)
func handleCreateTask(w http.ResponseWriter, r *http.Request) {
var req createTaskRequest
if err := decodeJSON(w, r, &req); err != nil {
var in inputError
if errors.As(err, &in) {
writeValidationError(w, in.Fields)
return
}
writeValidationError(w, map[string]string{"body": "некоректний JSON"})
return
}
if err := validateCreateTask(req); err != nil {
var in inputError
if errors.As(err, &in) {
writeValidationError(w, in.Fields)
return
}
writeValidationError(w, map[string]string{"body": "некоректний запит"})
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
Так, тут є невелика навчальна надмірність: двічі перевіряємо errors.As. Але зате логіка лінійна й зрозуміла: decode → validate → успіх.
І ще один важливий момент: зверніть увагу, що в разі проблем із вводом ми завжди повертаємо відповідь у форматі JSON. Клієнту не потрібно з’ясовувати, що саме ми використали всередині.
Що можна розкривати клієнту, а що краще залишити в логах
Коли ви обираєте, що писати в помилці, ви фактично обираєте, що стає частиною API. У Go це добре видно навіть на рівні звичайних функцій: якщо ви обгортаєте внутрішню помилку і даєте клієнту можливість її розпізнати, то ніби обіцяєте, що ця внутрішня деталь тепер стабільна. І це може стати проблемою, якщо ви захочете змінити реалізацію.
В HTTP API все так само. Якщо ви віддаєте назовні «сирі» тексти з encoding/json або «сирі» помилки бази даних, клієнт може почати на них покладатися — навіть якщо клянеться, що не буде. І за місяць ви виявите, що не можете змінити бібліотеку або покращити текст, бо в когось усе зламається.
Тому добре правило таке: назовні віддаємо стабільні коди та передбачувані поля, а деталі залишаємо собі — наприклад, у логах. Навіть якщо зараз логів немає, усе одно краще не робити «витоків майбутніх проблем».
10. Типові помилки
Помилка №1: відповідати на пошкоджений JSON через http.Error, а на валідацію — через JSON.
Так у клієнта з’являються два формати помилок. У найкращому разі він починає писати умовні гілки: «якщо JSON розпарсився — одне, інакше — інше». У найгіршому — падає прямо на парсингу відповіді. Для API значно краще, коли формат помилок один і завжди JSON.
Помилка №2: надсилати назовні err.Error() з encoding/json.
Навіть якщо здається, що це «корисно клієнту», на практиці ви віддаєте деталі реалізації та нестабільні формулювання. Клієнт їх запам’ятовує, тести на фронтенді їх фіксують, і ви самі себе заганяєте в кут.
Помилка №3: змішувати декодування та валідацію в одну купу без структури.
Якщо ви спочатку валідовуєте поля, а потім намагаєтеся декодувати JSON або робите це в різних місцях, легко отримати дивні баги: r.Body читається один раз, і вдруге там уже «порожньо». Правильніше тримати протокол «decode → validate» завжди в одному порядку та в одному місці.
Помилка №4: не перевіряти правило «рівно один JSON-об’єкт».
Якщо зробити лише один Decode, то запит із тілом виду {...}{...} може частково «проковтнутися», а сміття залишиться непоміченим. Це рідко трапляється в нормальних клієнтів, але часто трапляється в тестах, ручних запитах і в зловмисників — так, вони теж інколи існують.
Помилка №5: забути про ліміт розміру тіла запиту.
Без http.MaxBytesReader ви дозволяєте клієнту надіслати гігантське тіло, яке читатиметься й витрачатиме пам’ять і час. Навіть якщо ви не думаєте про атаки, думайте хоча б про випадковий баг клієнта: «ой, ми відправили не те поле, і воно виявилося мегабайтним».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ