1. Навіщо потрібні хелпери відповідей
Коли ви пишете перший HTTP‑сервер, здається, що відповідати просто: «ну я ж можу зробити json.NewEncoder(w).Encode(v) — і все». І так, це працює… рівно до того моменту, поки ви не зіткнетеся з реальними помилками й людьми, які намагаються користуватися вашим API. В API з’являється контракт: заголовки, статус-коди, формат помилок, стабільність відповіді та передбачуваність для клієнта.
Уявіть, що ваш клієнт — CLI, фронтенд або інший сервіс — уміє обробляти помилки лише тоді, коли вони приходять у форматі:
{ "error": { "code": "...", "message": "...", "fields": { ... } } }
А ви випадково в одному обробнику викликали http.Error(w, err.Error(), 500). Клієнт раптово отримує текст замість JSON, парсер падає, і користувачеві від цього не легше. І найгірше — це не «одна помилка», а просто відсутність дисципліни: кожен handler пише так, як йому зручно.
Є й підступніша технічна проблема: HTTP‑відповідь не можна «відкотити». Щойно ви почали писати тіло або виставили статус, ви вже не зможете раптом вирішити: «ой, усе-таки помилка» — і надіслати інший JSON. Тому хелпери важливі не лише заради краси, а й для того, щоб зменшити ризик частково записаної, зіпсованої відповіді.
Невелика схема того, куди ми рухаємося:
flowchart TD
R[HTTP‑запит] --> M[ServeMux обирає обробник]
M --> H[Наш обробник]
H -->|успіх| WJ[writeJSON]
H -->|помилка| EH[повертаємо HTTPError]
EH --> A[адаптер appHandler.ServeHTTP]
A --> WE[writeError -> writeJSON]
WJ --> RESP[HTTP‑відповідь]
WE --> RESP
2. Єдина JSON‑відповідь і єдині помилки
writeJSON: єдиний спосіб писати JSON‑відповідь
Коли ви вводите writeJSON, ви ніби кажете собі: «Відтепер у проєкті є нормальний, єдиний спосіб повертати JSON». Це трохи схоже на домовленість у команді: ми не сперечаємося щоразу, чи треба ставити Content-Type, чи треба додавати \n, чи треба кодувати в буфер. Ми просто викликаємо одну функцію — і всі відповіді однакові.
Головні обов’язки writeJSON прості: виставити коректний Content-Type, поставити статус-код, серіалізувати значення в JSON і записати його в ResponseWriter. Тонкість починається тоді, коли серіалізація може впасти: якщо ви вже почали писати в w, а потім Encode зламався, ви не зможете красиво надіслати клієнту помилку, бо заголовки вже пішли. Тому хороший навчально‑практичний прийом — спочатку кодувати в bytes.Buffer, а вже потім писати в w.
Ось мінімальний варіант writeJSON:
package main
import (
"bytes"
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
return nil
}
Зверніть увагу на дві «нудні» деталі, які на практиці рятують нерви. По‑перше, заголовок Content-Type виставлено до WriteHeader, бо після запису статусу змінювати заголовки вже пізно. По‑друге, ми повертаємо error, бо кодування JSON — це операція, яка може зламатися (наприклад, якщо всередині v опиниться непідтримуване значення).
Тепер подивімося, як обробник виглядає з цим хелпером. Важливо: ми не змішуємо формування відповіді та ручну роботу із заголовками — нехай це робить writeJSON.
package main
import "net/http"
func handleHealth(w http.ResponseWriter, r *http.Request) *HTTPError {
resp := map[string]any{"ok": true}
if err := writeJSON(w, http.StatusOK, resp); err != nil {
return internalError(err)
}
return nil
}
Так, поки що ми посилаємося на HTTPError і internalError, яких ще не визначили. Це нормально: ми збираємо систему, як конструктор — по деталях. Наразі головне ось що: успішна JSON‑відповідь завжди проходить через writeJSON.
writeError: єдиний формат помилок API
Помилки в API — це не «ой, щось пішло не так», а окремий продукт. Клієнт має вміти відрізняти помилку валідації від внутрішньої помилки, має розуміти, яке поле неправильне, і має мати машинний код помилки, щоб писати умовні if code == "validation" { ... }. Саме тому ми фіксуємо єдиний формат — обгортку помилки (error envelope).
Структури для обгортки зручно описати як DTO:
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"`
}
Поле fields позначено omitempty: якщо помилок по полях немає, воно не повинно бовтатися порожнім об’єктом — це дрібниця, але саме такі дрібниці роблять API приємнішим.
Тепер пишемо writeError. Ідея проста: writeError не займається HTTP‑магією напряму — він викликає writeJSON, передаючи envelope.
package main
import "net/http"
func writeError(w http.ResponseWriter, status int, code, msg string, fields map[string]string) {
_ = writeJSON(w, status, ErrorEnvelope{
Error: APIError{
Code: code,
Message: msg,
Fields: fields,
},
})
}
Тут спеціально стоїть _ = writeJSON(...): коли ми вже вирішили «пишемо помилку», можлива помилка серіалізації — це вже зовсім аварійна ситуація, і вдруге «відповісти помилкою на помилку» ми не зможемо. За потреби пізніше ви можете додати логування цього випадку — обережно, без ускладнень.
Тепер обробник може повертати структуровану помилку валідації як значення, а відповідь формуватиме окремий шар:
package main
import "net/http"
func badRequest(fields map[string]string) *HTTPError {
return &HTTPError{
Status: http.StatusBadRequest,
Code: "validation",
Message: "invalid request",
Fields: fields,
}
}
І далі все однаково: будь-яка 400‑помилка в нас повертатиметься як envelope. Клієнт задоволений: він завжди знає, де шукати error.code, error.message і error.fields.
Правило безпеки: не віддаємо err.Error() на 500
Дуже хочеться, особливо в навчальних прикладах, зробити так:
http.Error(w, err.Error(), 500)
Це виглядає чесно: «ну ось же помилка, чого приховувати». Але в реальному сервері це майже завжди помилка дизайну.
Чому? Тому що err.Error() часто містить внутрішні деталі: назви таблиць, шляхи до файлів, адреси серверів, шматки JSON, внутрішні ідентифікатори, а інколи навіть фрагменти секретів, якщо хтось необережно сформував помилку. Плюс це нестабільно: ви зміните бібліотеку — текст помилки зміниться, і клієнтські тести чи скрипти почнуть ламатися.
Є важлива думка з практики Go: коли ви додаєте контекст до помилки або вирішуєте, чи обгортати помилку, ви фактично приймаєте API-рішення про те, які деталі готові розкривати стороні, що викликає. Цю думку часто формулюють так: «wrap робить underlying error доступною для inspection стороні, що викликає; не wrap, якщо це розкриє деталі реалізації».
Для HTTP‑API думка ще жорсткіша: клієнту майже ніколи не потрібна «сира внутрішня причина». Клієнту потрібна зрозуміла реакція — "internal error", а деталі — вам, у логи.
Звідси правило лекції: для помилок 500‑класу в JSON‑відповіді завжди фіксоване message, наприклад "internal error". Справжній err зберігаємо всередині — у структурі помилки — і за потреби логуємо.
HTTPError: що віддаємо клієнту і що лишаємо собі
Щоб правило «не віддаємо err.Error() назовні» виконувалося автоматично, нам потрібен тип помилки, який зберігає і публічні поля для відповіді, і внутрішню причину для логів чи діагностики. Саме ним і буде HTTPError.
package main
type HTTPError struct {
Status int
Code string
Message string
Fields map[string]string
Err error // внутрішня причина (НЕ віддаємо клієнту)
}
Виглядає майже надто просто. Але саме так і треба: структура помилок має бути нудною, як бухгалтерія. Креатив залишимо для назв змінних.
Тепер зробімо конструктор для внутрішніх помилок. Він фіксує контракт: статус 500, код "internal", повідомлення "internal error", а внутрішня причина зберігається в Err.
package main
import "net/http"
func internalError(err error) *HTTPError {
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "internal",
Message: "internal error",
Err: err,
}
}
3. Централізація обробки помилок: appHandler
Якщо ви продовжите писати обробники напряму, майже в кожному повторюватиметься одне й те саме: перевірка вхідних даних, if err != nil, формування error envelope, return. Це не кінець світу, але мозок швидко втомлюється від однакових гілок, і в якийсь момент в одному обробнику ви випадково повернете помилку не тим форматом.
Класична ідея в Go — зробити адаптер: обробник повертає помилку як значення, а адаптер реалізує ServeHTTP і централізує реакцію на неї. У нашому варіанті обробник повертає *HTTPError (бо це саме помилка HTTP‑шару зі статусом, кодом і полями), а адаптер пише відповідь через writeError.
package main
import (
"log"
"net/http"
)
type appHandler func(http.ResponseWriter, *http.Request) *HTTPError
func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if e := h(w, r); e != nil {
if e.Err != nil {
log.Printf("internal error: %v", e.Err) // деталі залишаються в нас
}
writeError(w, e.Status, e.Code, e.Message, e.Fields)
}
}
Зверніть увагу на приємну річ: appHandler — це тип функції, і в нього є метод ServeHTTP. Так, у функції може бути метод. Так, Go інколи так уміє — і це один із тих моментів, коли мова ніби каже: «Я не забороняю вам робити зручно, я просто прошу не робити дивно».
4. Збірка міні‑API: маршрути та єдині відповіді
Тепер давайте зберемо мінісервер, на якому видно, що всі відповіді проходять одним шляхом. Ми не будуємо повноцінний CRUD і не йдемо в складну архітектуру — нам важливо закріпити саме відповіді та помилки.
Збірка mux і запуск сервера:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.Handle("/health", appHandler(handleHealth))
mux.Handle("/ping", appHandler(handlePing))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
/health ми вже бачили: він відповідає JSON‑об’єктом { "ok": true }. Зробімо /ping, щоб показати і успішну відповідь, і помилку валідації залежно від методу.
package main
import "net/http"
func handlePing(w http.ResponseWriter, r *http.Request) *HTTPError {
if r.Method != http.MethodGet {
return badRequest(map[string]string{
"method": "must be GET",
})
}
if err := writeJSON(w, http.StatusOK, map[string]string{"pong": "pong"}); err != nil {
return internalError(err)
}
return nil
}
Зверніть увагу, як читається обробник: спочатку guard clause (рання перевірка методу), потім успішний шлях, потім return nil. Немає змішування WriteHeader, json.Encode, http.Error і випадкових рядків. Саме це й є мета: зробити обробник лінійним.
5. Нюанс: writeJSON теж може повернути помилку
Є момент, який часто дивує новачків: «А що може зламатися під час кодування відповіді?». Здається, якщо ви формуєте структуру самі, то вона точно закодується. Але в Go можна випадково передати в any те, що JSON не вміє кодувати (наприклад, канал, функцію, циклічну структуру), і тоді Encode поверне помилку.
Тому writeJSON повертає error, і в обробнику ми робимо:
if err := writeJSON(...); err != nil {
return internalError(err)
}
Це виглядає як зайва перевірка, але вона варта того: ви уникаєте ситуації «половина відповіді записалася, а потім усе впало». Плюс, навіть якщо помилка кодування сталася, назовні ви все одно не віддаєте err.Error(), а повертаєте стабільне повідомлення "internal error".
З погляду дизайну помилок це узгоджується із загальною ідеєю Go: помилки — це значення, їх можна збагачувати контекстом, але розкривати нутрощі назовні треба свідомо.
6. Типові помилки при writeJSON/writeError і appHandler
Помилка №1: писати частину відповіді, а потім намагатися «повернути помилку».
Зазвичай це виглядає так: ви спочатку зробили w.WriteHeader(200), потім почали писати JSON, потім щось упало — і ви намагаєтеся викликати writeError. Але HTTP — не відеомонтаж: «перемотати назад» не можна. Тому ми кодуємо в буфер до запису заголовків, а в обробниках тримаємо правило: або ми успішно відповіли, або повернули *HTTPError до запису тіла.
Помилка №2: віддавати клієнту err.Error() на 500‑класі.
Це класика, і вона часто з’являється «тимчасово, для налагодження», а потім тимчасове стає вічним. Правильний підхід: публічне повідомлення фіксоване ("internal error"), внутрішня причина зберігається в HTTPError.Err і йде в логи. Це не параноя, а контракт і безпека, бо деталі реалізації не повинні ставати частиною зовнішнього API.
Помилка №3: змішувати формати помилок.
Якщо один обробник відповідає JSON‑помилкою, а інший робить http.Error, клієнту доводиться писати два парсери або вгадувати за Content-Type. Такий API здається «живим» лише розробнику сервера, а клієнту він здається непередбачуваним. Виправляється просто: усі помилки проходять через writeError, а writeError завжди пише envelope.
Помилка №4: робити fields завжди порожнім об’єктом.
Якщо fields з’являється завжди, клієнт починає думати, що там завжди є зміст, і пише зайву логіку. Краще тримати контракт акуратним: fields є лише тоді, коли це справді помилки по полях, тому omitempty і nil — ваші друзі.
Помилка №5: намагатися «логувати все» всередині кожного обробника.
Новачок часто додає log.Println у кожен обробник, і за тиждень логи перетворюються на шум. Набагато чистіше, коли у вас є одне місце — адаптер ServeHTTP, — де ви логуєте внутрішню причину e.Err. Такий підхід якраз і цінний тим, що зменшує копіпасту та робить поведінку однаковою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ