1. 404 і 405: схожі зовні, але означають різне
Коли ви починаєте писати API, перші помилки часто виглядають однаково: «Я надіслав запит, а у відповідь отримав не те». Дуже хочеться всі такі випадки автоматично називати «не знайдено» і ставити 404 куди завгодно. Але 404 і 405 — це різні діагнози. Якщо їх плутати, клієнт, а іноді й ви через два дні, починає лікувати ангіну зеленкою.
404 Not Found означає: «за цим шляхом сервер не знає, що робити» або «ресурсу з такими параметрами не існує». Тобто «не знайдено» — це або про маршрутизацію, або про дані.
405 Method Not Allowed означає: «шлях я розумію, але метод не той». Тобто двері є, але ви прийшли не з тим ключем: наприклад, ви надіслали POST /api/v1/tasks/1, а сервер на цьому шляху очікує лише GET.
Порівняймо в невеликій таблиці:
| Ситуація | Що це означає за змістом | Який статус очікується |
|---|---|---|
| Клієнт прийшов на шлях, якого в API немає | «Такої сторінки або кінцевої точки немає» | |
| Клієнт прийшов на правильний шлях, але методом, який тут заборонений | «Шлях правильний, але цей метод не можна використовувати» | |
| Клієнт прийшов на правильний шлях і метод, але просить неіснуючий ресурс (id не знайдено) | «Кінцева точка є, але об’єкта немає» | |
Зверніть увагу на останній пункт: 404 буває і на рівні маршрутизації, і на рівні даних. І це нормально. Важливо лише розуміти, хто саме вирішив повернути 404: роутер (ServeMux) чи ваш handler.
2. Де формуються 404/405 у net/http
Коли ви пишете сервер на Go, зручно уявляти, що запит проходить через два «контрольні пункти».
Спочатку запит потрапляє в ServeMux: він дивиться на шлях, а якщо ви використовуєте method-aware patterns, то ще й на метод, і вирішує, який обробник має обслуговувати запит. Якщо відповідного обробника немає, саме тут і з’являється 404.
Якщо обробника знайдено, запит передається у вашу функцію, а далі вже ви вирішуєте, що повернути. Тут ви можете повернути 200, 400, 404 (якщо ресурс не знайдено), 500 тощо.
Для наочності — мінісхема:
flowchart TD
A[HTTP-запит: метод + шлях] --> B{ServeMux шукає обробника}
B -->|Немає відповідного| C[404 Not Found
на рівні маршрутизації]
B -->|Є відповідний| D[Ваш обробник]
D --> E{Перевірки всередині обробника}
E -->|Ресурс не знайдено| F[404 Not Found
на рівні даних]
E -->|Метод не підтримано
якщо перевіряєте вручну| G[405 Method Not Allowed]
E -->|Усе гаразд| H[2xx — успіх]
Невеликий фрагмент навчального сервера
Продовжімо ідею навчального API «таск-трекера» (системи завдань). Мінімально зареєструймо пару маршрутів:
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks", listTasks)
mux.HandleFunc("GET /api/v1/tasks/{id}", getTask)
_ = http.ListenAndServe(":8080", mux)
}
Тут важливо не те, що роблять listTasks і getTask, а те, що ми явно кажемо ServeMux, які шляхи та методи існують. Тепер:
- GET /api/v1/tasks — існує
- POST /api/v1/tasks — не існує, поки ми його не зареєструємо
- GET /api/v1/aliens — не існує
- GET /api/v1/tasks/123 — існує як маршрут, а що буде з даними — вирішить обробник
3. Два види 404: маршрутизація та дані
404 багато хто сприймає як «ой, сервер зламався й нічого не знайшов». Але насправді 404 — це цілком коректна й корисна відповідь. У хороших API 404 — це спосіб сказати клієнту: «ви просите те, чого в нас немає, і це очікувано».
Спочатку розберімо 404 на рівні маршрутизації. Це ситуація, коли ServeMux не знайшов відповідного обробника. Наприклад, у вас немає маршруту "GET /api/v1/projects", і запит на нього — це просто невідома точка входу. У цьому випадку до вашого коду справа навіть не дійшла, і 404 сформував роутер.
А тепер 404 на рівні даних. Припустімо, маршрут є: "GET /api/v1/tasks/{id}" зареєстровано. Але id=999 у вас у пам’яті або в базі відсутній. Тоді маршрут знайдено, обробник уже запущено, але ви вирішуєте: «ресурсу немає» — отже, знову 404, тільки вже з іншої причини.
Цей сенс добре видно в прикладі з практики обробки помилок у Go: коли запис не знаходиться у сховищі, обробник повертає користувачу 404 із повідомленням "Record not found".
Мініприклад: 404 «завдання не знайдено» в обробнику
Зробімо просте сховище в пам’яті. Для навчання — ідеально, для продакшну — спірно, але зараз не про це.
package main
var tasks = map[int]string{
1: "купити молоко",
2: "вивчити Go",
}
І обробник getTask, який поверне 404, якщо ключа немає:
package main
import (
"fmt"
"net/http"
"strconv"
)
func getTask(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
title, ok := tasks[id]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
fmt.Fprintln(w, title) // наприклад: купити молоко
}
Тут сталося важливе: маршрут існує, обробник викликано, але 404 з’явився тому, що даних не знайдено.
І це нормальний сценарій: клієнт міг запросити завдання за id, яке вже видалили, або просто помилитися. Це не внутрішня помилка сервера, тож тут не 500.
4. 405 Method Not Allowed: шлях правильний, метод — ні
405 — це відповідь рівня «я вас зрозумів, але так не можна». І вона помітно полегшує життя клієнту. Бо коли ви відповідаєте 404 на неправильний метод, клієнт думає: «шляху немає» і починає виправляти URL. Хоча URL був правильний — проблема була в методі.
Є два основні способи отримати 405 у Go-сервері:
- Перший спосіб — ви використовуєте method-aware patterns ("GET /path", "POST /path"), і тоді частину роботи за вас бере ServeMux: він знає, що шлях існує, але метод не підходить, і може повернути 405.
- Другий спосіб — ви реєструєте шлях без методу або одним обробником на всі методи, а всередині обробника самі робите switch за r.Method і на невідомі методи відповідаєте 405.
Варіант A: різні обробники для різних методів
package main
import "net/http"
func registerTaskRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/tasks", listTasks)
mux.HandleFunc("POST /api/v1/tasks", createTask)
}
Тепер шлях один (/api/v1/tasks), але зареєстровано два варіанти. Якщо клієнт надішле PUT /api/v1/tasks, це вже неправильний метод.
Варіант B: один обробник і ручний switch за r.Method
Іноді так роблять для простих випадків або якщо хочуть тримати логіку в одному місці:
package main
import (
"fmt"
"net/http"
)
func tasksEndpoint(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
fmt.Fprintln(w, "list tasks")
case http.MethodPost:
w.WriteHeader(http.StatusCreated)
default:
w.Header().Set("Allow", "GET, POST")
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
Зверніть увагу: за ручного 405 потрібно пам’ятати про заголовок Allow. Це не «опція для педантів», а частина змісту 405: «ось які методи дозволені».
Заголовок Allow: як читати й навіщо він потрібен
Заголовок Allow — це чесна підказка клієнту: які методи допустимі для цього ресурсу. Він особливо корисний, коли одна й та сама точка API означає різні дії залежно від методу. Наприклад, GET /api/v1/tasks — «отримати список», а POST /api/v1/tasks — «створити завдання».
Якщо сервер відповідає 405, добрим тоном і, по суті, стандартним очікуванням є додати Allow. Тоді клієнт може автоматично повторити запит правильним методом або принаймні вивести зрозумілу помилку користувачу чи розробнику.
У мінімальному вигляді це виглядає так:
package main
import "net/http"
func methodNotAllowed(w http.ResponseWriter, allow string) {
w.Header().Set("Allow", allow)
w.WriteHeader(http.StatusMethodNotAllowed)
}
І в обробнику:
package main
import "net/http"
func onlyGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, "GET")
return
}
w.WriteHeader(http.StatusOK)
}
Як швидко перевірити Allow через curl
Коли ви налагоджуєте 405, корисно дивитися не лише тіло відповіді, а й заголовки:
curl -i -X PUT http://localhost:8080/api/v1/tasks
Ключ -i показує заголовки. Якщо ви бачите HTTP/1.1 405 Method Not Allowed і рядок на кшталт Allow: GET, POST, отже сервер каже: «шлях я знаю, але PUT тут не можна».
5. Навіщо розрізняти 404 і 405
Якби ми писали програму лише для себе, можна було б сказати: «та байдуже, яка різниця, усе одно помилка». Але API майже завжди використовує хтось інший: фронтенд, мобільний застосунок, скрипт або інший сервіс. І тут різниця стає дуже практичною.
Коли клієнт отримує 404, він зазвичай робить висновок: «URL неправильний» або «ресурсу немає». Коли отримує 405, робить висновок: «метод неправильний, спробую інший». Це різні гілки поведінки. Якщо ви плутаєте статуси, ви буквально змушуєте клієнта лагодити не те місце.
Ще один важливий момент — налагодження. 404 від роутера і 404 від обробника виглядають однаково за статусом, але причини в них різні. У першому випадку ви забули зареєструвати маршрут або помилилися в шаблоні шляху. У другому — усе зареєстровано, але бізнес-логіка каже: «немає такого об’єкта».
Щоб налагодження було менш «магічним», корисно хоча б тимчасово логувати метод і шлях в обробниках. Так, старий добрий fmt.Printf іноді рятує краще за філософські роздуми:
package main
import (
"fmt"
"net/http"
)
func debug(w http.ResponseWriter, r *http.Request) {
fmt.Printf("method=%s path=%s\n", r.Method, r.URL.Path) // method=GET path=/api/v1/tasks/1
w.WriteHeader(http.StatusOK)
}
Навіть один такий рядок іноді миттєво показує проблему: наприклад, клієнт ходить на /api/v1/tasks/1/ із кінцевим слешем, а ви зареєстрували /api/v1/tasks/{id} без завершального слеша. І ви дивитеся на 404 і думаєте: «чому?!», хоча відповідь проста: рядки шляху різні.
6. Типові помилки
На 404/405 новачки наступають дуже стабільно, ніби це не статуси, а граблі з автопідігрівом. Причина зазвичай у тому, що ми думаємо: «сервер має зрозуміти, що я мав на увазі». Але комп’ютер — істота буквальна: якщо ви сказали PUT, він вірить, що ви хотіли саме PUT, а не «ну, взагалі-то я хотів оновити завдання, але не пам’ятаю як».
Помилка №1: віддавати 404 замість 405, бо «так простіше».
Так простіше рівно до першого клієнта, який починає «лагодити URL» і витрачати час на неправильну діагностику. Якщо шлях існує, але метод заборонено, краще чесно сказати 405 і, якщо формуєте відповідь вручну, додати Allow. Це робить API передбачуваним.
Помилка №2: плутати 404 «маршрут не знайдено» і 404 «ресурс не знайдено».
Обидві відповіді коректні, але важливо тримати в голові, де саме вона виникла. Якщо 404 прийшов від роутера, отже проблема в реєстрації маршрутів або в шаблоні шляху. Якщо 404 прийшов із обробника, отже код відпрацював і вирішив, що даних немає, наприклад, id відсутній у сховищі.
Помилка №3: робити ручну перевірку методу і забувати Allow.
Код w.WriteHeader(405) виглядає «достатньо», але за змістом 405 майже завжди має на увазі: «ось дозволені методи». Якщо ви не вкажете Allow, клієнту складніше автоматично зрозуміти, що робити далі, а вам — складніше налагоджувати.
Помилка №4: відповідати 500 на неправильний метод.
Неправильний метод — це помилка запиту клієнта, а не «сервер зламався». 500 залишаємо для ситуацій, коли сервер справді не зміг обробити коректний запит: помилка бази, panic, баги логіки. Метод PATCH замість POST — не баг сервера, а неправильне використання API.
Помилка №5: реєструвати маршрути не там і запускати сервер з іншим mux.
Іноді маршрути вішають на один ServeMux, а ListenAndServe запускають з іншим або взагалі з nil, що означає DefaultServeMux. Підсумок виглядає як «увесь час 404». Ліки прості: тримайте один mux := http.NewServeMux() і передавайте саме його серверу, як у прикладах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ