Architektura obsługi błędów w Go: granice i wzorce
Obsługuj błędy na odpowiedniej granicy.
Obsługa błędów w Go jest łatwa do skrytykowania. Każdy programista Go napisał ten kod setki razy:
if err != nil {
return err
}
To nie jest ta ciekawa część. Ciekawą częścią jest to, co błąd oznacza, gdzie powinien być obsłużony, gdzie powinien zostać zawinięty (wrapped), gdzie powinien zostać przetłumaczony (na inny błąd domenowy), gdzie powinien zostać zalogowany i co powinno zostać ujawnione wywołującemu — to jest pytanie architektoniczne.
Go traktuje błędy jako wartości. Dzięki temu niepowodzenia są jawne. Oznacza to również, że Twoja baza kodu potrzebuje jasnego projektu obsługi błędów. Bez niego błędy stają się losowymi stringami, obsłużniki HTTP ujawniają szczegóły bazy danych, logi duplikują ten sam błąd pięć razy, ponowne próby (retries) mają miejsce z niewłaściwych powodów, a wywołujący badają tekst zamiast zachowania.

Ten artykuł nie jest wprowadzeniem dla początkujących do if err != nil.
Jest to praktyczny przewodnik po architekturze obsługi błędów w Go: zawijanie (wrapping), błędy sentineli, niestandardowe typy błędów, errors.Is, errors.As, granice błędów, mapowanie do API, logowanie, ponowne próby (retries), bezpieczeństwo i wzorce produkcyjne.
Słabo opiniowana wersja: nie próbuj sprawić, by błędy w Go zniknęły. Spraw, by były znaczące w odpowiednim miejscu granicznym (boundary).
Czym są błędy w Go
W Go błąd to po prostu wartość implementująca ten interfejs:
type error interface {
Error() string
}
Ten mały interfejs jest powodem, dla którego obsługa błędów w Go wydaje się tak bezpośrednia.
Funkcje zwracają błędy jawnie:
func LoadUser(id string) (*User, error) {
// ...
}
Wywołujący decydują, co zrobić:
user, err := LoadUser(id)
if err != nil {
return nil, err
}
Nie ma wyjątków ani ukrytego rozwijania stosu (stack unwinding). Niepowodzenie jest częścią sygnatury funkcji.
To jest dobre, ale oznacza również, że błędy potrzebują projektu. Jeśli każdy pakiet zwraca dowolne komunikaty, wywołujący nie mogą podejmować niezawodnych decyzji. Jeśli każda warstwa zawija każdy błąd bez dyscypliny, operatorzy otrzymują głośne komunikaty, a deweloperzy zdezorientowane łańcuchy. Jeśli żadna warstwa nie zawija błędów, niepowodzenia tracą kontekst.
Celem jest mniej obsługi błędów, ale lepsze znaczenie błędów.
Trzy zadania błędu
Pożyteczny błąd ma zwykle jedno lub więcej zadań.
Zadanie 1: Wyjaśnić, co się nie udało
Dla ludzi błąd powinien wyjaśniać, jaka operacja się nie powiodła.
Przykład:
return fmt.Errorf("load user %s: %w", id, err)
Daje to kontekst. Mówi, że niepowodzenie nastąpiło podczas ładowania użytkownika.
Zadanie 2: Zachować przyczynę
Dla kodu błąd powinien zachowywać podstawową przyczynę, gdy ta przyczyna ma znaczenie.
Przykład:
return fmt.Errorf("load user %s: %w", id, err)
%w zawija oryginalny błąd, dzięki czemu wywołujący mogą go zbadać za pomocą errors.Is lub errors.As. Zawijanie ma jedną nową pułapkę w Go 1.27: kontrola vet za go test ostrzega teraz o fmt.Errorf("...: %w", p), gdy p jest wskaźnikiem do typu, który sam implementuje error, ponieważ dodatkowa pośredniość (indirection) psuje asercje typów i dopasowywanie errors.Is. Przewodnik po aktualizacji do Go 1.27 omawia to oraz inne wyniki kontroli vet, które pojawiają się po aktualizacji narzędzi.
Zadanie 3: Pozwolić granicy podjąć decyzję
Na pewnej granicy program musi zdecydować, co zrobić.
Przykłady:
- Zwrócić HTTP 404
- Zwrócić HTTP 409
- Wykonać ponowną próbę operacji
- Zalogować na poziomie ostrzeżenia
- Wyświetlić bezpieczne dla użytkownika komunikat
- Przerwać transakcję
- Wysłać błąd do monitoringu
- Ignorować anulowanie
Ta decyzja powinna zwykle opierać się na tożsamości lub typie błędu, a nie na dopasowywaniu stringów.
Główne narzędzia do obsługi błędów we współczesnym Go
Współczesny Go daje Ci mały, ale potężny zestaw narzędzi.
errors.New
Używaj errors.New, aby utworzyć prostą wartość błędu:
var ErrNotFound = errors.New("not found")
Jest to przydatne dla błędów sentineli.
fmt.Errorf z %w
Używaj fmt.Errorf z %w, aby zawinąć błąd:
return fmt.Errorf("query user: %w", err)
Zawijanie dodaje kontekst, zachowując oryginalny błąd do badania.
errors.Is
Używaj errors.Is, aby sprawdzić, czy błąd pasuje do określonego celu gdziekolwiek w jego łańcuchu:
if errors.Is(err, ErrNotFound) {
// handle not found
}
Używaj tego dla błędów sentineli i znanych warunków.
errors.As
Używaj errors.As, aby wyodrębnić określony typ błędu z łańcucha:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// use validationErr.Field or validationErr.Reason
}
Używaj tego, gdy błąd niesie dane strukturalne.
errors.Join
Używaj errors.Join, gdy wystąpiło wiele błędów i wszystkie powinny zostać zachowane:
return errors.Join(closeErr, flushErr)
Złączone błędy można nadal badać za pomocą errors.Is i errors.As.
Używaj tego ostrożnie. Złączony błąd oznacza, że wiele niepowodzeń jest częścią jednego rezultatu.
Błędy sentinel
Błąd sentinel to wartość błędu na poziomie pakietu, która reprezentuje znany warunek.
Przykład:
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
Błędy sentineli są przydatne, gdy wywołujący musi wiedzieć tylko, jaka kategoria niepowodzenia wystąpiła.
Przykład:
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.queryUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user: %w", err)
}
return user, nil
}
Następnie serwis lub obsłużnik może sprawdzić:
if errors.Is(err, ErrUserNotFound) {
// return 404
}
Kiedy używać błędów sentineli
Używaj błędów sentineli, gdy:
- Warunek jest stabilny.
- Wywołujący musi rozgałęzić się na tej podstawie.
- Nie są potrzebne dodatkowe dane strukturalne.
- Błąd należy do Twojego pakietu lub domeny.
Dobre przykłady:
var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")
Kiedy nie używać błędów sentineli
Nie twórz sentineli dla każdego możliwego niepowodzenia.
Źle:
var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")
Jeśli wywołujący nie rozgałęziają się na tych błędach, mogą być to tylko komunikaty.
Bądź też ostrożny z eksportowaniem zbyt wielu sentineli. Zaeksportowane błędy sentineli stają się częścią API Twojego pakietu.
Niestandardowe typy błędów
Niestandardowy typ błędu jest przydatny, gdy błąd niesie informacje strukturalne.
Przykład:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}
Wywołujący:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
Jest to lepsze niż parsowanie stringa błędu.
Kiedy używać niestandardowych typów błędów
Używaj niestandardowych typów błędów, gdy:
- Wywołujący potrzebują danych strukturalnych.
- Błąd ma znaczące pola.
- Typ jest częścią kontraktu Twojego pakietu.
- Wywołujący może potrzebować obsługiwać różne wartości w inny sposób.
Przykłady:
- Błąd walidacji z nazwą pola
- Błąd limitu żądań z czasem ponowienia
- Błąd HTTP z kodem statusu
- Błąd parsowania z wierszem i kolumną
- Błąd domenowy z ID zasobu
Kiedy nie używać niestandardowych typów błędów
Nie twórz niestandardowych typów tylko po to, by uniknąć errors.New.
To jest zbędne:
type NotFoundError struct{}
func (e NotFoundError) Error() string {
return "not found"
}
Jeśli nie ma przydatnych danych, sentinel często wystarczy.
Zawijanie błędów (Wrapping)
Zawijanie dodaje kontekst do błędu, zachowując oryginalny błąd.
Przykład:
func LoadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if err := parseConfig(data); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}
Jeśli os.ReadFile nie powiedzie się, wywołujący otrzymuje oba:
- wysokopoziomową operację: odczyt konfiguracji
- niskopoziomową przyczynę: brak uprawnień, plik nie znaleziony itp.
Oba są dostępne przez łańcuch błędów, co sprawia, że konsekwentne zawijanie za pomocą %w ma sens.
Zawijaj z przydatnym kontekstem
Dobre zawijanie mówi, jaka operacja się nie powiodła:
return fmt.Errorf("create invoice %s: %w", invoiceID, err)
Źle zawijanie dodaje szum:
return fmt.Errorf("error: %w", err)
To nie mówi wywołującemu nic.
Unikaj również powtarzania tego samego rzeczownika na każdej warstwie:
return fmt.Errorf("user service: get user: user repository: query user: %w", err)
Tego rodzaju łańcuch jest technicznie poprawny, ale praktycznie irytujący.
Zawijaj tam, gdzie kontekst zmienia znaczenie. Jeśli nie potrafisz wyjaśnić jednym zdaniem, jaka operacja się nie powiodła, prawdopodobnie zawijasz zbyt agresywnie lub za mało.
Kiedy zawijać, a kiedy nie
To jest jedna z najważniejszych decyzji architektonicznych.
Zawijaj, gdy przekraczasz znaczącą granicę
Zawijaj, gdy błąd przesuwa się z jednej operacji do operacji wyższego poziomu.
Przykład:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
Błąd repozytorium jest teraz częścią operacji serwisowej, a dodany kontekst jest przydatny, gdy operatorzy śledzą niepowodzenie w logach.
Nie zawijaj tylko po to, aby powiedzieć “nie powiodło się”
Źle:
if err != nil {
return fmt.Errorf("failed: %w", err)
}
Słowo “failed” (nie powiodło się) jest zwykle implikowane przez sam fakt istnienia błędu.
Nie zawijaj, jeśli przetłumaczujesz
Czasami należy przetłumaczyć jeden błąd na inny błąd domenowy.
Przykład:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
To celowo ukrywa szczegóły bazy danych i ujawnia warunek domenowy.
Wciąż można zachować przyczynę, jeśli to przydatne, ale rób to świadomie.
Nie ujawniaj przypadkowo szczegółów implementacji
Jeśli zawijasz niskopoziomowy błąd za pomocą %w, wywołujący mogą go zbadać.
Wewnątrz Twojej aplikacji to zwykle dobre.
Ale w publicznym API pakietu zawijanie może ujawnić szczegóły implementacji jako część Twojego kontraktu.
Na przykład, jeśli Twój pakiet zawija sql.ErrNoRows, wywołujący mogą zacząć zalegać na nim:
if errors.Is(err, sql.ErrNoRows) {
// caller now knows you use database/sql
}
Jeśli możesz później zmienić magazyn, preferuj sentinel domenowy:
var ErrUserNotFound = errors.New("user not found")
Następnie zwróć to z granicy pakietu.
Granice błędów (Error boundaries)
Najbardziej przydatny sposób myślenia o obsłudze błędów w Go to granice.
Granica to miejsce, w którym błąd zmienia znaczenie lub grupę odbiorców.
Wspólne granice obejmują:
- baza danych do repozytorium
- repozytorium do serwisu
- serwis do obsłużnika HTTP
- serwis do komendy CLI
- błąd wewnętrzny do komunikatu skierowanego do użytkownika
- tymczasowe niepowodzenie do decyzji o ponownej próbie
- niepowodzenie operacji do zdarzenia logu
- błąd domenowy do odpowiedzi API
Architektura błędów to w dużej mierze projektowanie granic. Każda granica to punkt decyzyjny, w którym błędy zyskują kontekst, tracą szczegóły implementacji lub są tłumaczone na formę, na którą następna warstwa może zareagować.
Granica repozytorium
Repozytorium komunikuje się z magazynem danych.
Powinno zwykle tłumaczyć błędy specyficzne dla bazy danych na błędy domenowe.
Przykład:
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Repozytorium ukrywa sql.ErrNoRows i ujawnia ErrUserNotFound — czysta granica, co oznacza, że serwis nie musi wiedzieć nic o tym, jak magazyn reprezentuje „nie znaleziono”.
Granica serwisu
Serwis posiada znaczenie biznesowe.
Powinien zwykle dodawać kontekst operacji i zachowywać błędy domenowe.
Przykład:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
Zachowuje to warunek domenowy, dodając kontekst dla nieoczekiwanych błędów.
Dla bardziej złożonych reguł biznesowych serwis może tworzyć błędy domenowe bezpośrednio:
var ErrAccountDisabled = errors.New("account disabled")
func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user by email: %w", err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
// ...
return session, nil
}
Serwis to odpowiednie miejsce dla błędów biznesowych — tworzonych bezpośrednio z logiki domenowej, a nie tłumaczonych z warunków infrastrukturalnych.
Granica obsłużnika HTTP
Obsłużnik HTTP tłumaczy błędy aplikacji na odpowiedzi HTTP.
Jest to granica, gdzie wewnętrzne szczegóły powinny stać się bezpiecznymi dla użytkownika odpowiedziami.
Frameworki webowe różnią się w tym, jak strukturują to tłumaczenie. Echo kieruje błędy obsłużników przez pojedynczy HTTPErrorHandler, podczas gdy obsłużniki Gin renderują błędy bezpośrednio na kontekście żądania — kompromisy między tymi dwoma stylami porównano w Frameworki Web Go w 2026: net/http, chi, Gin, Echo, Fiber porównanie.
Przykład:
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil {
writeHTTPError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Mapowanie błędów:
func writeHTTPError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, ErrAccountDisabled):
http.Error(w, "account disabled", http.StatusForbidden)
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
Obsłużnik mapuje błędy domenowe na semantykę HTTP, zamiast ujawniać surowe szczegóły bazy danych lub błędy wewnętrzne. To jest miejsce, w którym wiele aplikacji Go popełnia błędy — albo ujawniają zbyt dużo szczegółów wewnętrznych, albo spłaszczają wszystkie błędy do HTTP 500. W celu uzyskania kompletnego obrazu wzorców obsłużników i middleware w API Go, Budowanie API REST w Go omawia uwierzytelnianie, routing i obsługę błędów w standardowej bibliotece, Gin, Echo i Fiber.
Granica CLI
CLI ma inną granicę niż API HTTP.
W CLI błąd powinien być przydatny dla osoby wykonującej komendę.
Przykład:
func RunImport(ctx context.Context, args []string) error {
if len(args) == 0 {
return ErrMissingInputFile
}
if err := importFile(ctx, args[0]); err != nil {
return fmt.Errorf("import %s: %w", args[0], err)
}
return nil
}
Na granicy komendy:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, formatCLIError(err))
os.Exit(exitCode(err))
}
}
Mapuj znane błędy na kody wyjścia:
func exitCode(err error) int {
switch {
case errors.Is(err, ErrMissingInputFile):
return 2
case errors.Is(err, ErrValidation):
return 3
default:
return 1
}
}
CLI może często pokazywać więcej szczegółów niż publiczne API, ale powinno nadal unikać wycieku sekretów.
Wzorzec typu błędu API
Dla API HTTP mały typ błędu na poziomie aplikacji może być przydatny.
Przykład:
type APIError struct {
Status int
Code string
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err == nil {
return e.Message
}
return e.Message + ": " + e.Err.Error()
}
func (e *APIError) Unwrap() error {
return e.Err
}
Konstruktor:
func NewAPIError(status int, code string, message string, err error) *APIError {
return &APIError{
Status: status,
Code: code,
Message: message,
Err: err,
}
}
Zastosowanie:
return NewAPIError(
http.StatusConflict,
"duplicate_email",
"email is already registered",
ErrDuplicateEmail,
)
Obsłużnik:
func writeAPIError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
writeJSON(w, apiErr.Status, map[string]string{
"code": apiErr.Code,
"message": apiErr.Message,
})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
Ten wzorzec jest przydatny, gdy chcesz mieć strukturalne błędy API ze stabilnymi kodami.
Używaj go na granicy API. Nie wymuszaj, aby każdy wewnętrzny pakiet zwracał błędy specyficzne dla API.
Błędy domenowe vs błędy transportowe
Trzymaj błędy domenowe oddzielone od błędów transportowych.
Błąd domenowy:
var ErrInsufficientBalance = errors.New("insufficient balance")
Mapowanie transportowe:
if errors.Is(err, ErrInsufficientBalance) {
http.Error(w, "insufficient balance", http.StatusConflict)
return
}
Nie rób, aby Twoja warstwa domenowa zwracała kody statusu HTTP:
return &APIError{Status: http.StatusConflict}
To sprzęga logikę biznesową z HTTP i uniemożliwia Twojej warserwisowej działać bezbłędnie przez HTTP, CLI, workerów, testy i przyszłe adaptery gRPC. Mapowanie transportowe należy do granicy transportowej, a nie do kodu domenowego. W celu uzyskania wskazówek, gdzie definiować błędy domenowe, sentineli i adaptery transportowe wewnątrz struktury projektu, Struktura Projektu Go: Praktyki i Wzorce omawia konwencje internal/, pkg/ i adapterów, które utrzymują te warstwy czysto rozdzielone.
Błędy powtarzalne (Retryable errors)
Niektóre błędy powinny wyzwalać ponowną próbę. Inne nie.
Nie podejmuj tej decyzji na podstawie dopasowania stringów.
Używaj interfejsu znacznikowego lub jawnej funkcji.
Przykład:
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string {
return e.Err.Error()
}
func (e *RetryableError) Unwrap() error {
return e.Err
}
Funkcja pomocnicza:
func Retryable(err error) error {
if err == nil {
return nil
}
return &RetryableError{Err: err}
}
func IsRetryable(err error) bool {
var retryable *RetryableError
return errors.As(err, &retryable)
}
Zastosowanie:
if err := callRemoteAPI(ctx); err != nil {
if isTemporaryNetworkError(err) {
return Retryable(fmt.Errorf("call remote api: %w", err))
}
return fmt.Errorf("call remote api: %w", err)
}
Pętla ponawiania:
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// retry with backoff
}
return err
}
Jest to znacznie lepsze niż sprawdzanie, czy string błędu zawiera „timeout” — dopasowanie stringów cicho się psuje, gdy komunikaty się zmieniają, i tworzy niewidoczne sprzężenie między producentem a konsumentem.
Błędy walidacji
Błędy walidacji często potrzebują danych strukturalnych.
Przykład:
type FieldError struct {
Field string
Message string
}
type ValidationError struct {
Fields []FieldError
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Zastosowanie:
func ValidateCreateUser(req CreateUserRequest) error {
var fields []FieldError
if req.Email == "" {
fields = append(fields, FieldError{
Field: "email",
Message: "email is required",
})
}
if len(fields) > 0 {
return &ValidationError{Fields: fields}
}
return nil
}
Obsłużnik:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
writeJSON(w, http.StatusBadRequest, validationErr)
return
}
Jest to dobre zastosowanie errors.As, ponieważ wywołujący potrzebuje informacji strukturalnych — nazw pól i komunikatów walidacyjnych — a nie tylko nieprzezroczystego stringa błędu.
Wiele błędów
Czasami kilka rzeczy się nie udaje.
Przykłady:
- zamykanie wielu zasobów
- walidowanie wielu pól
- zamykanie kilku workerów
- uruchamianie niezależnych sprawdzeń
- wypłukiwanie i zamykanie wyjścia
Używaj errors.Join, gdy wszystkie błędy powinny zostać zachowane.
Przykład:
func CloseAll(closers ...io.Closer) error {
var errs []error
for _, closer := range closers {
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Wywołujący:
if err := CloseAll(a, b, c); err != nil {
return fmt.Errorf("close resources: %w", err)
}
Oba errors.Is i errors.As mogą badać złączone błędy, co oznacza, że złączone wartości błędów pozostają w pełni kompatybilne ze standardowymi wzorcami sprawdzania błędów.
Kiedy nie używać errors.Join
Nie używaj errors.Join, gdy jest jeden główny błąd i trochę kontekstu do logowania.
Nie używaj go, aby uniknąć decyzji, który błąd jest ważny.
Nie zwracaj ogromnych złączonych błędów użytkownikom.
Złączone błędy są przydatne, ale mogą szybko stać się głośne.
Panic nie jest obsługą błędów
W zwykłym kodzie aplikacji nie używaj panic dla oczekiwanych błędów.
Źle:
if err != nil {
panic(err)
}
Używaj panic dla błędów programisty lub sytuacji naprawdę nieodwracalnych.
Przykłady:
- naruszenie niemożliwego wewnętrznej niezmienności
- nieważna inicjalizacja pakietu
- niepowodzenie pomocnika testowego z
t.Fatallub panic w ograniczonych przypadkach - nieodwracalny błąd konfiguracji startowej, zależnie od stylu
Nie stosuj panic, bo zapytanie bazy danych się nie powiodło lub użytkownik przesłał nieważne dane wejściowe.
To są zwykłe błędy.
Logowanie błędów
Wspólnym błędem w Go jest logowanie tego samego błędu na każdej warstwie.
Źle:
func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.query(ctx, id)
if err != nil {
log.Printf("query failed: %v", err)
return nil, err
}
return user, nil
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
log.Printf("service failed: %v", err)
return nil, err
}
return user, nil
}
To tworzy duplikowane logi dla jednego niepowodzenia.
Lepiej:
- zawijaj błędy, gdy przesuwają się wyżej
- loguj raz na granicy, gdzie błąd jest obsługiwany
- włącz strukturalny kontekst do logu
Przykład:
func (s *Server) handleError(r *http.Request, err error) {
s.logger.ErrorContext(
r.Context(),
"request failed",
"method", r.Method,
"path", r.URL.Path,
"err", err,
)
}
Daje to jedno zdarzenie logu z pełnym łańcuchem błędów. W celu uzyskania produkcyjnego, strukturalnego ustawienia logowania, Strukturalne Logowanie w Go z slog omawia rekordy log/slog, obsłużniki JSON, korelację kontekstu i redagowanie — wszystko, co naturalnie łączy się z logowaniem błędów na poziomie granic.
Kiedy logować w niższych warstwach
Loguj w niższych warstwach tylko wtedy, gdy warstwa faktycznie obsługuje błąd lub dodaje ważny kontekst operacyjny, który nie będzie widoczny gdzie indziej.
Na przykład pętla ponawiania może logować każdą próbę ponowną na poziomie debug lub warning.
Ale repozytorium nie powinno logować każdego błędu zapytania, jeśli obsłużnik zaloguje ostateczne niepowodzenie żądania.
Błędy skierowane do użytkownika vs błędy dla operatorów
Nie pokazuj bezpośrednio użytkownikom błędów wewnętrznych.
Błąd wewnętrzny:
query user by id: dial tcp 10.0.4.12:5432: connection refused
Komunikat skierowany do użytkownika:
internal server error
Log operatora:
request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"
To są różne grupy odbiorców, a dobra architektura błędów trzyma je rozdzielone:
- wewnętrzny błąd diagnostyczny
- bezpieczna dla użytkownika odpowiedź
- stabilny kod błędu API
- kontekst logu operatora
Wymuszanie jednego stringa błędu, aby służył wszystkim tym grupom odbiorców, prowadzi do ryzyka ujawnienia danych lub koszmaru debugowania. Projektuj architekturę błędów wokół odrębnych wartości dla odrębnych konsumentów.
Bezpieczna obsługa błędów
Błędy mogą wyciekać wrażliwe informacje.
Unikaj ujawniania:
- łańcuchów połączeń bazy danych
- zapytań SQL z sekretami
- wewnętrznych nazw hostów
- ścieżek plików
- tokenów dostępu
- kluczy API
- wyobrażeń stosu (stack traces)
- prywatnych danych klientów
- szczegółów polityki autoryzacji
Jest to szczególnie ważne w API HTTP.
Źle:
http.Error(w, err.Error(), http.StatusInternalServerError)
Dobrze:
http.Error(w, "internal server error", http.StatusInternalServerError)
Loguj wewnętrzny błąd bezpiecznie dla operatorów. Zwracaj bezpieczny komunikat użytkownikowi.
Kody błędów
Dla publicznych API stabilne kody błędów są często lepsze niż poleganie wyłącznie na komunikatach.
Przykład odpowiedzi:
{
"code": "user_not_found",
"message": "user not found"
}
Komunikat może się zmieniać. Kod powinien być stabilny.
Używaj kodów błędów dla:
- zachowania klienta
- dokumentacji
- SDK
- lokalizacji
- diagnostyki wsparcia
Nie zmuszaj klientów do parsowania angielskich komunikatów błędów.
Praktyczny, warstwowy projekt obsługi błędów
Oto czysty wzorzec dla wielu serwisów backendowych w Go.
Warstwa repozytorium
- Komunikuje się z bazą danych lub zewnętrznym magazynem.
- Konwertuje specyficzne dla magazynu błędy „nie znaleziono” na błędy domenowe.
- Zawija nieoczekiwane błędy magazynu z kontekstem operacji.
- Nie zwraca błędów HTTP.
- Zwykle nie loguje.
Przykład:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
Warstwa serwisu
- Posiada reguły biznesowe.
- Tworzy błędy domenowe.
- Zachowuje znane błędy domenowe.
- Zawija nieoczekiwane błędy niższego poziomu.
- Nie zwraca kodów statusu HTTP.
- Zwykle nie loguje.
Przykład:
if user.Disabled {
return nil, ErrAccountDisabled
}
Warstwa transportu
- Mapuje błędy domenowe na odpowiedzi HTTP, gRPC lub CLI.
- Loguje nieobsłużone lub nieoczekiwane błędy.
- Ukrywa wewnętrzne szczegóły przed użytkownikami.
- Ustawia kody statusu i kody błędów API.
Przykład:
switch {
case errors.Is(err, ErrUserNotFound):
writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
To rozdzielenie utrzymuje obsługę błędów zrozumiałą i pozwala każdej warstwie ewoluować niezależnie — możesz zmienić technologię magazynu, nie dotykając logiki serwisu ani mapowania transportu. Warstwowy projekt działa najlepiej, gdy zależności są injektowane, a nie twarde kodowane; Iniekcja Zależności w Go: Wzorce i Najlepsze Praktyki omawia wzorce konstruktorów i interfejsów, które pozwalają każdemu punktowi granicznemu łatwo testować w izolacji.
Kompletny przykład
Oto mały przykład end-to-end.
Błędy domenowe:
package users
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("duplicate email")
ErrAccountDisabled = errors.New("account disabled")
)
Repozytorium:
package users
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name, disabled
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.Disabled,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Serwis:
package users
import (
"context"
"errors"
"fmt"
)
type Service struct {
repo *Repository
}
func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get profile for user %s: %w", id, err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
return &Profile{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}, nil
}
Obsłużnik HTTP:
package httpapi
import (
"context"
"errors"
"net/http"
"example.com/app/users"
)
type Handler struct {
users *users.Service
}
func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
if err != nil {
h.writeError(w, err)
return
}
writeJSON(w, http.StatusOK, profile)
}
func (h *Handler) writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, users.ErrUserNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{
"code": "user_not_found",
"message": "user not found",
})
case errors.Is(err, users.ErrAccountDisabled):
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "account_disabled",
"message": "account is disabled",
})
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusGatewayTimeout, map[string]string{
"code": "request_timeout",
"message": "request timed out",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
}
Ta struktura daje Ci:
- błędy domenowe
- tłumaczenie magazynu
- kontekst serwisu
- bezpieczne mapowanie HTTP
- badalne łańcuchy błędów
- brak dopasowania stringów
- brak wycieku transportu do kodu domenowego
To jest rodzaj architektury błędów, która się skaluje — wystarczająco prosta, aby nowy współpracownik mógł ją zrozumieć, a jednocześnie wystarczająco strukturalna, aby logika domenowa nigdy nie wyciekała do odpowiedzi transportowych.
Testowanie zachowania błędów
Zachowanie błędów powinno być testowane tak samo dokładnie, jak ścieżka sukcesu, ponieważ decyzje graniczne — mapowanie sentinel, wyodrębnianie typów, kody HTTP — to często miejsca, gdzie ukrywają się najdłużej błędy. W celu uzyskania pełnego przewodnika po strukturze testów Go, mockach i wzorcach pokrycia, zobacz Testowanie Jednostkowe Go: Struktura i Najlepsze Praktyki.
Testowanie mapowania sentineli
func TestGetByIDNotFound(t *testing.T) {
repo := newTestRepository(t)
_, err := repo.GetByID(t.Context(), "missing")
if !errors.Is(err, users.ErrUserNotFound) {
t.Fatalf("got %v, want ErrUserNotFound", err)
}
}
Testowanie wyodrębniania niestandardowych błędów
func TestValidationError(t *testing.T) {
err := ValidateCreateUser(CreateUserRequest{})
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("got %T, want ValidationError", err)
}
if len(validationErr.Fields) == 0 {
t.Fatal("expected validation fields")
}
}
Testowanie mapowania HTTP
func TestWriteErrorNotFound(t *testing.T) {
rec := httptest.NewRecorder()
writeHTTPError(rec, users.ErrUserNotFound)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
Testy powinny udowadniać, że znane błędy wywołują odpowiednie zachowanie na każdej granicy, aby refaktoryzacja warstw magazynu lub transportu nie mogła cicho zmienić kontraktu niepowodzeń.
Typowe antywzorce
Antywzorzec 1: Dopasowanie stringów
Źle:
if strings.Contains(err.Error(), "not found") {
// ...
}
Używaj zamiast tego errors.Is lub errors.As — oba automatycznie obsługują łańcuchy zawiniętych błędów i nie psują się, gdy komunikaty są przeformatowane lub zlokalizowane.
Antywzorzec 2: Tracenie przyczyny
Źle:
return errors.New("query failed")
Lepiej:
return fmt.Errorf("query user: %w", err)
Antywzorzec 3: Zawijanie bez znaczenia
Źle:
return fmt.Errorf("error happened: %w", err)
Zawijaj z kontekstem operacji, który wyjaśnia, co było próbowane, np. "create invoice %s: %w", zamiast mglistego prefiksu, który nie dodaje wartości diagnostycznej.
Antywzorzec 4: Logowanie na każdej warstwie
Źle:
log.Println(err)
return err
na każdym poziomie. Loguj raz tam, gdzie błąd jest ostatecznie obsługiwany, a nie na każdej pośredniej warstwie, która po prostu przekazuje go wyżej.
Antywzorzec 5: Zwracanie błędów HTTP z kodu domenowego
Źle:
return &APIError{Status: http.StatusNotFound}
z serwisu domenowego. Mapuj błędy domenowe na kody statusu HTTP i ciała odpowiedzi na granicy obsłużnika, utrzymując Twoją warstwę serwisową niezależną od kwestii transportu.
Antywzorzec 6: Ujawnianie wewnętrznych błędów użytkownikom
Źle:
http.Error(w, err.Error(), http.StatusInternalServerError)
Zwracaj bezpieczne, ogólne komunikaty użytkownikom i loguj pełny wewnętrzny błąd ze strukturalnym kontekstem dla operatorów. Nigdy nie ujawniaj łańcuchów połączeń bazy danych, ścieżek plików lub surowych wyobrażeń stosu w odpowiedziach API.
Antywzorzec 7: Za dużo zaeksportowanych sentineli
Zaeksportowane błędy są częścią API Twojego pakietu, a ich dodawanie zobowiązuje Cię do ich utrzymywania. Nie eksportuj każdego wewnętrznego warunku, o ile zewnętrzni wywołujący naprawdę nie muszą na nim rozgałęziać się — preferuj trzymanie sentineli niezaeksportowanych, dopóki nie będzie jasnej potrzeby.
Antywzorzec 8: Używanie panic dla oczekiwanych niepowodzeń
Źle:
panic(err)
dla zwykłych niepowodzeń w czasie wykonania. Zostaw panic dla naprawdę nieodwracalnych warunków lub błędów programisty, a nie dla brakujących rekordów lub nieważnych danych wejściowych użytkownika — w tych przypadkach zawsze zwracaj błędy.
Antywzorzec 9: Ignorowanie błędów kontekstu
Źle:
return fmt.Errorf("request failed")
gdy prawdziwą przyczyną był context.Canceled. Zachowuj błędy kontekstu, aby wywołujący mogli rozróżnić prawdziwe niepowodzenie operacji od anulowanego lub wygasłego żądania i odpowiednio na nie reagować. W celu uzyskania wyczerpującego omówienia tego, jak anulowanie kontekstu i propagowanie timeoutów działają między warstwami serwisów, zobacz Go context.Context Dobrze Zrobione.
Checklista przeglądu błędów
Używaj tej listy kontrolnej podczas przeglądu kodu.
Tworzenie błędów
- Czy to znany warunek?
- Czy powinien być sentinelem?
- Czy potrzebuje danych strukturalnych?
- Czy powinien być niestandardowym typem?
- Czy komunikat błędu jest jasny?
Zawijanie błędów
- Czy zawinięcie dodaje przydatny kontekst operacji?
- Czy
%wzachowuje przyczynę tam, gdzie to potrzebne? - Czy kod przypadkowo ujawnia szczegóły implementacji?
- Czy łańcuch jest zbyt głośny?
Tłumaczenie błędów
- Czy niskopoziomowy błąd został przetłumaczony na właściwej granicy?
- Czy specyficzne zachowanie bazy danych jest ukryte przed kodem serwisu?
- Czy błędy domenowe są niezależne od kwestii HTTP lub CLI?
Obsługa błędów
- Czy wywołujący rozgałęzia się za pomocą
errors.Isluberrors.As? - Czy anulowanie kontekstu i terminy są obsługiwane poprawnie?
- Czy błędy powtarzalne są identyfikowane jawnie?
- Czy błędy walidacji są strukturalne?
Logowanie
- Czy błąd jest logowany raz, na granicy obsługi?
- Czy logi są strukturalne?
- Czy wrażliwe szczegóły są wykluczone z odpowiedzi do użytkownika?
- Czy jest wystarczający kontekst dla operatorów?
Testowanie
- Czy znane przypadki błędów są testowane?
- Czy mapowania HTTP lub CLI są testowane?
- Czy szczegóły walidacji są testowane?
- Czy decyzje o ponowieniu są testowane?
Moje opiniiowane zasady
Zasada 1: Błędy powinny przekraczać granice ze znaczeniem
Nie przesyłaj tylko błędów. Decyduj, co oznaczają na każdej warstwie.
Zasada 2: Zawijaj dla kontekstu, nie dla dekoracji
Jeśli zawinięcie nie dodaje przydatnych informacji o tym, jaka operacja się nie powiodła, nie zawijaj. Dodatkowa warstwa kontekstu bez znaczenia sprawia, że łańcuch błędów jest trudniejszy w czytaniu i nie dodaje wartości diagnostycznej.
Zasada 3: Tłumacz błędy implementacji na błędy domenowe
Nie pozwalaj, aby sql.ErrNoRows stał się częścią Twojej logiki biznesowej. Tłumacz błędy implementacji na błędy domenowe na granicy magazynu, aby reszta aplikacji nigdy nie musiała wiedzieć, która baza danych lub ORM znajduje się pod spodem.
Zasada 4: Nie parsuj stringów błędów
Jeśli kod musi rozgałęzić się na podstawie typu niepowodzenia, używaj sentineli, niestandardowych typów, errors.Is lub errors.As. Badanie stringów tworzy niewidoczne sprzężenie, które cicho się psuje, gdy komunikaty błędów się zmieniają.
Zasada 5: Loguj raz
Zawijaj, gdy błędy przesuwały się wyżej. Loguj tam, gdzie błąd jest ostatecznie obsługiwany.
Zasada 6: Utrzymuj bezpieczne komunikaty dla użytkownika
Wewnętrzne błędy diagnostyczne są dla logów. Komunikaty skierowane do użytkownika są dla użytkowników.
Zasada 7: Trzymaj błędy transportu na granicy transportu
Kody statusu HTTP należą do obsłużników lub adapterów API, a nie do serwisów domenowych. Kod domenowy powinien być wielokrotnie używalny przez różne transporty — dziś HTTP, jutro CLI, gRPC lub oparty na zdarzeniach worker.
Myśli końcowe
Obsługa błędów w Go nie polega na wiecznym pisaniu if err != nil — chodzi o uczynienie niepowodzenia jawnym i zrozumiałym na każdej granicy.
Mechanika jest prosta:
return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter
Architektura jest trudniejszą częścią:
translate at boundaries
preserve causes
hide internals from users
log once
test known failures
To jest dobrze wykonana obsługa błędów w Go — nie sprytne, nie magiczne, ale wystarczająco jasne, aby następny deweloper, operator, klient API i Twoje przyszłe „ja” mogli zrozumieć, co się nie powiodło i co powinno się stać dalej. W celu uzyskania szerszego obrazu produkcyjnych wzorców Go w zakresie integracji, testowania i dostępu do danych, zobacz Architektura Aplikacji w Produkcji.