Timeouty kontekstu w Go zapobiegają gromadzeniu się wolnych wywołań do bazy i zewnętrznych serwisów. Naucz się propagacji deadline, anulowania i bezpiecznych domyślnych ustawień.

Pojedyncze wolne żądanie rzadko jest „po prostu wolne”. Podczas oczekiwania utrzymuje goroutine, zajmuje pamięć na bufory i obiekty odpowiedzi, a często blokuje też połączenie do bazy danych lub miejsce w puli. Gdy wystarczająco dużo wolnych żądań się nawarstwi, twoje API przestaje robić użyteczną pracę, bo ograniczone zasoby siedzą i czekają.
Zazwyczaj odczujesz to w trzech miejscach. Goroutine się kumulują i rośnie narzut planowania, więc opóźnienia pogarszają się dla wszystkich. Pule połączeń do bazy danych kończą wolne sloty, więc nawet szybkie zapytania zaczynają stać w kolejce za wolnymi. Pamięć rośnie z powodu danych w locie i częściowo zbudowanych odpowiedzi, co zwiększa pracę GC.
Dodanie więcej serwerów często tego nie naprawia. Jeśli każda instancja trafia na ten sam wąski gardło (mała pula DB, jeden wolny upstream, współdzielone limity), po prostu przesuwasz kolejkę i płacisz więcej, podczas gdy błędy nadal rosną.
Wyobraź sobie handler, który rozgłasza pracę: ładuje użytkownika z PostgreSQL, wywołuje serwis płatności, a potem serwis rekomendacji. Jeśli wywołanie rekomendacji zawiesi się i nic go nie anuluje, żądanie nigdy się nie kończy. Połączenie do DB może zostać zwrócone, ale goroutine i zasoby klienta HTTP pozostają związane. Pomnóż to przez setki żądań i dostajesz powolne załamanie.
Cel jest prosty: ustal czytelny limit czasu, zatrzymaj pracę, gdy czas minie, zwolnij zasoby i zwróć przewidywalny błąd. Timeouty context w Go dają każdemu krokowi deadline, dzięki czemu praca przestaje, gdy użytkownik już nie czeka.
context.Context to mały obiekt, który przekazujesz w dół łańcucha wywołań, żeby każda warstwa zgadzała się co do jednej rzeczy: kiedy to żądanie musi się zatrzymać. Timeouty to powszechny sposób, by zapobiec przywiązaniu serwera do jednej wolnej zależności.
Kontekst może nieść trzy rodzaje informacji: deadline (kiedy praca musi się zatrzymać), sygnał anulowania (ktoś postanowił przerwać wcześniej) oraz kilka wartości związanych z żądaniem (używaj oszczędnie i nigdy nie trzymaj tam dużych danych).
Anulowanie nie jest magią. Kontekst udostępnia kanał Done(). Kiedy się zamyka, żądanie zostało anulowane lub skończył się czas. Kod, który respektuje kontekst, sprawdza Done() (często z select) i wraca wcześniej. Możesz też sprawdzić ctx.Err() by dowiedzieć się, dlaczego się zakończył — zwykle context.Canceled lub context.DeadlineExceeded.
Użyj context.WithTimeout żeby „zatrzymać po X sekundach”. Użyj context.WithDeadline gdy znasz już dokładny czas odcięcia. Użyj context.WithCancel gdy jakiś warunek rodzica powinien zatrzymać pracę (klient się rozłączył, użytkownik opuścił stronę, albo masz już odpowiedź).
Gdy kontekst jest anulowany, poprawne zachowanie jest nudne, ale ważne: przestań robić pracę, przestań czekać na wolne I/O i zwróć jasny błąd. Jeśli handler czeka na zapytanie do bazy danych i kontekst się kończy, wróć szybko i pozwól, by zapytanie zostało przerwane, jeśli sterownik/DB to obsługują.
Najbezpieczniejsze miejsce, by zatrzymywać wolne żądania, to granica, gdzie ruch wchodzi do usługi. Jeśli żądanie ma czasowi out, chcesz, żeby działo się to przewidywalnie i wcześnie — nie po tym, jak zdążyło zająć goroutine, połączenia do DB i pamięć.
Zacznij od krawędzi (load balancer, API gateway, reverse proxy) i ustaw twardy limit, jak długo każde żądanie może żyć. To chroni twój serwer Go nawet jeśli handler zapomni ustawić timeout.
W samym serwerze Go ustaw timeouty HTTP, żeby serwer nie czekał w nieskończoność na wolnego klienta lub zatrzymaną odpowiedź. Minimum to timeouty na odczyt nagłówków, odczyt całego ciała żądania, zapis odpowiedzi i utrzymywanie połączeń idle.
Wybierz domyślny budżet żądania dopasowany do produktu. Dla wielu API 1–3 sekundy to rozsądny punkt wyjścia dla typowych żądań, z wyższym limitem dla znanych wolnych operacji (np. eksporty). Dokładna liczba ma mniejsze znaczenie niż konsekwencja, pomiar i jasna zasada wyjątków.
Odpowiedzi strumieniowe wymagają dodatkowej uwagi. Łatwo przez pomyłkę stworzyć nieskończony strumień, gdzie serwer trzyma połączenie otwarte i zapisuje maleńkie porcje w nieskończoność, lub czeka w nieskończoność przed pierwszym bajtem. Zdecyduj zawczasu, czy endpoint jest naprawdę strumieniem. Jeśli nie jest, wymusz limit całkowitego czasu i limit czasu do pierwszego bajtu.
Gdy granica ma jasny deadline, łatwiej jest propagować go przez całe żądanie.
Najprostsze miejsce na start to handler HTTP. To punkt wejścia jednego żądania do systemu, więc naturalne miejsce na ustawienie twardego limitu.
Utwórz nowy kontekst z deadline i pamiętaj, aby go anulować. Przekaż ten kontekst do wszystkiego, co może się blokować: pracy w bazie danych, wywołań HTTP lub wolnych obliczeń.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
ctx do każdego blokującego wywołaniaDobra zasada: jeśli funkcja może czekać na I/O, powinna akceptować context.Context. Utrzymuj handlery czytelnymi, przenosząc szczegóły do małych helperów jak loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo powinno używać QueryRowContext/ExecContext
}
Jeśli deadline minie (albo klient się rozłączy), zakończ pracę i zwróć przyjazną odpowiedź. Częsta mapa to context.DeadlineExceeded -> 504 Gateway Timeout, a context.Canceled -> „klient odszedł” (często bez ciała odpowiedzi).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Klient się rozłączył. Unikaj dalszej pracy.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Ten wzorzec zapobiega nawarstwianiu się żądań. Gdy timer wygaśnie, każda funkcja respektująca context dalej w łańcuchu otrzyma ten sam sygnał stop i może szybko zakończyć pracę.
Gdy handler ma kontekst z deadline, najważniejsza zasada jest prosta: używaj tego samego ctx aż do wywołania bazy danych. Dzięki temu timeouty faktycznie zatrzymują pracę, a nie tylko przestają czekać twój handler.
W database/sql preferuj metody świadome kontekstu:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Jeśli budżet handlera to 2 sekundy, baza powinna dostać tylko część tego. Zostaw czas na kodowanie JSON, inne zależności i obsługę błędów. Prosty punkt startowy to przyznać Postgresowi 30–60% całego budżetu. Przy 2 sekundach to może być 800ms–1.2s.
Gdy kontekst zostanie anulowany, sterownik poprosi Postgresa o zatrzymanie zapytania. Zwykle połączenie wraca do puli i można je ponownie użyć. Jeśli anulowanie nastąpi podczas złego momentu sieciowego, sterownik może odrzucić to połączenie i później otworzyć nowe. W każdym wypadku unikasz goroutine czekającej w nieskończoność.
Sprawdzając błędy, traktuj timeouty inaczej niż zwykłe błędy DB. Jeśli errors.Is(err, context.DeadlineExceeded), zabrakło czasu i powinieneś zwrócić timeout. Jeśli errors.Is(err, context.Canceled), klient odszedł i powinieneś spokojnie przerwać pracę. Inne błędy to normalne problemy zapytań (błędny SQL, brak wiersza, uprawnienia).
Jeśli handler ma deadline, twoje wyjściowe wywołania HTTP też powinny go respektować. W przeciwnym razie klient się podda, ale twój serwer będzie nadal czekał na wolny upstream i będzie blokował goroutine, gniazda i pamięć.
Buduj wychodzące requesty z nadrzędnym kontekstem, żeby anulowanie przeszło automatycznie:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
Ten per-call timeout to siatka bezpieczeństwa. Nadrzędny deadline nadal jest przełożonym. Jedna zegarowa granica dla całego żądania, plus mniejsze limity dla ryzykownych kroków.
Skonfiguruj też timeouty na poziomie transportu. Kontekst anuluje request, ale timeouty transportu chronią przed wolnymi handshake'ami i serwerami, które nigdy nie wysyłają nagłówków.
Jeden szczegół, który potrafi zespołom spłatać figla: ciała odpowiedzi muszą być zamykane na każdej ścieżce. Jeśli wracasz wcześniej (sprawdzenie statusu, błąd dekodowania JSON, timeout kontekstu), i tak zamknij body. Wycieki body mogą cicho wyczerpać połączenia w puli i zamienić się w „losowe” skoki latencji.
Konkretna scena: twoje API wywołuje dostawcę płatności. Klient time'outuje po 2 sekundach, ale upstream wisi przez 30 sekund. Bez anulowania requestu i timeoutów transportu płacisz za te 30 sekund czekania dla każdego porzuconego żądania.
Jedno żądanie zwykle dotyka więcej niż jednej powolnej rzeczy: praca handlera, zapytanie do bazy i jedno lub więcej wywołań zewnętrznych. Jeśli każdemu krokowi dasz hojne timeouty, całkowity czas cicho rośnie, aż użytkownicy to poczują i serwer zacznie się zapychać.
Budżetowanie jest najprostszą naprawą. Ustaw jeden nadrzędny deadline dla całego żądania, a następnie daj każdej zależności mniejszy kawałek. Deadliny potomków powinny być wcześniejsze niż rodzica, żebyś mógł szybko niepowieść i wciąż mieć czas na zwrócenie czystego błędu.
Zasady, które sprawdzają się w realnych usługach:
Unikaj nakładania timeoutów, które ze sobą walczą. Jeśli handler ma deadline 2s, a klient HTTP ma timeout 10s, jesteś bezpieczny choć to mylące. Jeśli jest odwrotnie, klient może odciąć pracę wcześniej z innych powodów.
Dla pracy w tle (logi audytu, metryki, emaile) nie używaj kontekstu żądania. Użyj osobnego kontekstu z własnym krótkim timeoutem, by anulowanie przez klienta nie zabijało istotnego sprzątania.
Większość bugów związanych z timeoutami nie jest w handlerze. Pojawiają się jedną lub dwie warstwy niżej, gdzie deadline cicho ginie. Jeśli ustawisz timeout na krawędzi, ale zignorujesz go gdzieś pośrodku, nadal możesz mieć goroutine, zapytania DB lub wywołania HTTP, które będą działać dalej po tym, jak klient odszedł.
Najczęściej pojawiające się wzorce są proste:
context.Background() (albo TODO). To odłącza pracę od anulowania klienta i deadline handlera.sleep, retry lub pętle bez sprawdzania ctx.Done(). Żądanie jest anulowane, ale twój kod dalej czeka.context.WithTimeout. Kończy się wieloma timerami i mylącymi deadline'ami.ctx do blokujących wywołań (DB, HTTP, publikacja wiadomości). Timeout handlera nic nie da, jeśli zależność go ignoruje.Klasyczna porażka: dodajesz 2s timeout w handlerze, a repo używa context.Background() dla zapytania DB. Pod obciążeniem wolne zapytanie nadal działa nawet po tym, jak klient się poddał, i kolejka rośnie.
Napraw podstawy: przekaż ctx jako pierwszy argument przez stos wywołań. W długiej pracy dodaj szybkie sprawdzenia jak select { case <-ctx.Done(): return ctx.Err() default: }. Mapuj context.DeadlineExceeded na odpowiedź timeout (często 504), a context.Canceled na styl odpowiedzi klient-odszedł (często 408 lub 499 zależnie od konwencji).
Timeouty pomagają tylko wtedy, gdy możesz je zobaczyć i potwierdzić, że system się ładnie odzyskuje. Gdy coś jest wolne, żądanie powinno się zatrzymać, zasoby powinny zostać zwolnione, a API powinno pozostać responsywne.
Dla każdego żądania loguj ten sam mały zestaw pól, aby móc porównywać normalne żądania vs timeouty. Dołącz deadline kontekstu (jeśli istnieje) i co zakończyło pracę.
Przydatne pola to deadline (albo "none"), całkowity czas wykonania, powód anulowania (timeout vs client canceled), krótka etykieta operacji ("db.query users", "http.call billing") i request ID.
Minimalny wzorzec wygląda tak:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
Logi pomagają debugować pojedyncze żądanie. Metryki pokazują trendy.
Śledź kilka sygnałów, które zwykle rosną wcześnie, gdy timeouty są nieprawidłowe: liczba timeoutów po trasie i zależności, ilość żądań w locie (powinna się ustabilizować pod obciążeniem), czas oczekiwania w puli DB oraz percentyle latencji (p95/p99) rozdzielone na sukcesy vs timeouty.
Spraw, by wolność była przewidywalna. Dodaj debugowy delay do jednego handlera, spowolnij zapytanie DB celowo, lub opakuj zewnętrzne wywołanie testowym serwerem, który śpi. Potem zweryfikuj dwie rzeczy: widzisz błąd timeout i praca zatrzymuje się szybko po anulowaniu.
Mały test obciążeniowy też się przyda. Uruchom 20–50 równoczesnych żądań przez 30–60 sekund z jedną wymuszoną wolną zależnością. Liczba goroutine i żądań w locie powinna wzrosnąć, a potem się ustabilizować. Jeśli ciągle rośnie, coś ignoruje anulowanie kontekstu.
Timeouty pomagają tylko, jeśli są stosowane wszędzie, gdzie żądanie może czekać. Zrób jednorazowe przeglądnięcie kodu i upewnij się, że te same zasady są przestrzegane w każdym handlerze.
context.DeadlineExceeded i context.Canceled.http.NewRequestWithContext (lub req = req.WithContext(ctx)) i klient ma timeouty transportu (dial, TLS, response header). Unikaj polegania na http.DefaultClient w ścieżkach produkcyjnych.Krótki "slow dependency" drill przed wydaniem jest tego wart. Dodaj sztuczne 2s opóźnienie do jednego zapytania SQL i potwierdź trzy rzeczy: handler zwraca na czas, zapytanie DB naprawdę się zatrzymiało (nie tylko handler), i logi jasno mówią, że to był DB timeout.
Wyobraź sobie endpoint GET /v1/account/summary. Jedna akcja użytkownika uruchamia trzy rzeczy: zapytanie PostgreSQL (konto + ostatnia aktywność) i dwa wywołania HTTP (np. sprawdzenie statusu płatności i wzbogacenie profilu).
Daj całemu żądaniu twardy budżet 2 sekund. Bez budżetu jedna wolna zależność może trzymać goroutine, połączenia DB i pamięć, aż twoje API zacznie timeoutować wszędzie.
Prosty podział to 800ms na DB, 600ms na wywołanie zewnętrzne A i 600ms na wywołanie zewnętrzne B.
Gdy znasz deadline, przekaż go w dół. Każda zależność dostaje swój mniejszy timeout, ale nadal dziedziczy anulowanie od rodzica.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Jeśli zewnętrzne wywołanie B spowalnia i zajmuje 2.5 sekundy, twój handler powinien przestać czekać po 600ms, anulować pracę w locie i zwrócić jasny timeout klientowi. Klient dostaje szybkie niepowodzenie zamiast zawieszonego spinnera.
W logach powinno być jasne, co wykorzystało budżet: DB skończył szybko, zewnętrzne A się powiodło, zewnętrzne B przekroczyło swój limit i zwróciło context deadline exceeded.
Gdy jeden endpoint realnie dobrze działa z timeoutami i anulowaniem, zamień to w powtarzalny wzorzec. Zastosuj end-to-end: deadline w handlerze, wywołania DB i outbound HTTP. Potem kopiuj tę samą strukturę do kolejnych endpointów.
Pójdzie szybciej, jeśli scentralizujesz nudne części: helper do timeoutów na granicy, wrappery zapewniające przekazanie ctx do DB i HTTP oraz jedna spójna mapa błędów i format logów.
Jeśli chcesz szybko prototypować ten wzorzec, Koder.ai (koder.ai) może wygenerować handlery Go i wywołania serwisów na podstawie promptu w czacie, a następnie wyeksportować kod źródłowy, żebyś mógł dodać swoje middleware do deadline'ów, logów i mapowania błędów. Cel to konsekwencja: wolne wywołania zatrzymują się wcześnie, błędy wyglądają tak samo i debugowanie nie zależy od tego, kto napisał endpoint.
Wolne żądanie zajmuje ograniczone zasoby podczas oczekiwania: goroutine, pamięć na bufory i obiekty odpowiedzi oraz często połączenie z bazą danych lub połączenie HTTP. Gdy wiele takich żądań czeka jednocześnie, tworzą się kolejki, opóźnienia rosną dla wszystkich żądań, a usługa może zacząć zawodzić mimo że każde żądanie z osobna w końcu by się zakończyło.
Ustaw jasny deadline na granicy żądania (proxy/gateway i w serwerze Go), stwórz w handlerze kontekst z limitem czasu i przekaż ten ctx do każdego blokującego wywołania (baza danych i outbound HTTP). Gdy deadline minie, szybko zwróć spójną odpowiedź timeout i przerwij pracę, którą można anulować.
Użyj context.WithTimeout(parent, d) gdy chcesz „zatrzymaj po upływie tej długości czasu” — to najczęstszy przypadek w handlerach. Użyj context.WithDeadline(parent, t) gdy masz już konkretny czas graniczny do zachowania. Użyj context.WithCancel(parent) gdy jakiś warunek wewnętrzny powinien wcześniej przerwać pracę, np. gdy „mamy już odpowiedź” lub „klient się rozłączył”.
Zawsze wywołuj funkcję cancel (zwykle defer cancel() bezpośrednio po utworzeniu pochodnego kontekstu). Anulowanie zwalnia timer i daje jasny sygnał zatrzymania pracy dzieciom, szczególnie w ścieżkach, które wracają wcześniej niż upłynie deadline.
Stwórz kontekst w handlerze i przekaż go dalej jako pierwszy argument do funkcji, które mogą się blokować. Szybkim sposobem na sprawdzenie problemów jest wyszukanie context.Background() lub context.TODO() w ścieżkach obsługi żądań — one często przerywają propagację anulowania i deadline.
Używaj metod QueryContext, QueryRowContext oraz ExecContext (albo ich odpowiedników w twoim driverze). Gdy kontekst się zakończy, driver poprosi Postgresa o anulowanie zapytania, więc nie będziesz tracić czasu i połączeń po stronie serwera.
Dołącz nadrzędny kontekst żądania do wyjściowego requestu za pomocą http.NewRequestWithContext(ctx, ...) i skonfiguruj również timeouty transportu klienta, aby chronić się przy łączeniu, TLS i oczekiwaniu na nagłówki. Nawet przy błędach czy nie-200 odpowiedziach zawsze zamykaj resp.Body, żeby połączenia wracały do puli.
Najpierw wybierz całkowity budżet żądania, potem podziel go na mniejsze kawałki dla zależności, zostawiając mały margines na overhead handlera i formatowanie odpowiedzi. Jeśli nadrzędny kontekst ma mało czasu, nie zaczynaj kosztownej pracy, która nie zdąży przed deadline.
Często mapuje się context.DeadlineExceeded na 504 Gateway Timeout z krótkim komunikatem typu „request timed out”. context.Canceled zwykle oznacza, że klient się rozłączył — najlepszą akcją jest zakończyć pracę i zwykle nie wysyłać ciała odpowiedzi, żeby nie marnować zasobów.
Najczęstsze błędy to: porzucenie kontekstu żądania przez użycie context.Background(), uruchamianie retry/sleep/pętli bez sprawdzania ctx.Done(), zapominanie o dołączeniu ctx do blokujących wywołań, oraz niespójne nakładanie wielu timeoutów w różnych warstwach, co utrudnia zrozumienie, który timeout wygrał.