Po co w ogóle „odporność na błędy” i co to znaczy w Go
Co to znaczy, że usługa sieciowa jest odporna na błędy
Odporna na błędy usługa sieciowa w Go to taka, która w warunkach produkcyjnych zachowuje się stabilnie i przewidywalnie, nawet gdy:
- klienci wysyłają niepoprawne lub złośliwe dane,
- zewnętrzne systemy (bazy, API) zawodzą,
- następuje nagły skok ruchu lub częściowa awaria infrastruktury,
- kod wewnątrz usługi popełnia lokalne błędy logiczne.
Odporność nie oznacza „braku błędów”. Oznacza raczej kontrolowaną degradację – lepiej odpowiedzieć HTTP 503 z czytelnym komunikatem, niż zawiesić wszystkie goroutines aż do zużycia pamięci. Lepszy jest też częściowy brak funkcjonalności niż pełna niedostępność całej aplikacji.
W praktyce odporna na błędy usługa w Go ma kilka cech:
- brak niekontrolowanych paniców wypływających do klienta,
- sensownie ustawione limity czasowe i rozmiarów requestów,
- spójną obsługę błędów z mapowaniem na kody HTTP,
- przewidywalne zachowanie przy przeciążeniu (limitowanie, kolejki, backpressure),
- bezpieczną współbieżność: brak race condition i wycieków goroutines.
Specyfika Go: prostota, wbudowany serwer HTTP, jawne błędy
Go oferuje bardzo przyjemny zestaw klocków do budowy odpornych usług sieciowych: net/http, goroutines, kanały, prosty model konkurencji oraz jawny typ error zamiast wyjątków. To ostatnie mocno kształtuje sposób myślenia o stabilności.
Nie ma tu try/catch; każdy błąd trzeba zobaczyć i przemyśleć: co zrobić, jeśli baza nie odpowiada, jeśli JSON jest niepoprawny, jeśli zewnętrzny serwis zwróci HTTP 500. Ten „przymus” bywa irytujący, ale w kontekście bezpieczeństwa i odporności to ogromna zaleta – mniej magii, więcej jawnych decyzji.
Wbudowany serwer HTTP w Go jest prosty, wydajny i sprawdzony produkcyjnie. Nie trzeba dorzucać wielkiego frameworka, aby obsłużyć tysiące requestów na sekundę. Z drugiej strony prostota oznacza, że sporo odpowiedzialności spada na programistę: timeouty, limity, nagłówki bezpieczeństwa, obsługa panic – to wszystko trzeba skonfigurować lub zaimplementować.
„Działa na lokalnym” vs „przeżyje produkcję”
Na lokalnym środowisku, przy garści testowych requestów, niemal każda usługa wygląda na stabilną. Problemy wychodzą dopiero w boju, gdy:
- ktoś wyśle 50 MB JSON-a na endpoint przewidziany na 10 KB,
- zniknie DNS do bazy danych,
- serwis zewnętrzny zacznie odpowiadać po 30 sekundach, a Ty nie ustawisz żadnego timeoutu,
- dwóch klientów jednocześnie zaktualizuje ten sam zasób przez niezsynchronizowany globalny cache.
Odporna na błędy usługa Go zakłada z góry, że takie rzeczy się wydarzą. Kod jest pisany nie pod „happy path”, ale pod rzeczywistość: zmienne sieci, nieprzewidywalne dane wejściowe, błędy ludzi i maszyn. To przełożenie myślenia jest kluczowe: produkcyjna odporność to nie jest optymalizacja na końcu, tylko cecha architektury od pierwszych linijek.
Typowe scenariusze awarii i jak Go pomaga
Kilka realnych, prostych scenariuszy, w których cechy Go robią ogromną różnicę:
- Zbyt długie requesty – bez limitów klient może otworzyć połączenie i wysyłać body „kroplami”, aż zużyje wszystkie worker goroutines. Go pozwala ustawić
ReadTimeout,ReadHeaderTimeouti limity rozmiaru ciała przezhttp.MaxBytesReader. - Przeciążenie bazy danych – bez limitowania liczby jednoczesnych zapytań do bazy, goroutines mogą rzucić się hurtem, powodując lawinową awarię. Kanały i worker pool w Go bardzo ułatwiają wdrożenie prostych, a skutecznych semaforów konkurencji.
- Błędna walidacja danych – dynamiczne języki często „odkładają” problem na runtime, co kończy się dziwnymi NPE lub 500tkami. Silne typowanie w Go, struktury z tagami JSON i biblioteki walidacji pozwalają łapać dużą część problemów przy wejściu requestu.
- Wyjątki/paniki – panic w Go jest „głośna”, ale można ją przechwycić
recover()w odpowiednio napisanym middleware. Dzięki temu pojedynczy błąd programisty nie wykoleja całego serwera HTTP.
Fundamenty projektowania prostych i bezpiecznych usług w Go
Minimalistyczna architektura: kiedy wystarczy net/http
W większości zastosowań wystarcza wbudowane net/http plus lekki router (albo nawet http.ServeMux). Rozbudowane frameworki potrafią pomóc, ale często wprowadzają:
- dodatkową warstwę magii, utrudniającą zrozumienie przepływu requestu,
- skomplikowany lifecycle, który miesza się z prostym modelem Go,
- większą powierzchnię ataku i zależność od zewnętrznego projektu.
Wiele stabilnych, wysokowydajnych usług produkcyjnych w Go opiera się wyłącznie na net/http. Prosty serwer można zdefiniować tak:
srv := &http.Server{
Addr: ":8080",
Handler: router, // Twój mux lub http.DefaultServeMux
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Fatal(srv.ListenAndServe())
Jeśli aplikacja ma jedynie kilka endpointów, logikę biznesową da się ładnie odseparować w pakietach, a wymagania co do funkcji frameworkowych są niewielkie – prosty kod jest łatwiejszy do zabezpieczenia i przetestowania.
Zasada „najpierw prostota” i unikanie zbędnych abstrakcji
Go nagradza prostotę. Im więcej „warstw abstrakcji”, wzorców z książek i magii DI, tym trudniej:
- zlokalizować błędy przepływu,
- przeanalizować zachowanie usługi pod obciążeniem,
- wychwycić problemy z concurrency.
Lepiej zacząć od prostego, prawie „brutalnego” kodu, trzymając się kilku pryncypiów:
- brak globalnego stanu, poza ewidentnymi wyjątkami (np.
log.Loggerskonfigurowany wmain), - małe, czytelne funkcje – jedna rzecz, jeden odpowiedzialny fragment,
- kompozycja zamiast dziedziczenia (którego w Go i tak nie ma).
Przykładowo, zamiast tworzyć skomplikowaną hierarchię struktur i interface’ów, często wystarczy prosta struktura aplikacji:
type App struct {
logger *log.Logger
store Store
}
func (a *App) createUserHandler(w http.ResponseWriter, r *http.Request) {
// użycie a.logger, a.store
}
Ta prostota ułatwia bezpieczeństwo: łatwo zorientować się, gdzie trafia request, jakie zależności są wykorzystywane, gdzie potencjalnie mogą wystąpić błędy oraz jak sterować contextem.
Rozdzielenie warstw: HTTP, logika biznesowa, dostęp do danych
Usługa jest bezpieczniejsza i bardziej odporna na błędy, gdy logika biznesowa nie jest przyspawana do HTTP. Chodzi o jasny podział na:
- warstwę transportu – tu żyje
net/http, routing, serializacja JSON, nagłówki, kody statusów, - warstwę biznesową – czyste funkcje, które przyjmują dane, zwracają wynik lub błąd, nie wiedząc nic o HTTP,
- warstwę dostępu do danych – odpowiedzialną za komunikację z bazą, cache, zewnętrznymi API.
Prosty schemat przepływu:
- Handler HTTP parsuje request, waliduje dane, tworzy model wejściowy.
- Wywołuje metodę serwisu biznesowego, przekazując context i dane.
- Serwis biznesowy używa repozytoriów (interfejsów) do pracy z danymi.
- Handler mapuje wynik lub błąd na odpowiednią odpowiedź HTTP.
Dzięki temu łatwiej:
- testować logikę biznesową niezależnie od HTTP,
- zmieniać warstwę transportową (np. dodać gRPC obok HTTP),
- kontrolować i centralizować obsługę błędów i logowanie.
Interfejsy i małe pakiety jako zbroja na zmiany
Go promuje małe, wyspecjalizowane interfejsy. Zamiast jednego „mega-interfejsu” bazy danych lepiej zdefiniować np.:
type UserStore interface {
CreateUser(ctx context.Context, u User) (UserID, error)
GetUser(ctx context.Context, id UserID) (User, error)
}
Logika biznesowa opiera się na interfejsach, a konkretną implementację (SQL, in-memory, zewnętrzne API) wstrzykuje się w main. Z punktu widzenia odporności na błędy to istotne, bo:
- łatwiej zasymulować błędy w testach (np. timeouty, różne typy błędów),
- można wymienić implementację na lepiej obsługującą przeciążenie,
- obsługa błędów i retry może być specyficzna dla konkretnego „store” bez mieszania się z resztą.

Podstawy bezpiecznego HTTP w Go (net/http, routing, middleware)
Model obsługi requestów w net/http
net/http działa w bardzo prosty sposób:
- Każdy przychodzący request HTTP trafia do odpowiedniego
Handler. - Dla każdego requestu Go uruchamia osobną goroutine.
Handlerto interfejs z metodąServeHTTP(w http.ResponseWriter, r *http.Request).
Prosta definicja handlera:
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, secure Go!")
}
Równoległość jest wbudowana, ale to oznacza też, że każdy dostęp do współdzielonego stanu musi być bezpieczny. W przeciwnym razie błędy będą występować losowo, zwykle tylko pod obciążeniem – czyli dokładnie wtedy, gdy trzeba trzymać usługę przy życiu.
Routing: prosty mux vs biblioteki
Do routingu można użyć standardowego http.ServeMux:
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
mux.HandleFunc("/users", createUserHandler) // np. POST
mux.HandleFunc("/users/", userHandler) // np. GET /users/{id}
Dla prostych usług to w zupełności wystarczy. Gdy potrzebne są funkcje typu parametry ścieżek, grupy, middleware per grupa – wtedy wchodzą biblioteki, np. chi, gorilla/mux (choć gorilla jest wygaszana), czy httprouter.
W kontekście bezpieczeństwa ważne aspekty routera:
- jak obsługuje metody HTTP (GET/POST/PUT/DELETE),
- jak zarządza trailing slashem i normalizacją ścieżki,
- czy nie przecieka zmiennych ścieżki do logów bez sanitizacji.
Prosty router plus własne, jawne middleware to często najbezpieczniejszy kompromis: pełna kontrola nad przepływem, brak zaskoczeń.
Middleware jako miejsce na logowanie, auth i recover
Middleware to funkcja, która opakowuje handler, dodając wspólne zachowania: logowanie, autoryzację, limitowanie, zabezpieczenia. Najważniejsze z punktu widzenia odporności na błędy:
- logowanie requestów i błędów,
- odzyskiwanie po panic (recover),
- spójne mapowanie błędów na odpowiedzi HTTP,
- nagłówki bezpieczeństwa (CORS, HSTS, X-Frame-Options itd.).
Prosty middleware recoverujący paniki:
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// loguj szczegóły po stronie serwera
log.Printf("panic: %vn", rec)
// nie pokazuj stacktrace klientowi
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Takie „powietrze w poduszce” powoduje, że pojedynczy błąd w kodzie handlera kończy się kontrolowaną 500tką, a nie wywaleniem całego worker processu.
Bezpieczne domyślne ustawienia serwera HTTP
Go stawia na sensowne domyślne wartości, ale w kontekście usług internetowych trzeba kilka rzeczy doprecyzować:
Bezpieczna konfiguracja HTTP i TLS
Sam serwer HTTP to dopiero początek. Żeby faktycznie był bezpieczny, trzeba go skonfigurować jak dorosłą usługę, a nie jak przykład z tutoriala.
Rozsądne minimum:
- ustawienie timeoutów (czyli koniec z wiszącymi połączeniami),
- wymuszenie TLS wszędzie poza lokalnym developmentem,
- wyłączenie starych, dziurawych protokołów i słabych szyfrów.
Fragment konfiguracji serwera z TLS:
srv := &http.Server{
Addr: ":443",
Handler: router,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MB
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
PreferServerCipherSuites: true,
CurvePreferences: []tls.CurveID{
tls.CurveP256,
tls.X25519,
},
},
}
log.Fatal(srv.ListenAndServeTLS("fullchain.pem", "privkey.pem"))
Kilka elementów ma realne znaczenie dla odporności na ataki i błędy:
ReadHeaderTimeoutiReadTimeoutograniczają czas na wysłanie nagłówków i całego requestu – to tarcza przeciwko powolnym atakom typu slowloris.MaxHeaderBytesbroni przed gigantycznymi nagłówkami, które mogą wyczerpać pamięć.MinVersion: tls.VersionTLS12ucina stare protokoły (SSLv3, TLS 1.0/1.1), które po prostu nie powinny już istnieć w produkcji.
Dodatkowo można wprowadzić redirect z HTTP na HTTPS na porcie 80:
go func() {
// prosty redirect z :80 na :443
http.ListenAndServe(":80", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
}))
}()
Nagłówki bezpieczeństwa i CORS
HTTP bez nagłówków bezpieczeństwa to jak serwer bez logów – niby działa, ale aż prosi się o kłopoty. Najważniejsze nagłówki można wstrzyknąć globalnym middleware.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none';")
next.ServeHTTP(w, r)
})
}
Dla API JSON Content-Security-Policy często może być agresywna, bo i tak nie serwujesz HTML. Gdy wejdziesz w świat SPA, trzeba ją dostosować – ale lepiej zacząć od „pancernej” wersji niż od żadnej.
Jeśli API ma być używane z przeglądarki, dochodzi CORS. Nie ustawiaj Access-Control-Allow-Origin: * z rozpędu, szczególnie jeśli używasz cookies lub bearer tokens. Przykładowy, prosty middleware CORS z whitelistą domen:
func cors(allowedOrigins []string, next http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(allowedOrigins))
for _, o := range allowedOrigins {
allowed[o] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
if _, ok := allowed[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Obsługa błędów w Go – styl, który naprawdę ratuje produkcję
Konsekwentne zwracanie i owijanie błędów
Brak wyjątków w Go bywa wyśmiewany, ale w systemach sieciowych to ogromny plus. Każda operacja, która może się nie udać, zwraca error – i tyle. Odporność na błędy zaczyna się od konsekwentnego sprawdzania i owijania tych błędów.
func (s *UserService) CreateUser(ctx context.Context, input CreateUserInput) (User, error) {
if err := validateCreateUser(input); err != nil {
return User{}, fmt.Errorf("validate input: %w", err)
}
u := toUserModel(input)
id, err := s.store.CreateUser(ctx, u)
if err != nil {
return User{}, fmt.Errorf("create user in store: %w", err)
}
user, err := s.store.GetUser(ctx, id)
if err != nil {
return User{}, fmt.Errorf("reload created user: %w", err)
}
return user, nil
}
Łańcuch %w w fmt.Errorf pozwala później rozpakować błąd i rozpoznać konkretną przyczynę.
Błędy domenowe i ich mapowanie na HTTP
Żeby handler HTTP nie musiał analizować setek typów i napisów, dobrze wprowadzić własny typ błędu domenowego:
type ErrorCode string
const (
ErrCodeNotFound ErrorCode = "not_found"
ErrCodeInvalidInput ErrorCode = "invalid_input"
ErrCodeUnauthenticated ErrorCode = "unauthenticated"
ErrCodeUnauthorized ErrorCode = "unauthorized"
ErrCodeConflict ErrorCode = "conflict"
ErrCodeInternal ErrorCode = "internal"
)
type AppError struct {
Code ErrorCode
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.Err }
func NewAppError(code ErrorCode, msg string, err error) *AppError {
return &AppError{Code: code, Message: msg, Err: err}
}
Logika biznesowa zwraca *AppError, a HTTP handler tłumaczy to na kod statusu i bezpieczny JSON:
func writeError(w http.ResponseWriter, r *http.Request, err error) {
var appErr *AppError
if !errors.As(err, &appErr) {
// niespodziewany błąd
log.Printf("unexpected error: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
status := mapErrorCodeToStatus(appErr.Code)
// log po stronie serwera może zawierać więcej szczegółów
if status >= 500 {
log.Printf("server error: %v", appErr)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": appErr.Code,
"message": appErr.Message,
})
}
func mapErrorCodeToStatus(code ErrorCode) int {
switch code {
case ErrCodeInvalidInput:
return http.StatusBadRequest
case ErrCodeNotFound:
return http.StatusNotFound
case ErrCodeUnauthenticated:
return http.StatusUnauthorized
case ErrCodeUnauthorized:
return http.StatusForbidden
case ErrCodeConflict:
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
Dzięki temu logika biznesowa nie musi wiedzieć, czym różni się 401 od 403, a handler nie musi znać szczegółów działania bazy.
Centralny middleware do obsługi błędów
Żeby nie powtarzać wywoływania writeError w każdym handlerze, można wprowadzić prostą konwencję: handler zwraca błąd, zamiast pisać do http.ResponseWriter w wielu miejscach.
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeError(w, r, err)
}
}
Teraz handler jest bardziej „czysty”:
func (a *App) createUserHandler(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost {
return NewAppError(ErrCodeInvalidInput, "method not allowed", nil)
}
var input CreateUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
return NewAppError(ErrCodeInvalidInput, "invalid JSON body", err)
}
defer r.Body.Close()
user, err := a.userService.CreateUser(r.Context(), input)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
return json.NewEncoder(w).Encode(user)
}
Mniej szumu, mniejsza szansa na „zapomniane” sprawdzenie błędu i niespójne odpowiedzi.
Nie loguj wrażliwych danych w błędach
W logach często kończą dane, których zdecydowanie nie chcesz ujawniać: hasła, tokeny, numery dokumentów. Z punktu widzenia bezpieczeństwa najważniejsze zasady:
- w logach technicznych trzymaj głównie identyfikatory (ID użytkownika, request ID),
- nie loguj pełnych payloadów requestów przy błędach walidacji – lepiej fragmenty i pola problematyczne,
- jeśli musisz logować zapytania SQL, maskuj wartości bindów wrażliwych kolumn.
Jeden zespół dopiero po incydencie z wyciekiem logów zorientował się, że w logach od lat krążą hasła w czystym tekście. Lepiej nie mieć takiego „ach, to jednak było ważne”.

Context, timeouty i przerwania – jak nie blokować zasobów w nieskończoność
Context jako przewodnik życia requestu
Każdy *http.Request ma w sobie Context(). To tam żyje informacja o tym, kiedy klient się rozłączył, kiedy minął deadline i jakie metadane niosą middlewares (np. request ID, user ID).
Podstawowa zasada: wszystkie operacje, które mogą trwać (I/O, baza, RPC), powinny przyjmować context.Context i respektować jego zakończenie.
func (s *SQLUserStore) GetUser(ctx context.Context, id UserID) (User, error) {
const query = `SELECT id, email, name FROM users WHERE id = $1`
var u User
if err := s.db.QueryRowContext(ctx, query, id).Scan(&u.ID, &u.Email, &u.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, NewAppError(ErrCodeNotFound, "user not found", err)
}
return User{}, fmt.Errorf("query user: %w", err)
}
return u, nil
}
Wszystko, co „schodzi” w dół od handlera – serwisy, store’y, klienci HTTP – powinno przyjmować context jako pierwszy argument. Dzięki temu jeden timeout może przerwać całą kaskadę wywołań.
Timeouty per request i per operacja
Same server timeouty nie wystarczą. Dla wybranych endpointów sensowne jest nałożenie krótszych limitów czasu, np. dla zapytań do powolnego zewnętrznego API.
func withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, d)
}
W handlerze:
func (a *App) externalDataHandler(w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
data, err := a.externalClient.FetchData(ctx)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return NewAppError(ErrCodeInternal, "upstream timeout", err)
}
return err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(data)
}
Przy każdej WithTimeout lub WithCancel trzeba pamiętać o defer cancel(). Brak wywołania cancel to klasyczny, mały wyciek – niby drobiazg, ale przy tysiącach requestów na sekundę różnice robi się naprawdę widoczna.
Respektowanie anulowania contextu
Content można sprawdzać ręcznie, jeśli operacja robi własne, długotrwałe pętle:
func longCalculation(ctx context.Context, items []Item) (Result, error) {
var res Result
for i, it := range items {
select {
case <-ctx.Done():
return Result{}, ctx.Err()
default:
}
processItem(&res, it)
if (i+1)%1000 == 0 {
// można wstawić dodatkowe sprawdzenie, jeśli pętla jest megadługa
if err := ctx.Err(); err != nil {
return Result{}, err
}
}
}
return res, nil
}
Jeżeli context jest ignorowany w dolnych warstwach, timeout w handlerze niewiele daje – request i tak będzie „mielony” dalej, tylko że bez możliwości odpowiedzi klientowi.
Request ID i cancelacja łańcucha wywołań
Dla debugowania i korelacji logów przydaje się request ID. Można go wygenerować w middleware i wrzucić do contextu:
type ctxKeyRequestID struct{}
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = generateID() // np. uuid.NewString()
}
ctx := context.WithValue(r.Context(), ctxKeyRequestID{}, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyRequestID{}).(string)
return id
}
Każde logowanie w głąb stosu może dopisywać RequestID(ctx). Gdy user zgłasza, że „coś nie działało o 12:03”, dużo łatwiej znaleźć konkretny ślad niż przeglądać kilometry logów.
Context przy zamykaniu aplikacji (graceful shutdown)
Context przydaje się nie tylko w obrębie pojedynczego requestu. Przy zatrzymywaniu serwera dobrze jest pozwolić trwającym żądaniom się domknąć, a nie po prostu „uciąć prąd” w połowie zapisu do bazy.
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: buildHandler(), // router + middlewares
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Kanał na sygnały systemowe
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
log.Println("server started")
<-stop
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
if err := srv.Close(); err != nil {
log.Printf("force close failed: %v", err)
}
}
log.Println("server exited")
}
Server.Shutdown zamyka listener, ale pozwala dokończyć trwające requesty (z limitem czasu). Dopiero gdy limit minie, można wymusić Close. Dzięki temu load balancer nie wyśle nowych żądań na martwą instancję, a stare nie zostaną przerwane w połowie zapisu albo transakcji.
Concurrency w Go bez bólu głowy: goroutines, kanały, safety
Goroutines – tanie, ale nie darmowe
Nowy programista Go szybko odkrywa, że go przed wywołaniem funkcji jest kusząco łatwe. I że można wszędzie dodać „trochę równoległości”. Dopóki system nie zaczyna się dusić od tysięcy wiszących goroutines czekających na coś, co nigdy nie nastąpi.
Kilka prostych zasad trzymających to w ryzach:
- każda dłużej żyjąca goroutine powinna mieć ścieżkę wyjścia związaną z contextem,
- jeśli goroutine ma komunikować się z resztą świata – używaj kanałów lub konfigurowalnych interfejsów, nie globalnych zmiennych,
- nie twórz nowych goroutines w pętli bez limitu – wprowadź pulę lub semafor.
Przekazywanie contextu do goroutine
Goroutine, która nie słucha contextu, często zmienia się w wyciek pamięci. Nawet jeśli robi „tylko logowanie” albo „tylko drobną sync’ę metryk”.
func (a *App) startWorker(ctx context.Context, jobs <-chan Job) {
go func() {
for {
select {
case <-ctx.Done():
log.Println("worker stopped:", ctx.Err())
return
case job, ok := <-jobs:
if !ok {
log.Println("jobs channel closed")
return
}
if err := a.processJob(ctx, job); err != nil {
log.Printf("process job: %v", err)
}
}
}
}()
}
Taki worker skończy pracę, gdy aplikacja się zamyka (context z main) albo gdy „nadawca” uzna, że dany kontekst jest już nieaktualny.
Bezpieczne korzystanie z kanałów
Kanały są świetne do koordynacji, ale też idealne do stworzenia deadlocka. Dwa podstawowe grzechy:
- wysyłanie do kanału, z którego nikt nie odbiera,
- zamknięcie kanału przez kogoś, kto nie jest jego właścicielem.
Prosty schemat, który pomaga:
- kanał tworzy strona, która wysyła dane (producent),
- tylko producent może zamknąć kanał,
- odbiorca nigdy nie zamyka kanału, z którego czyta.
func produce(ctx context.Context) <-chan Item {
out := make(chan Item)
go func() {
defer close(out)
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return
case out <- Item{ID: i}:
}
}
}()
return out
}
func consume(ctx context.Context, in <-chan Item) {
for {
select {
case <-ctx.Done():
return
case it, ok := <-in:
if !ok {
return
}
handleItem(it)
}
}
}
Prosta, ale trzyma odpowiedzialności w jednym miejscu i eliminuje większość przypadkowych panic przez zapis w zamknięty kanał.
Limiter równoległych zadań (semafor)
Jeśli dla każdego requestu odpalasz kilkadziesiąt goroutines (np. równoległe zapytania do kilku usług), sensownie jest wprowadzić limit. W innym wypadku kilka gorszych minut w systemie upstream może wygenerować lawinę czekających goroutines.
type Limiter struct {
sem chan struct{}
}
func NewLimiter(max int) *Limiter {
return &Limiter{sem: make(chan struct{}, max)}
}
func (l *Limiter) Do(ctx context.Context, fn func(ctx context.Context) error) error {
select {
case <-ctx.Done():
return ctx.Err()
case l.sem <- struct{}{}:
// zarezerwowane miejsce
}
defer func() { <-l.sem }()
return fn(ctx)
}
Użycie w handlerze:
func (a *App) searchHandler(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
if err := a.searchLimiter.Do(ctx, func(ctx context.Context) error {
res, err := a.searchService.Search(ctx, r.URL.Query().Get("q"))
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(res)
}); err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return NewAppError(ErrCodeInternal, "search timeout", err)
}
return err
}
return nil
}
Takie proste „światełko na skrzyżowaniu” często ratuje bazę albo usługę zewnętrzną przed zmasowanym atakiem (również przypadkowym, od własnych klientów).
Race conditions i prosty model współdzielenia danych
Najprostszy sposób, by unikać wyścigów: nie współdziel danych, jeśli nie musisz. Albo współdziel, ale tylko do odczytu.
Dwa sugestie, które bardzo pomagają:
- konfiguracja aplikacji: niech będzie niemutowalna po starcie (struct z tylko-czytelnymi polami),
- cache’e w pamięci: jeśli można, aktualizuj je przez wymianę całej struktury pod wskaźnikiem, nie przez modyfikację w miejscu.
type Config struct {
DBURL string
MaxConnections int
AllowedOrigins []string
// brak setterów, żadnego zmieniania w trakcie jazdy
}
type atomicCache struct {
v atomic.Value // przechowuje *CacheData
}
func (c *atomicCache) Get() *CacheData {
v := c.v.Load()
if v == nil {
return nil
}
return v.(*CacheData)
}
func (c *atomicCache) Set(newData *CacheData) {
c.v.Store(newData)
}
Zamiast kombinacji RWMutex + ręczne pilnowanie kto co gdzie modyfikuje, całość zamienia się w: „odczyt jest lock-free, zapis to po prostu podmiana wskaźnika”. Mniej kodu, mniej potencjalnych deadlocków.
Mutexy – jak ich używać, żeby nie bolało
Mutexy są w porządku, dopóki:
- lockujesz możliwie małe sekcje,
- nie wołasz funkcji, które mogą długo blokować lub robić I/O, trzymając lock,
- nie próbujesz zdobyć kilku mutexów w różnych miejscach w różnej kolejności.
type SafeCounter struct {
mu sync.Mutex
v map[string]int
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
c.v[key]++
c.mu.Unlock()
}
func (c *SafeCounter) Value(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.v[key]
}
Brzmi banalnie, ale 90% problemów z sync wynika z przekombinowania. Lock – krótka sekcja – unlock. Reszta logiki poza blokadą.
Walidacja danych wejściowych i ochrony na brzegu
Walidacja jak najbliżej brzegu usługi
Najbardziej opłaca się odrzucać złe dane możliwie wcześnie. HTTP handler jest dobrym miejscem, by:
- parsować i walidować parametry ścieżki, query i body,
- limitować rozmiar requestu,
- zapewnić poprawne typy i formaty.
type CreateUserInput struct {
Email string `json:"email"`
Name string `json:"name"`
}
func (in *CreateUserInput) Validate() error {
if len(in.Email) > 255 || !strings.Contains(in.Email, "@") {
return NewAppError(ErrCodeInvalidInput, "invalid email", nil)
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len(in.Name) > 100 {
return NewAppError(ErrCodeInvalidInput, "invalid name", nil)
}
return nil
}
func (a *App) createUserHandler(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost {
return NewAppError(ErrCodeInvalidInput, "method not allowed", nil)
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB
var input CreateUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
return NewAppError(ErrCodeInvalidInput, "invalid JSON body", err)
}
defer r.Body.Close()
if err := input.Validate(); err != nil {
return err
}
user, err := a.userService.CreateUser(r.Context(), input)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
return json.NewEncoder(w).Encode(user)
}
MaxBytesReader to tani „bezpiecznik” przed klientem, który z jakiegoś powodu uznał, że JSON 100MB to dobry pomysł.
Walidacja vs sanitization – dwa różne narzędzia
Walidacja odpowiada na pytanie „czy dane są akceptowalne?”. Sanitization na „jak bezpiecznie je użyć?”. Przykładowo, HTML w komentarzach użytkownika:
- walidacja może zabronić pewnych tagów (np. <script>),
- sanitization przefiltruje lub zescapuje resztę, zanim trafi do szablonu.
W Go często wystarcza dwóch przyjaciół: html/template oraz proste funkcje czyszczące.
func sanitizePlainText(s string, maxLen int) string {
s = strings.TrimSpace(s)
if len(s) > maxLen {
s = s[:maxLen]
}
return s
}
Jeśli renderujesz HTML, używaj html/template, które domyślnie escapuje dane:
tmpl := template.Must(template.ParseFiles("templates/comment.html"))
type CommentData struct {
Author string
Content string
}
func (a *App) commentHandler(w http.ResponseWriter, r *http.Request) error {
// ... parsowanie danych ...
data := CommentData{
Author: sanitizePlainText(author, 100),
Content: sanitizePlainText(content, 2000),
}
return tmpl.Execute(w, data)
}
Jeśli potrzebny jest bardziej zaawansowany HTML (np. markdown), sensownie sięgnąć po dedykowaną bibliotekę sanitizingu, zamiast próbować ręcznie wycinać tagi regexem. Ten pomysł brzmi dobrze wyłącznie przez pierwsze 5 minut.
Bezpieczne budowanie zapytań SQL
SQL injection dalej się zdarza – nawet w świeżym kodzie. W Go sytuację ratują przygotowane zapytania i bindy parametrów.
const query = `SELECT id, email, name FROM users WHERE email = $1`
func (s *SQLUserStore) FindByEmail(ctx context.Context, email string) (User, error) {
var u User
if err := s.db.QueryRowContext(ctx, query, email).Scan(&u.ID, &u.Email, &u.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, NewAppError(ErrCodeNotFound, "user not found", err)
}
return User{}, fmt.Errorf("query user: %w", err)
}
return u, nil
}
Zapomnij o konkatenacji stringów z danymi użytkownika w SQL. Jeśli musisz dynamicznie składać fragmenty zapytania (filtry, sortowanie), łącz tylko fragmenty z góry zdefiniowane w kodzie (np. "ORDER BY created_at DESC" albo "ORDER BY name ASC" wybrane z mapy), a wartości zawsze idą jako parametry bindowane.
Bezpieczny routing i parametry ścieżki
Przy parametrach w ścieżce dobrze jest łączyć walidację na poziomie routera (np. regexp) z walidacją w handlerze. Przykład z użyciem chi lub gorilla/mux (schemat jest podobny):
r := chi.NewRouter()
r.Route("/users", func(r chi.Router) {
r.Get("/{id:[0-9]+}", app.getUserHandler)
})
W handlerze i tak lepiej sprawdzić parametr:
func (a *App) getUserHandler(w http.ResponseWriter, r *http.Request) error {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
return NewAppError(ErrCodeInvalidInput, "invalid user id", err)
}
user, err := a.userService.GetUser(r.Context(), UserID(id))
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).
Najczęściej zadawane pytania (FAQ)
Co to znaczy, że usługa sieciowa w Go jest „odporna na błędy”?
Odporna na błędy usługa w Go to taka, która zachowuje się przewidywalnie pod obciążeniem i przy awariach: nie wiesza się, nie zjada całej pamięci, nie zalewa logów stack trace’ami i nie ujawnia szczegółów wewnętrznej implementacji klientowi.
W praktyce oznacza to m.in. brak niekontrolowanych paniców, sensowne timeouty, limity rozmiaru requestów, przewidywalne zachowanie przy przeciążeniu oraz bezpieczną współbieżność (brak wyścigów danych i wycieków goroutines). Błędy się zdarzają – sztuka polega na tym, żeby skutki były ograniczone i zrozumiałe.
Jak w Go zabezpieczyć serwer HTTP przed zbyt długimi lub złośliwymi requestami?
Podstawą są odpowiednio ustawione timeouty i limity rozmiarów. W strukturze http.Server ustaw ReadTimeout, ReadHeaderTimeout, WriteTimeout oraz IdleTimeout, aby klient nie mógł trzymać połączenia „na kroplówce” przez pół dnia. Dodatkowo w handlerach możesz owinąć r.Body w http.MaxBytesReader, ograniczając maksymalny rozmiar body.
Prosty przykład: jeśli endpoint ma przyjmować JSON do 10 KB, ustaw limit na 10 KB z niewielkim zapasem. Gdy klient wyśle 50 MB, zamiast stopniowo zjadać zasoby serwera, odrzucasz żądanie z czytelnym kodem HTTP 413 lub 400.
Czy do bezpiecznych usług w Go potrzebuję frameworka, czy wystarczy net/http?
W większości przypadków wbudowane net/http plus prosty router w zupełności wystarczą. Wiele produkcyjnych usług o dużym ruchu działa właśnie na takim zestawie. Zyskujesz prosty lifecycle, mało „magii” i łatwiejszą analizę przepływu requestu, a tym samym mniej miejsc, gdzie mogą ukrywać się problemy z bezpieczeństwem i stabilnością.
Większe frameworki potrafią pomóc (middleware, generatory, DI), ale wprowadzają też dodatkową złożoność i większą powierzchnię ataku. Dobrym podejściem jest start od net/http i ewentualne sięganie po framework dopiero wtedy, gdy realnie brakuje Ci konkretnych funkcji.
Jak w Go poprawnie obsługiwać błędy, żeby nie kończyły się HTTP 500 z panic?
Go stawia na jawny typ error zamiast wyjątków, co zachęca do świadomej obsługi każdego błędu. W handlerach HTTP sprawdzaj zwrócone błędy krok po kroku, mapuj je na konkretne kody statusu (400, 401, 404, 409, 503 itd.) i zwracaj klientowi zwięzły, bezpieczny komunikat. Szczegóły (stack trace, wewnętrzne struktury) powinny trafić wyłącznie do logów technicznych.
Dodatkowo przydaje się middleware, który na wejściu opakowuje każdy handler i łapie panic za pomocą recover(). Dzięki temu pojedyncza wpadka programisty nie zabije całego serwera, a klient zobaczy kontrolowaną odpowiedź, np. HTTP 500 bez wycieku informacji o implementacji.
Jak projektować warstwy (HTTP, logika biznesowa, baza) w Go, żeby usługa była prostsza i bezpieczniejsza?
Sprawdza się jasny podział na trzy warstwy: transport (net/http, routing, serializacja JSON, nagłówki), logika biznesowa (funkcje operujące na modelach domenowych, nic nie wiedzące o HTTP) oraz dostęp do danych (baza, cache, zewnętrzne API). HTTP jest wtedy tylko „skórką” na Twojej domenie, a nie miejscem, gdzie żyje cała logika.
Taki układ ułatwia testowanie (logikę biznesową testujesz bez serwera), centralizację obsługi błędów oraz zmianę transportu – możesz dodać gRPC czy kolejki, nie przepisując całej aplikacji. Mówiąc nieco brutalnie: handler ma tylko przyjąć request, zawołać serwis i zamienić wynik na odpowiedź.
Jak w Go uniknąć problemów z concurrency i wycieków goroutines w usługach sieciowych?
Podstawą jest unikanie globalnego, współdzielonego stanu oraz ostrożne użycie goroutines. Tam, gdzie pojawia się współbieżny dostęp do danych, stosuj prymitywy synchronizacji (mutex, kanały) lub zaprojektuj struktury tak, by dane były niemutowalne po utworzeniu. Przydają się także worker poole i semafory oparte na kanałach, które ograniczają liczbę równoległych operacji np. na bazie.
Wycieki goroutines często wynikają z tego, że kod nie reaguje na anulowanie kontekstu lub czeka w nieskończoność na dane z kanału. Używaj context.Context z deadline’ami i timeoutami, szanuj jego anulowanie w pętlach i długotrwałych operacjach – goroutine, która zawsze słucha <-ctx.Done(), rzadko zostaje „sierotą”.
Jak Go pomaga w zabezpieczeniu usług przed błędnymi lub złośliwymi danymi wejściowymi?
Silne typowanie, struktury z tagami JSON i biblioteki walidacji sprawiają, że wiele błędów da się wychwycić na wejściu. Zamiast pracować na „luźnych” mapach czy interfejsach, parsuj request do dobrze zdefiniowanych struktur, a następnie waliduj ich pola (np. wymagane pola, zakresy, format e-maila).
Dzięki temu klient z niepoprawnym JSON-em czy nieprawidłowymi danymi dostaje kontrolowaną odpowiedź 400, a Twoja aplikacja nie kończy z tajemniczym NPE lub losową panic gdzieś w środku logiki. To trochę jak kontrola bagażu na lotnisku – im lepsza na bramce, tym spokojniejszy lot dalej.
Kluczowe Wnioski
- Odporność na błędy to nie brak awarii, lecz kontrolowana degradacja: lepiej zwrócić spójny błąd HTTP (np. 503) i utrzymać serwis przy życiu, niż doprowadzić do zacięcia goroutines i wyczerpania zasobów.
- Model błędów w Go (jawny
error zamiast wyjątków) wymusza świadome decyzje: przy każdym zapytaniu do bazy, parsowaniu JSON-a czy wywołaniu zewnętrznego API trzeba jasno określić, co zrobić, gdy coś pójdzie nie tak.
- Produkcja to nie „lokalne na laptopie”: trzeba z góry zakładać niepoprawne dane, opóźnione lub martwe serwisy zewnętrzne, dziwne DNS-y i zbyt duże requesty, a architekturę projektować właśnie pod takie scenariusze, a nie tylko pod „happy path”.
- Odporna usługa w Go musi mieć sensowne timeouty i limity (np.
ReadTimeout, WriteTimeout, http.MaxBytesReader), kontrolę przeciążenia (limity równoległych zapytań, worker pool) oraz bezpieczną współbieżność bez wyciekających goroutines.
- W wielu przypadkach wbudowane
net/http plus prosty router w zupełności wystarczą; ciężkie frameworki częściej dodają magię, złożony lifecycle i powierzchnię ataku, niż realnie poprawiają stabilność prostej usługi.
- Prostota wygrywa: małe funkcje, brak niepotrzebnych warstw abstrakcji i unikanie globalnego stanu ułatwiają debugowanie problemów z przepływem requestu i concurrency – szczególnie gdy produkcja zaczyna „strzelać w kostki” pod obciążeniem.






