Go-Context-Timeouts verhindern, dass langsame DB-Aufrufe und externe Requests Ressourcen blockieren. Lerne Deadline-Propagation, Abbruchverhalten und sinnvolle Defaults.

Eine einzelne langsame Anfrage ist selten "einfach nur langsam." Während sie wartet, hält sie eine Goroutine am Leben, belegt Speicher für Puffer und Antwortobjekte und nimmt oft eine Datenbankverbindung oder einen Platz in einem Pool ein. Wenn sich genug langsame Anfragen ansammeln, kann deine API keine nützliche Arbeit mehr leisten, weil ihre knappen Ressourcen blockiert sind.
Man merkt es meist an drei Dingen. Goroutines häufen sich und der Scheduling-Overhead steigt, sodass die Latenz für alle schlechter wird. Datenbankpools laufen leer, sodass selbst schnelle Abfragen hinter langsamen warten müssen. Der Speicher wächst wegen unterwegs befindlicher Daten und teilweise aufgebauter Antworten, was die GC-Arbeit erhöht.
Mehr Server bringen oft keine Lösung. Wenn jede Instanz denselben Engpass trifft (ein kleiner DB-Pool, ein langsames Upstream, gemeinsame Rate-Limits), verschiebst du nur die Warteschlange und zahlst mehr, während Fehler weiter steigen.
Stell dir einen Handler vor, der ausfächert: er lädt einen Nutzer aus PostgreSQL, ruft einen Zahlungsdienst auf und dann einen Empfehlungsdienst. Wenn der Empfehlungsaufruf hängt und nichts ihn abbricht, endet die Anfrage nie. Die DB-Verbindung wird vielleicht zurückgegeben, aber Goroutine- und HTTP-Client-Ressourcen bleiben gebunden. Multipliziere das mit Hunderten von Anfragen und du bekommst einen langsamen Zusammenbruch.
Das Ziel ist einfach: setze eine klare Zeitgrenze, beende Arbeit, wenn die Zeit abgelaufen ist, gib Ressourcen frei und liefere einen vorhersehbaren Fehler. Go-Context-Timeouts geben jedem Schritt eine Deadline, sodass Arbeit stoppt, wenn der Nutzer nicht mehr wartet.
Ein context.Context ist ein kleines Objekt, das du durch deine Aufrufkette reichst, damit jede Ebene einer Sache zustimmt: wann diese Anfrage beendet werden muss. Timeouts sind die übliche Methode, um zu verhindern, dass eine langsame Abhängigkeit deinen Server blockiert.
Ein Context kann drei Arten von Informationen tragen: eine Deadline (wann die Arbeit stoppen muss), ein Abbruchsignal (jemand hat entschieden, früher zu stoppen) und ein paar an die Anfrage gebundene Werte (sparsam verwenden und niemals für große Daten).
Abbruch ist keine Magie. Ein Context bietet einen Done()-Channel. Wenn er geschlossen wird, ist die Anfrage abgebrochen oder die Zeit abgelaufen. Code, der Context respektiert, prüft Done() (oft mit einem select) und kehrt frühzeitig zurück. Du kannst auch ctx.Err() prüfen, um zu erfahren, warum er beendet wurde — meist context.Canceled oder context.DeadlineExceeded.
Verwende context.WithTimeout für „nach X Sekunden stoppen“. Nutze context.WithDeadline, wenn du bereits den genauen Stichtag kennst. context.WithCancel eignet sich, wenn eine übergeordnete Bedingung die Arbeit stoppen soll (Client getrennt, Nutzer navigiert weg, du hast bereits die Antwort).
Wenn ein Context abgebrochen wird, ist das richtige Verhalten unspektakulär, aber wichtig: Arbeit stoppen, nicht weiter auf langsame I/O warten und einen klaren Fehler zurückgeben. Wenn ein Handler auf eine Datenbankabfrage wartet und der Context endet, kehre schnell zurück und lasse die Abfrage abbrechen, falls sie Context unterstützt.
Der sicherste Ort, langsame Anfragen zu stoppen, ist die Grenze, an der Traffic in deinen Service eintritt. Wenn eine Anfrage timeouten soll, willst du, dass das vorhersehbar und früh geschieht — nicht erst, nachdem Goroutines, DB-Verbindungen und Speicher gebunden wurden.
Fange an der Edge an (Load Balancer, API Gateway, Reverse Proxy) und setze eine harte Obergrenze dafür, wie lange eine Anfrage leben darf. Das schützt deinen Go-Service, selbst wenn ein Handler vergisst, ein Timeout zu setzen.
Innerhalb deines Go-Servers konfiguriere HTTP-Timeouts, damit der Server nicht ewig auf einen langsamen Client oder eine blockierte Antwort wartet. Mindestens solltest du Timeouts für das Lesen der Header, das Lesen des kompletten Request-Bodys, das Schreiben der Antwort und das Halten von Idle-Verbindungen konfigurieren.
Wähle ein Standard-Anfragebudget, das zu deinem Produkt passt. Für viele APIs sind 1 bis 3 Sekunden ein vernünftiger Startpunkt für typische Requests, mit höheren Grenzwerten für bekannte langsame Vorgänge wie Export-Operationen. Die genaue Zahl ist weniger wichtig als Konsistenz, Messen und klare Regeln für Ausnahmen.
Streaming-Antworten brauchen besondere Aufmerksamkeit. Es ist leicht, unbeabsichtigt einen endlosen Stream zu erzeugen, bei dem der Server die Verbindung offenhält und kleine Chunks schreibt oder ewig vor dem ersten Byte wartet. Entscheide im Vorfeld, ob ein Endpoint wirklich ein Stream ist. Wenn nicht, erzwinge eine maximale Gesamtlaufzeit und eine maximale Zeit-bis-erstes-Byte.
Hast du an der Grenze eine klare Deadline, ist es viel einfacher, diese durch die ganze Anfrage zu propagieren.
Der einfachste Startpunkt ist der HTTP-Handler. Hier betritt eine Anfrage dein System, deshalb ist es ein natürlicher Ort, ein hartes Limit zu setzen.
Erzeuge einen neuen Context mit einer Deadline und rufe die zugehörige Cancel-Funktion sicher auf. Gib diesen Context an alles weiter, was blockieren könnte: Datenbankarbeit, HTTP-Aufrufe oder langsame Berechnungen.
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)
}
Eine gute Regel: Wenn eine Funktion auf I/O warten kann, sollte sie einen context.Context akzeptieren. Halte Handler lesbar, indem du Details in kleine Helfer wie loadUser auslagerst.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
Wenn die Deadline erreicht ist (oder der Client getrennt wurde), stoppe die Arbeit und gib eine nutzerfreundliche Antwort zurück. Eine übliche Abbildung ist context.DeadlineExceeded auf 504 Gateway Timeout und context.Canceled auf „Client gone“ (häufig ohne Antwortkörper).
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) {
// Client went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Dieses Muster verhindert das Aufstauen. Sobald der Timer abläuft, erhalten alle kontextbewussten Funktionen in der Kette dasselbe Stoppsignal und können schnell beenden.
Hat dein Handler einmal einen Context mit Deadline, gilt die wichtigste Regel: Verwende denselben ctx bis hinein in den Datenbankaufruf. So stoppen Timeouts Arbeit, anstatt nur deinen Handler daran zu hindern zu warten.
Mit database/sql bevorzuge die context-aware Methoden:
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
}
}
Wenn das Handlerbudget 2 Sekunden beträgt, sollte die Datenbank nur einen Teil davon erhalten. Lasse Zeit für JSON-Encoding, andere Abhängigkeiten und Fehlerbehandlung. Ein einfacher Ausgangspunkt ist, Postgres 30–60% des Gesamtbudgets zu geben. Bei 2 Sekunden wären das vielleicht 800ms bis 1,2s.
Wenn der Context abgebrochen wird, sagt der Treiber Postgres, die Abfrage zu stoppen. Normalerweise kehrt die Verbindung in den Pool zurück und kann wiederverwendet werden. Erfolgt die Abbruchaktion während eines Netzwerkfehlers, verwirft der Treiber die Verbindung und öffnet später eine neue. So vermeidest du eine Goroutine, die ewig wartet.
Behandle Timeout-Fehler anders als echte DB-Fehler. Wenn errors.Is(err, context.DeadlineExceeded) ist, bist du aus der Zeit gekommen und solltest ein Timeout zurückgeben. Ist errors.Is(err, context.Canceled), ist der Client weg und du solltest leise stoppen. Andere Fehler sind normale Abfrageprobleme (fehlerhaftes SQL, fehlende Zeile, Berechtigungen).
Hat dein Handler eine Deadline, sollten auch ausgehende HTTP-Aufrufe sie respektieren. Andernfalls gibt der Client auf, aber dein Server wartet weiter auf ein langsames Upstream und bindet Goroutines, Sockets und Speicher.
Baue ausgehende Anfragen mit dem übergeordneten Context, damit sich Cancellation automatisch fortpflanzt:
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)
}
Dieses per-Aufruf-Timeout ist ein Sicherheitsnetz. Die übergeordnete Request-Deadline bleibt der echte Chef. Eine Uhr fürs ganze Request plus kleinere Deckel für riskante Schritte.
Setze außerdem Timeouts auf Transportebene. Context bricht die Anfrage ab, aber Transport-Timeouts schützen vor langsamen Handshakes und Servern, die niemals Header senden.
Ein Detail, das Teams oft überrascht: Response-Bodies müssen auf jedem Pfad geschlossen werden. Wenn du früher zurückkehrst (Statuscode-Check, JSON-Decode-Fehler, Context-Timeout), schließe trotzdem den Body. Das Leaken von Bodies kann Verbindungen im Pool heimlich erschöpfen und zu "zufälligen" Latenzspitzen führen.
Ein konkretes Szenario: Deine API ruft einen Zahlungsanbieter auf. Der Client wartet 2 Sekunden, aber das Upstream hängt 30 Sekunden. Ohne Request-Cancellation und Transport-Timeouts wartest du diese 30 Sekunden für jede verlassene Anfrage.
Eine einzelne Anfrage berührt meist mehr als eine langsame Sache: Handler-Arbeit, eine Datenbankabfrage und ein oder mehrere externe APIs. Wenn du jedem Schritt ein großzügiges Timeout gibst, wächst die Gesamtlaufzeit stillschweigend, bis Nutzer es spüren und dein Server sich aufstaut.
Budgetierung ist die einfachste Lösung. Setze eine übergeordnete Deadline für das ganze Request und gib jeder Abhängigkeit ein kleineres Stück. Kind-Deadlines sollten früher liegen als die des Parents, damit du schnell scheiterst und trotzdem Zeit hast, einen sauberen Fehler zurückzugeben.
Faustregeln, die in echten Services halten:
Vermeide das Stapeln von Timeouts, die sich gegenseitig bekämpfen. Hat dein Handler-Context 2 Sekunden und dein HTTP-Client ein Timeout von 10 Sekunden, bist du sicher, aber es ist verwirrend. Ist es andersherum, kann der Client frühzeitig aussteigen.
Für Hintergrundarbeit (Audit-Logs, Metriken, E-Mails) verwende nicht den Request-Context. Nutze einen separaten Context mit eigenem kurzen Timeout, damit Client-Abbrüche wichtige Aufräumarbeiten nicht verhindern.
Die meisten Timeout-Bugs sind nicht im Handler. Sie passieren ein oder zwei Ebenen tiefer, wo die Deadline stillschweigend verloren geht. Setzt du Timeouts an der Edge, aber ignorierst sie in der Mitte, endest du trotzdem mit Goroutines, DB-Abfragen oder HTTP-Aufrufen, die weiterlaufen, nachdem der Client weg ist.
Die Muster, die am häufigsten auftauchen, sind simpel:
context.Background() (oder TODO) aufrufen. Das trennt die Arbeit vom Client-Abbruch und der Handler-Deadline.ctx.Done(). Die Anfrage ist abgebrochen, aber dein Code wartet weiter.context.WithTimeout hüllen. Am Ende hast du viele Timer und verwirrende Deadlines.ctx an blockierende Aufrufe zu hängen (DB, ausgehende HTTP-Anfragen, Message-Publishes). Ein Handler-Timeout nützt nichts, wenn die Abhängigkeit ihn ignoriert.Ein klassisches Versagen: Du fügst im Handler ein 2-Sekunden-Timeout hinzu, aber dein Repository verwendet context.Background() für die DB-Abfrage. Unter Last läuft eine langsame Abfrage weiter, auch nachdem der Client aufgegeben hat, und die Warteschlange wächst.
Behebe die Grundlagen: übergib ctx als ersten Parameter durch den Call-Stack. In langer Arbeit füge kurze Prüfungen wie select { case <-ctx.Done(): return ctx.Err() default: } hinzu. Mappe context.DeadlineExceeded auf eine Timeout-Antwort (oft 504) und context.Canceled auf eine Client-Cancel-Antwort (je nach Konvention 408 oder 499).
Timeouts helfen nur, wenn du sie sehen kannst und bestätigen kannst, dass das System sauber wiederherstellt. Wenn etwas langsam ist, sollte die Anfrage stoppen, Ressourcen sollten frei werden und die API reaktionsfähig bleiben.
Logge für jede Anfrage dieselbe kleine Menge Felder, damit du normale Anfragen und Timeouts vergleichen kannst. Schließe die Context-Deadline (falls vorhanden) und was die Arbeit beendet hat mit ein.
Nützliche Felder sind Deadline (oder "none"), gesamte Laufzeit, Abbruchgrund (Timeout vs Client canceled), ein kurzer Operations-Label ("db.query users", "http.call billing") und eine Request-ID.
Ein minimales Muster sieht so aus:
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)
Logs helfen beim Debugging einzelner Anfragen. Metriken zeigen Trends.
Erfasse Signale, die typischerweise früh ansteigen, wenn Timeouts falsch sind: Anzahl der Timeouts nach Route und Abhängigkeit, In-Flight-Requests (sollten sich unter Last einpendeln), DB-Pool-Wartezeit und Latenz-Percentiles (p95/p99) getrennt nach Erfolg vs Timeout.
Mach Langsamkeit vorhersehbar. Füge einen debug-only Delay zu einem Handler hinzu, verlangsamen eine DB-Abfrage gezielt oder nutze einen Testserver, der bei ausgehenden Calls schläft. Verifiziere dann zwei Dinge: du siehst den Timeout-Fehler, und die Arbeit stoppt kurz nach der Cancellation.
Ein kleiner Lasttest hilft auch: Starte 20–50 gleichzeitige Requests für 30–60 Sekunden mit einer erzwungen langsamen Abhängigkeit. Goroutine-Anzahl und In-Flight-Requests sollten ansteigen und dann stabil bleiben. Wenn sie weiter steigen, ignoriert etwas Context-Cancellation.
Timeouts helfen nur, wenn sie überall dort angewendet werden, wo eine Anfrage warten kann. Mach vor dem Deployment einen Durchgang durch den Code und bestätige dieselben Regeln in jedem Handler.
context.DeadlineExceeded und context.Canceled.http.NewRequestWithContext (oder req = req.WithContext(ctx)) und der Client hat Transport-Timeouts (Dial, TLS, Response Header). Vermeide http.DefaultClient in Produktionspfaden.Eine kurze "slow dependency"-Übung vor Release lohnt sich. Füge eine künstliche 2-Sekunden-Verzögerung in eine SQL-Abfrage ein und bestätige drei Dinge: der Handler antwortet rechtzeitig, die DB-Abfrage stoppt tatsächlich (nicht nur der Handler) und deine Logs zeigen klar, dass es ein DB-Timeout war.
Stell dir einen Endpoint wie GET /v1/account/summary vor. Eine Benutzeraktion löst drei Dinge aus: eine PostgreSQL-Abfrage (Account plus letzte Aktivitäten) und zwei externe HTTP-Aufrufe (z. B. Billing-Status und Profilanreicherung).
Gib der gesamten Anfrage ein hartes Budget von 2 Sekunden. Ohne Budget kann eine langsame Abhängigkeit Goroutines, DB-Verbindungen und Speicher so lange fesseln, bis deine API überall zeitlich ausgelastet ist.
Eine einfache Aufteilung könnte 800ms für die DB-Abfrage, 600ms für externen Aufruf A und 600ms für externen Aufruf B sein.
Hast du die Gesamtdeadline, gib sie weiter. Jede Abhängigkeit bekommt ihr eigenes kleineres Timeout, erbt aber weiterhin Cancellation vom Parent.
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.
}
Wenn externer Aufruf B langsam wird und 2,5 Sekunden braucht, sollte dein Handler nach 600ms aufhören zu warten, die laufende Arbeit abbrechen und eine klare Timeout-Antwort an den Client zurückgeben. Der Client sieht ein schnelles Scheitern statt eines hängenden Ladeindikators.
Deine Logs sollten deutlich machen, was das Budget verbraucht hat: DB schnell, extern A erfolgreich, extern B traf seine Grenze und gab context deadline exceeded zurück.
Wenn ein realer Endpoint gut mit Timeouts und Cancellation funktioniert, mache daraus ein wiederholbares Muster. Wende es Ende-zu-Ende an: Handler-Deadline, DB-Aufrufe und ausgehende HTTP-Aufrufe. Kopiere dann dieselbe Struktur auf den nächsten Endpoint.
Du arbeitest schneller, wenn du die langweiligen Teile zentralisierst: ein Boundary-Timeout-Helper, Wrapper, die sicherstellen, dass ctx an DB- und HTTP-Aufrufe weitergegeben wird, und eine einheitliche Error-Mapping- und Log-Formatvorlage.
Wenn du dieses Muster schnell prototypen willst, kann Koder.ai (koder.ai) Go-Handler und Service-Calls aus einem Chat-Prompt generieren; du kannst den Quellcode exportieren und deine eigenen Timeout-Helper und Budgets anwenden. Das Ziel ist Konsistenz: langsame Aufrufe stoppen früh, Fehler sehen gleich aus und Debugging hängt nicht davon ab, wer den Endpoint geschrieben hat.
Eine langsame Anfrage belegt während des Wartens knappe Ressourcen: eine Goroutine, Speicher für Puffer und Antwortobjekte und oft eine Datenbank- oder HTTP-Verbindung. Wenn genug Anfragen gleichzeitig warten, bilden sich Warteschlangen, die Latenz steigt für den gesamten Traffic, und der Dienst kann ausfallen, obwohl jede einzelne Anfrage irgendwann fertig würde.
Setze ein klares Zeitlimit an der Grenze der Anfrage (Proxy/Gateway und im Go-Server), leite im Handler einen zeitbegrenzten Context ab und gib dieses ctx an jede blockierende Funktion weiter (Datenbank und ausgehende HTTP-Aufrufe). Wenn die Frist abläuft, antworte schnell mit einer konsistenten Timeout-Antwort und stoppe laufende, kontext-unterstützte Arbeiten.
Verwende context.WithTimeout(parent, d), wenn du „nach dieser Dauer stoppen“ willst — das ist in Handlern am gebräuchlichsten. Nutze context.WithDeadline(parent, t), wenn du bereits einen festen Stichtag hast. Verwende context.WithCancel(parent), wenn eine interne Bedingung die Arbeit vorzeitig beenden soll (z. B. weil du schon eine Antwort hast oder der Client getrennt wurde).
Rufe die Cancel-Funktion immer auf, typischerweise direkt nach Erzeugung des abgeleiteten Contexts mit defer cancel(). Das gibt den Timer frei und sendet ein deutliches Stoppsignal an Kinder, besonders auf Pfaden, die vor Ablauf der Frist frühzeitig zurückkehren.
Erzeuge den Request-Context einmal im Handler und gib ihn als ersten Parameter an alle Funktionen weiter, die blockieren können. Ein schneller Check ist die Suche nach context.Background() oder context.TODO() in Pfaden, die zu einer Anfrage gehören — diese trennen oft die Arbeit vom Deadline-Signal.
Nutze kontext-aware Methoden der Datenbank wie QueryContext, QueryRowContext und ExecContext (oder die entsprechenden Methoden deines Treibers). Wenn der Context endet, kann der Treiber Postgres anweisen, die Abfrage abzubrechen, sodass du nicht weiter Zeit und Verbindungen verbrennst, nachdem die Anfrage vorbei ist.
Hänge den übergeordneten Request-Context an die ausgehende Anfrage mit http.NewRequestWithContext(ctx, ...) und konfiguriere zusätzlich Client-/Transport-Timeouts (Dial, TLS, Response Header). Schließe bei Fehlern oder Nicht-200-Antworten immer den Response-Body, damit Verbindungen in den Pool zurückkehren.
Wähle zuerst ein Gesamtbudget für die Anfrage und teile dann die verbleibende Zeit auf die Abhängigkeiten auf, wobei du einen kleinen Puffer für Handler-Overhead und Response-Encoding lässt. Wenn der übergeordnete Context nur noch wenig Zeit übrig hat, starte keine teuren Aufrufe, die realistischerweise nicht vor Ablauf fertig werden.
Ein übliches Mapping ist context.DeadlineExceeded zu 504 Gateway Timeout mit einer kurzen Nachricht wie „request timed out“. context.Canceled bedeutet meist, dass der Client getrennt wurde; häufig ist die beste Aktion, die Arbeit zu beenden und ohne Body zurückzukehren, damit keine Ressourcen verschwendet werden.
Am häufigsten werden der Request-Context fallen gelassen, indem man context.Background() verwendet, oder man startet Sleeps/Repeats ohne ctx.Done() zu prüfen. Weitere Fehlerquellen sind, überall eigene Timeouts zu setzen (viele Timer machen Deadlines schwer verständlich) und das Vergessen, ctx an blockierende Aufrufe zu übergeben. Diese Muster führen dazu, dass Abbruchsignale nicht ankommen.