Wzorce obsługi błędów w API w Go, które standaryzują typowane błędy, mapowanie na kody HTTP, identyfikatory żądań i bezpieczne komunikaty bez ujawniania szczegółów wewnętrznych.

Gdy każdy endpoint raportuje błędy inaczej, klienci przestają ufać Twojemu API. Jeden endpoint zwraca { "error": "not found" }, inny { "message": "missing" }, a jeszcze inny wysyła zwykły tekst. Nawet jeśli znaczenie jest podobne, kod klienta musi teraz zgadywać, co się stało.
Koszty pojawiają się szybko. Zespoły tworzą kruche logiki parsowania i dorzucają wyjątki dla każdego endpointu. Ponawianie żądań staje się ryzykowne, bo klient nie wie, czy „spróbuj ponownie później”, czy „Twoje dane są błędne”. Liczba zgłoszeń do wsparcia rośnie, bo klient widzi niejasny komunikat, a Twój zespół nie może łatwo dopasować go do linii logów po stronie serwera.
Typowy scenariusz: aplikacja mobilna wywołuje trzy endpointy podczas rejestracji. Pierwszy zwraca HTTP 400 z mapą błędów pól, drugi zwraca HTTP 500 ze stringiem śladu stosu, a trzeci zwraca HTTP 200 z { "ok": false }. Zespół aplikacji wypuszcza trzy różne handlery błędów, a Twój backend nadal dostaje raporty typu „rejestracja czasem nie działa” bez jasnego punktu startu.
Cel jest prosty: jedna przewidywalna umowa. Klienci powinni móc niezawodnie odczytać, co się stało, czy to ich wina, czy Twoja, czy warto spróbować ponownie, i mieć identyfikator żądania, który mogą wkleić do zgłoszenia.
Uwaga: dotyczy to JSON-owych API HTTP (nie gRPC), ale te same idee mają zastosowanie wszędzie tam, gdzie zwracasz błędy do innych systemów.
Wybierz jedną jasną umowę dla błędów i spraw, by każdy endpoint jej przestrzegał. „Spójne” oznacza ten sam kształt JSON, to samo znaczenie pól i takie samo zachowanie niezależnie od tego, który handler zawodzi. Gdy to zrobisz, klienci przestaną zgadywać i zaczną obsługiwać błędy poprawnie.
Użyteczna umowa pomaga klientom zdecydować, co robić dalej. Dla większości aplikacji każda odpowiedź z błędem powinna odpowiedzieć na trzy pytania:
Praktyczny zestaw zasad:
Zdecyduj z góry, co nigdy nie powinno się pojawić w odpowiedziach. Do typowych „nigdy” należą fragmenty SQL, ślady stosu, wewnętrzne nazwy hostów, sekrety i surowe stringi błędów z zależności.
Utrzymuj wyraźny podział: krótki komunikat dla użytkownika (bezpieczny, grzeczny, wskazujący sposób działania) oraz szczegóły wewnętrzne (pełny błąd, stack, kontekst) przechowywane w logach. Na przykład „Nie udało się zapisać zmian. Spróbuj ponownie.” jest bezpieczne. „pq: duplicate key value violates unique constraint users_email_key” nie jest.
Gdy każdy endpoint przestrzega tej samej umowy, klienci mogą zbudować jeden handler błędów i używać go wszędzie.
Klienci mogą obsługiwać błędy czysto tylko wtedy, gdy każdy endpoint odpowiada w tym samym kształcie. Wybierz jedną otoczkę JSON i utrzymuj ją stabilną.
Praktycznym domyślnym wyborem jest obiekt error plus top-level request_id:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
HTTP status daje szeroką kategorię (400, 401, 409, 500). Maszynowo czytelne error.code daje konkretny przypadek, po którym klient może się rozgałęziać. To rozdzielenie ma znaczenie, bo wiele różnych problemów dzieli ten sam status. Aplikacja mobilna może pokazać inną UI dla EMAIL_TAKEN vs WEAK_PASSWORD, nawet jeśli oba są 400.
Trzymaj error.message bezpieczny i przyjazny. Powinien pomóc użytkownikowi naprawić problem, ale nigdy nie ujawniać wnętrza (SQL, ślady stosu, nazwy providerów, ścieżki plików).
Pola opcjonalne są przydatne, jeśli pozostają przewidywalne:
details.fields jako mapa pola -> komunikat.details.retry_after_seconds.details.docs_hint jako zwykły tekst (nie URL).Dla kompatybilności wstecznej traktuj wartości error.code jako część kontraktu API. Dodawaj nowe kody bez zmiany starych znaczeń. Dodawaj tylko pola opcjonalne i zakładaj, że klienci zignorują nieznane im pola.
Obsługa błędów robi się chaotyczna, gdy każdy handler wymyśla własny sposób sygnalizowania niepowodzenia. Mały zestaw typowanych błędów to naprawia: handlery zwracają znane typy błędów, a jedna warstwa odpowiedzialna za odpowiedzi zamienia je na spójne odpowiedzi.
Praktyczny zestaw startowy obejmuje większość endpointów:
Klucz to stabilność na najwyższym poziomie, nawet jeśli pierwotna przyczyna się zmienia. Możesz owijać niższe błędy (SQL, sieć, parsowanie JSON), jednocześnie zwracając ten sam publiczny typ, który middleware potrafi wykryć.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
W handlerze zwróć NotFoundError{Resource: "user", ID: id, Err: err} zamiast bezpośrednio ujawniać sql.ErrNoRows.
Do sprawdzania błędów preferuj errors.As dla typów niestandardowych i errors.Is dla sentinel errors. Sentinel errors (np. var ErrUnauthorized = errors.New("unauthorized")) działają w prostych przypadkach, ale typy niestandardowe wygrywają, gdy potrzebujesz bezpiecznego kontekstu (np. którego zasobu nie znaleziono) bez zmiany publicznego kontraktu odpowiedzi.
Bądź rygorystyczny co do tego, co dołączasz:
Err, informacje o stacku, surowe błędy SQL, tokeny, dane użytkownika.Ten podział pozwala pomagać klientom bez ujawniania wnętrza.
Gdy masz już typowane błędy, następne zadanie jest nudne, ale niezbędne: ten sam typ błędu powinien zawsze generować ten sam HTTP status. Klienci zbudują wokół tego logikę.
Praktyczne mapowanie, które pasuje do większości API:
| Typ błędu (przykład) | Status | Kiedy używać |
|---|---|---|
| BadRequest (uszkodzony JSON, brak wymaganych parametrów query) | 400 | Żądanie jest na poziomie protokołu lub formatu niepoprawne. |
| Unauthenticated (brak/nieprawidłowy token) | 401 | Klient musi się uwierzytelnić. |
| Forbidden (brak uprawnień) | 403 | Auth jest poprawny, ale dostęp zabroniony. |
| NotFound (ID zasobu nie istnieje) | 404 | Żądany zasób nie istnieje (lub decydujesz się ukryć istnienie). |
| Conflict (naruszenie unikalności, rozbieżność wersji) | 409 | Żądanie poprawne, ale koliduje z aktualnym stanem. |
| ValidationFailed (reguły pola) | 422 | Kształt JSON jest OK, ale biznesowa walidacja zawiodła. |
| RateLimited | 429 | Zbyt wiele żądań w oknie czasowym. |
| Internal (nieoczekiwany błąd) | 500 | Bug lub nieprzewidziane niepowodzenie. |
| Unavailable (zależność padła, timeout, konserwacja) | 503 | Tymczasowy problem po stronie serwera. |
Dwie rozróżnienia zapobiegają dużo zamieszania:
Wskazówki co do ponawiania:
Request ID to krótka, unikalna wartość identyfikująca jedno wywołanie API end-to-end. Jeśli klienci widzą go w każdej odpowiedzi, wsparcie staje się proste: „Prześlij mi request ID” często wystarcza, by znaleźć dokładne logi i konkretną awarię.
Ten zwyczaj opłaca się zarówno dla odpowiedzi sukcesu, jak i błędów.
Stosuj prostą regułę: jeśli klient wysyła identyfikator żądania, zachowaj go. Jeśli nie, stwórz nowy.
X-Request-Id).Umieść request ID w trzech miejscach:
request_id w standardowym schemacie)Dla endpointów batchowych lub zadań backgroundowych trzymaj nadrzędny request ID. Przykład: klient przesyła 200 wierszy, 12 nie przechodzi walidacji i enqueujesz pracę. Zwróć jeden request_id dla całego wywołania i dołącz parent_request_id do każdej pracy i do każdego błędu per-item. Dzięki temu możesz odtworzyć „jedno upload” nawet gdy rozbija się ono na wiele zadań.
Klienci potrzebują jasnej, stabilnej odpowiedzi o błędzie. Twoje logi potrzebują brudnej prawdy. Trzymaj te dwa światy rozdzielone: zwróć klientowi bezpieczny komunikat i publiczny kod błędu, a w logach zapisz wewnętrzną przyczynę, stack i kontekst serwera.
Loguj jeden zdarzenie strukturalne dla każdej odpowiedzi z błędem, możliwe do wyszukania po request_id.
Pola warte zachowania w spójny sposób:
Przechowuj szczegóły wewnętrzne tylko w logach serwera (lub w wewnętrznym magazynie błędów). Klient nigdy nie powinien widzieć surowych błędów bazy danych, zapytań SQL, śladów stosu ani komunikatów providerów. Jeśli masz wiele usług, pole wewnętrzne takie jak source (api, db, auth, upstream) może przyspieszyć triage.
Obserwuj hałaśliwe endpointy i błędy rate-limited. Jeśli endpoint może generować ten sam 429 lub 400 tysiące razy na minutę, unikaj spamowania logów: próbkuj powtarzające się zdarzenia albo obniżaj poziom severności dla oczekiwanych błędów, jednocześnie zliczając je w metrykach.
Metryki wykrywają problemy wcześniej niż logi. Śledź liczniki pogrupowane po HTTP status i kodzie błędu i ustaw alerty na nagły wzrost. Jeśli RATE_LIMITED wzrośnie 10x po deployu, zobaczysz to szybko, nawet gdy logi są próbkowane.
Najprostszy sposób na osiągnięcie spójności błędów to przestać obsługiwać je „wszędzie” i przepuszczać przez mały pipeline. Ten pipeline decyduje, co klient zobaczy, a co trafi do logów.
Zacznij od małego zestawu kodów błędów, na których klienci mogą polegać (np.: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Owiń je w typowany błąd, który ujawnia tylko bezpieczne, publiczne pola (code, safe message, opcjonalne details jak które pole jest niepoprawne). Trzymaj prywatne przyczyny ukryte.
Następnie zaimplementuj jedną funkcję translatora, która zamienia dowolny błąd w (statusCode, responseBody). To tutaj typowane błędy mapowane są na kody HTTP, a nieznane błędy stają się bezpieczną odpowiedzią 500.
Dalej dodaj middleware, które:
request_idPanic nie powinien nigdy wypluć śladu stosu do klienta. Zwróć normalną odpowiedź 500 z generickim komunikatem i zaloguj pełny panic z tym samym request_id.
Na koniec zmień handlery, aby zwracały error zamiast bezpośrednio pisać odpowiedź. Jeden wrapper może wywołać handler, uruchomić translator i zapisać JSON w standardowym formacie.
Krótka lista kontrolna:
Golden tests są ważne, bo zamykają kontrakt. Jeśli ktoś później zmieni komunikat lub kod statusu, testy padną zanim klienci zostaną zaskoczeni.
Wyobraź sobie endpoint tworzący rekord klienta.
POST /v1/customers z JSON jak { "email": "[email protected]", "name": "Pat" }. Serwer zawsze zwraca ten sam kształt błędu i zawsze zawiera request_id.
Brakuje e-maila lub jest źle sformatowany. Klient może oznaczyć pole.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
E-mail już istnieje. Klient może zasugerować logowanie albo wybór innego e-maila.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
Zależność jest niedostępna. Klient może spróbować ponownie z backoffem i wyświetlić spokojny komunikat.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
Z jedną umową klient reaguje konsekwentnie:
details.fieldsrequest_id jako identyfikator do wsparciaDla wsparcia ten sam request_id to najszybsza droga do prawdziwej przyczyny w logach, bez ujawniania śladów stosu czy błędów bazy.
Najszybszy sposób, by zirytować klientów API, to zmuszać ich do zgadywania. Jeśli jeden endpoint zwraca { "error": "..." }, a inny { "message": "..." }, każdy klient zamienia się w stos wyjątków, a błędy chowają się tygodniami.
Kilka częstych błędów:
code, po którym klient może kluczyć.request_id tylko przy błędach, więc nie da się skorelować zgłoszenia użytkownika z wcześniejszym udanym wywołaniem.Ujawnianie wnętrza to najłatwiejsza pułapka. Handler zwraca err.Error() bo tak wygodnie, a potem nazwa constraintu lub komunikat zewnętrznej biblioteki trafia do produkcji. Trzymaj komunikat dla klienta bezpieczny i krótki, a szczegółową przyczynę w logach.
Poleganie wyłącznie na tekście to powolne gnicie. Jeśli klient musi parsować po angielsku zdania typu „email already exists”, nie możesz zmieniać brzmienia bez łamania logiki. Stabilne kody błędów pozwalają zmieniać i tłumaczyć komunikaty, zachowując niezmienną logikę.
Traktuj kody błędów jako część kontraktu publicznego. Jeśli musisz zmienić kod, dodaj nowy i utrzymaj stary przez jakiś czas, nawet jeśli oba mapują do tego samego HTTP status.
Na koniec: dołączaj to samo pole request_id do każdej odpowiedzi, sukcesu i błędu. Gdy użytkownik mówi „działało, potem przestało”, ten jeden ID często oszczędza godzinę zgadywania.
Przed release zrób szybki przegląd spójności:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Dodaj testy, by handlery nie mogły zwracać nieznanych kodów przez przypadek.request_id i loguj go dla każdego żądania, włącznie z panic i timeoutami.Po tym ręcznie sprawdź kilka endpointów: wywołaj błąd walidacji, brak rekordu i nieoczekiwany błąd. Jeśli odpowiedzi różnią się między endpointami (zmieniają się pola, statusy dryfują, komunikaty wyciekają), napraw wspólny pipeline zanim dodasz więcej funkcji.
Praktyczna zasada: jeśli komunikat mógłby pomóc atakującemu lub zmylić zwykłego użytkownika, należy go trzymać w logach, nie w odpowiedzi.
Spisz kontrakt błędów, którego chcesz, by każdy endpoint przestrzegał, nawet jeśli Twoje API jest już live. Wspólny kontrakt (status, stabilny kod błędu, bezpieczny komunikat i request_id) to najszybszy sposób, by uczynić błędy przewidywalnymi dla klientów.
Następnie migracja stopniowa. Zachowaj istniejące handlery, ale przekieruj ich błędy przez jednego mappera, który zamienia wewnętrzne błędy w publiczny kształt odpowiedzi. To poprawia spójność bez ryzykownego „big rewrite” i zapobiega nowym endpointom wymyślania formatów.
Prowadź mały katalog kodów błędów i traktuj go jak część API. Gdy ktoś chce dodać nowy kod, rób krótki przegląd: czy to naprawdę nowy przypadek, czy nazwa jest jasna i czy mapuje do właściwego statusu?
Dodaj kilka testów, które wykryją dryf:
request_id.error.code jest obecny i pochodzi z katalogu.error.message pozostaje bezpieczny i nigdy nie zawiera wewnętrznych szczegółów.Jeśli budujesz backend w Go od zera, warto zablokować kontrakt wcześnie. Na przykład, Koder.ai (koder.ai) zawiera tryb planowania, w którym możesz zdefiniować konwencje jak schema błędów i katalog kodów na początku, a potem utrzymać zgodność handlerów w miarę rozwoju API.
Użyj jednego kształtu JSON dla każdej odpowiedzi z błędem, w całym API. Praktycznym domyślnym wyborem jest top-level request_id plus obiekt error z polami code, message i opcjonalnym details, aby klienci mogli niezawodnie parsować i reagować.
Zwracaj error.message jako krótkie, bezpieczne dla użytkownika zdanie i trzymaj prawdziwą przyczynę w logach serwera. Nie zwracaj surowych błędów bazy danych, śladów stosu, wewnętrznych hostów ani komunikatów zależności, nawet jeśli podczas developmentu wydają się pomocne.
Użyj stabilnego error.code do logiki maszynowej, a pozwól kodowi HTTP opisać szeroką kategorię. Klienci powinni rozgałęziać się po error.code (np. ALREADY_EXISTS), a status traktować jako wskazówkę (np. 409 oznacza konflikt stanu).
Użyj 400, gdy żądanie nie da się niezawodnie sparsować lub zinterpretować (uszkodzony JSON, złe typy). Użyj 422, gdy żądanie jest poprawne składniowo, ale narusza reguły biznesowe (niepoprawny format e-mail, za krótkie hasło).
Użyj 409, gdy wejście jest poprawne, ale nie można go zastosować z powodu konfliktu stanu (adres e-mail już zajęty, rozbieżność wersji). Użyj 422 dla walidacji na poziomie pola, gdzie zmiana wartości naprawia problem bez zmiany stanu serwera.
Stwórz mały zestaw typowanych błędów (walidacja, nie znaleziono, konflikt, nieautoryzowany, wewnętrzny) i pozwól handlerom je zwracać. Następnie użyj jednego wspólnego translatora, który mapuje te typy na kody statusu i standardowy kształt odpowiedzi JSON.
Zawsze zwracaj request_id w każdej odpowiedzi, sukcesie lub błędzie, i loguj go w każdej linii loga serwera. Gdy klient zgłasza problem, ten identyfikator powinien wystarczyć, by znaleźć dokładną ścieżkę błędu w logach.
Zwracaj 200 tylko wtedy, gdy operacja się powiodła; używaj 4xx/5xx dla błędów. Ukrywanie błędów za 200 zmusza klientów do parsowania pól w ciele odpowiedzi i tworzy niespójne zachowanie między endpointami.
Domyślnie nie ponawiaj żądań dla 400, 401, 403, 404, 409 i 422, bo bez zmian żądania ponowne próby nie pomogą. Pozwól na ponawianie dla 503 i czasami 429 po odczekaniu; jeśli wspierasz klucze idempotentności, ponawianie dla POST przy błędach przejściowych staje się bezpieczniejsze.
Zamknij kontrakt kilkoma „golden” testami, które sprawdzają status, error.code i obecność request_id. Dodawaj nowe kody błędów bez zmiany starych znaczeń i tylko dodawaj pola opcjonalne, aby starsi klienci dalej działali.