Go context-timeouts voor API's: van HTTP-handlers tot SQL
Go context-timeouts voor API's helpen deadlines door te geven van HTTP-handlers naar SQL, voorkomen vastlopende requests en houden services stabiel onder load.

Waarom requests vastlopen (en waarom dat onder load pijn doet)
Een request raakt “vast” wanneer het wacht op iets dat niet terugkeert: een trage databasequery, een geblokkeerde verbinding uit de pool, een DNS-hiccup of een upstream-service die de oproep accepteert maar nooit antwoordt.
Het symptoom lijkt eenvoudig: sommige requests duren eindeloos, en er stapelen zich meer en meer achter hen op. Vaak zie je toenemend geheugengebruik, een groeiend aantal goroutines en een rij open verbindingen die maar niet leegloopt.
Onder load doet een vastgelopen request twee keer kwaad. Ze houden workers bezet en ze houden schaarse resources vast zoals databaseverbindingen en locks. Dat maakt normaal snelle requests langzaam, wat weer meer overlap creëert, en nog meer wachten.
Retries en verkeerspieken verergeren deze spiraal. Een client timet uit en retryt terwijl het oorspronkelijke request nog draait, dus betaal je nu voor twee requests. Vermenigvuldig dat met veel clients tijdens een korte vertraging, en je kunt de database overbelasten of connection limits raken, zelfs als het gemiddelde verkeer prima is.
Een timeout is simpel gezegd een belofte: “we wachten niet langer dan X.” Het helpt je snel te falen en resources vrij te geven, maar het maakt werk niet sneller af.
Het garandeert ook niet dat het werk meteen stopt. De database kan doorgaan met uitvoeren, een upstream-service kan je annulering negeren, of je eigen code is mogelijk niet veilig voor annulering.
Wat een timeout wel garandeert, is dat je handler kan stoppen met wachten, een duidelijke fout kan teruggeven en kan vrijgeven wat hij vasthoudt. Die begrensde wachttijd voorkomt dat een paar trage calls uitgroeien tot een volledige outage.
Het doel met Go context timeouts is één gedeelde deadline van de rand tot aan de diepste call. Stel het eenmaal in bij de HTTP-grens, geef dezelfde context door door je servicecode en gebruik het in database/sql-calls zodat de database ook te horen krijgt wanneer hij moet stoppen met wachten.
Context in Go in gewone taal
Een context.Context is een klein object dat je door je code meegeeft om te beschrijven wat er nu gebeurt. Het beantwoordt vragen als: “Is dit request nog geldig?”, “Wanneer moeten we opgeven?” en “Welke request-gescopeerde waarden gaan mee met dit werk?”
De grote winst is dat één beslissing aan de rand van je systeem (je HTTP-handler) elke downstream stap kan beschermen, zolang je dezelfde context blijft doorgeven.
Wat context meedraagt
Context is geen plek voor businessdata. Het is bedoeld voor controlesignalen en een kleine hoeveelheid request-scope: cancelatie, een deadline/timeout en kleine metadata zoals een request-id voor logs.
Timeout versus cancelatie is eenvoudig: een timeout is één reden voor cancelatie. Als je een timeout van 2 seconden instelt, wordt de context geannuleerd na 2 seconden. Maar een context kan ook eerder geannuleerd worden als de gebruiker het tabblad sluit, de load balancer de verbinding verbreekt of je code besluit dat het request moet stoppen.
Context stroomt door functieroeps heen als een expliciet parameter, meestal de eerste: func DoThing(ctx context.Context, ...). Dat is het punt. Het is moeilijk om het te “vergeten” wanneer het bij elke call zichtbaar is.
Wanneer de deadline verloopt, moet alles dat naar die context kijkt snel stoppen. Bijvoorbeeld: een databasequery met QueryContext zou vroeg moeten terugkeren met een fout zoals context deadline exceeded, en je handler kan reageren met een timeout in plaats van te hangen totdat de server door zijn workers heen is.
Een goed mentaal model: één request, één context, overal doorgeven. Als het request stopt, moet het werk ook stoppen.
Een duidelijke deadline instellen bij de HTTP-grens
Als je end-to-end timeouts wilt laten werken, beslis waar de klok start. De veiligste plek is direct bij de HTTP-edge, zodat elke downstream call (businesslogica, SQL, andere services) dezelfde deadline erft.
Je kunt die deadline op een paar plaatsen instellen. Server-level timeouts zijn een goede basis en beschermen je tegen trage clients. Middleware is fijn voor consistentie over routegroepen. Het in de handler zelf instellen is ook prima wanneer je iets expliciets en lokaals wilt.
Voor de meeste API's zijn per-request timeouts in middleware of de handler het makkelijkst te beredeneren. Houd ze realistisch: gebruikers geven de voorkeur aan een snelle, duidelijke fout boven een request dat blijft hangen. Veel teams gebruiken kortere budgets voor reads (bijv. 1–2s) en iets langere voor writes (bijv. 3–10s), afhankelijk van wat het endpoint doet.
Hier is een simpel handler-patroon:
func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
json.NewEncoder(w).Encode(report)
}
Twee regels houden dit effectief:
- Roep altijd
cancel()aan zodat timers en resources snel worden vrijgegeven. - Vervang de request-context nooit met
context.Background()ofcontext.TODO()binnen de handler. Dat breekt de keten, en je database-calls en outbound requests kunnen blijven draaien nadat de client verdwenen is.
Context door je codebase propagateren
Zodra je een deadline bij de HTTP-grens instelt, is het echte werk ervoor zorgen dat diezelfde deadline elke laag bereikt die kan blokkeren. Het idee is één klok, gedeeld door de handler, servicecode en alles dat netwerk of schijf aanraakt.
Een simpele regel houdt dingen consistent: elke functie die kan wachten moet een context.Context accepteren en het moet de eerste parameter zijn. Dat maakt het duidelijk op callsites en het wordt een gewoonte.
Een praktisch signature-patroon
Geef de voorkeur aan signatures zoals DoThing(ctx context.Context, ...) voor services en repositories. Vermijd het verbergen van context in structs of het opnieuw aanmaken met context.Background() in lagere lagen, want dat verliest stilletjes de deadline van de caller.
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
// map context errors to a clear client response elsewhere
http.Error(w, err.Error(), http.StatusRequestTimeout)
return
}
}
func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
// parsing or validation can still respect cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return s.repo.InsertOrder(ctx, /* data */)
}
Vroege exits netjes afhandelen
Behandel ctx.Done() als een normale controlepad. Twee gewoonten helpen:
- Controleer
ctx.Err()voordat je duur werk start en na lange lussen. - Geef
ctx.Err()onveranderd omhoog terug, zodat de handler snel kan reageren en geen resources meer verspilt.
Wanneer elke laag dezelfde ctx doorgeeft, kan één timeout parsing, businesslogica en database-wachten in één keer afkappen.
Deadlines toepassen op database/sql-queries
Zodra je HTTP-handler een deadline heeft, zorg dan dat je databasewerk er daadwerkelijk naar luistert. Met database/sql betekent dat: gebruik altijd de context-aware methoden. Als je Query() of Exec() zonder context aanroept, kan je API blijven wachten op een trage query zelfs nadat de client heeft opgegeven.
Gebruik consequent: db.QueryContext, db.QueryRowContext, db.ExecContext en db.PrepareContext (en daarna QueryContext/ExecContext op de teruggegeven statement).
func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, email FROM users WHERE id = $1`, id,
)
var u User
if err := row.Scan(&u.ID, &u.Email); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET email = $1 WHERE id = $2`, email, id,
)
return err
}
Twee dingen zijn makkelijk te missen.
Ten eerste moet je SQL-driver context-cancelatie respecteren. Veel drivers doen dat, maar controleer het in je stack door een opzettelijk trage query te testen en te controleren of die snel geannuleerd wordt wanneer de deadline is bereikt.
Ten tweede, overweeg een database-side timeout als backstop. Bijvoorbeeld: Postgres kan een per-statement limiet afdwingen (meestal statement_timeout). Dat beschermt de database zelfs als er ergens een bug is die context vergeet door te geven.
Wanneer een operatie stopt vanwege een timeout, behandel dat anders dan een normale SQL-fout. Controleer errors.Is(err, context.DeadlineExceeded) en errors.Is(err, context.Canceled) en geef een duidelijke response terug (zoals een 504) in plaats van te doen alsof de database kapot is. Als je Go-backends genereert (bijv. met AppMaster), maakt het onderscheid houden tussen deze foutpaden logs en retries ook makkelijker te begrijpen.
Downstream calls: HTTP-clients, caches en andere services
Zelfs als je handler en SQL-queries context respecteren, kan een request nog steeds hangen als een downstream call eeuwig wacht. Onder load kunnen een paar vastgelopen goroutines zich opstapelen, connection pools opeten en een kleine vertraging veranderen in een volledige outage. De oplossing is consistente propagatie met een harde backstop.
Outbound HTTP
Bij het aanroepen van een andere API, maak het request met dezelfde context zodat deadline en cancelatie automatisch meeliften.
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)
Vertrouw niet alleen op context. Configureer ook de HTTP-client en transport zodat je beschermd bent als code per ongeluk een background-context gebruikt, of als DNS/TLS/idle connections vastlopen. Stel http.Client.Timeout in als een bovenlimiet voor de hele call, stel transport-timeouts in (dial, TLS-handshake, response header) en hergebruik één client in plaats van er per request een nieuwe te maken.
Caches en queues
Caches, message brokers en RPC-clients hebben vaak hun eigen wachtroutines: verkrijgen van een verbinding, wachten op een reply, blokkeren op een volle queue of wachten op een lock. Zorg dat die operaties ctx accepteren en gebruik library-level timeouts waar mogelijk.
Een praktische regel: als het gebruikersrequest nog 800ms over heeft, begin dan niet aan een downstream-call die 2 seconden kan duren. Sla het over, degradeer of geef een gedeeltelijk antwoord.
Bepaal van tevoren wat een timeout voor je API betekent. Soms is het juiste antwoord een snelle fout. Soms is het gedeeltelijke data voor optionele velden. Soms is het verouderde cache-data, duidelijk gemarkeerd.
Als je Go-backends bouwt (inclusief gegenereerde, zoals in AppMaster), is dit het verschil tussen “timeouts bestaan” en “timeouts beschermen het systeem consistent” tijdens verkeerspieken.
Stapsgewijs: refactor een API om end-to-end timeouts te gebruiken
Refactoren voor timeouts komt neer op één gewoonte: geef dezelfde context.Context van de HTTP-edge helemaal door naar elke call die kan blokkeren.
Een praktische manier is top-down werken:
- Verander je handler en core servicemethoden zodat ze
ctx context.Contextaccepteren. - Werk elke DB-call bij naar
QueryContextofExecContext. - Doe hetzelfde voor externe calls (HTTP-clients, caches, queues). Als een library geen
ctxaccepteert, wrapp het of vervang het. - Beslis wie timeouts beheert. Een veelgebruikte regel is: de handler stelt de totale deadline in; lagere lagen mogen alleen kortere deadlines instellen wanneer dat nodig is.
- Maak fouten voorspelbaar aan de rand: map
context.DeadlineExceededencontext.Cancelednaar duidelijke HTTP-responses.
Dit is de gewenste vorm over lagen heen:
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(order)
}
func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
// scan...
}
Timeout-waarden moeten saai en consistent zijn. Als de handler 2 seconden totaal heeft, houd DB-queries onder 1 seconde om ruimte over te laten voor JSON-encoding en ander werk.
Om te bewijzen dat het werkt, voeg een test toe die een timeout forceert. Een eenvoudige aanpak is een fake repository-methode die blokkeert tot ctx.Done() en dan ctx.Err() teruggeeft. Je test moet asserten dat de handler snel een 504 teruggeeft, niet pas na de fake vertraging.
Als je Go-backends genereert met een generator (bijv. AppMaster), blijft de regel hetzelfde: één request-context, doorgevoerd overal, met duidelijke eigenaar van de deadline.
Observability: aantonen dat timeouts werken
Timeouts helpen alleen als je ze kunt zien gebeuren. Het doel is simpel: elk request heeft een deadline, en wanneer het faalt kun je zien waar de tijd naartoe ging.
Begin met logs die veilig en nuttig zijn. In plaats van volledige request-bodies te dumpen, log genoeg om de draad te volgen en trage paden te zien: request-id (of trace-id), of er een deadline is ingesteld en hoeveel tijd er nog over is op belangrijke punten, de operatie-naam (handler, SQL-querynaam, outbound call-naam) en de resultaatcategorie (ok, timeout, canceled, andere fout).
Voeg een paar gerichte metrics toe zodat gedrag onder load duidelijk is:
- Aantal timeouts per endpoint en afhankelijkheid
- Request-latency (p50/p95/p99)
- In-flight requests
- Database-query-latency (p95/p99)
- Foutpercentage uitgesplitst op type
Bij het afhandelen van fouten, tag ze correct. context.DeadlineExceeded betekent meestal dat je budget op is geraakt. context.Canceled betekent vaak dat de client is weggegaan of een upstream timeout eerst afging. Houd deze apart omdat de oplossingen verschillen.
Tracing: vind waar de tijd wegvloeide
Tracing-spans moeten dezelfde context volgen van de HTTP-handler in database/sql-calls zoals QueryContext. Stel dat een request om 2 seconden time-out en de trace toont 1.8 seconden wachten op een DB-verbinding. Dat wijst op poolgrootte of trage transacties, niet op de querytekst.
Als je een intern dashboard bouwt (timeouts per route, top trage queries), kan een no-code tool zoals AppMaster je helpen het snel uit te rollen zonder observability als apart engineeringproject te maken.
Veelgemaakte fouten die je timeouts tenietdoen
De meeste “het hangt soms nog steeds” bugs komen door een paar kleine fouten.
- Resetten van de klok onderweg. Een handler zet een 2s-deadline, maar de repository maakt een nieuwe context met een andere timeout (of geen timeout). Nu kan de database doorgaan nadat de client weg is. Geef de inkomende
ctxdoor en verscherp hem alleen als je een duidelijke reden hebt. - Goroutines starten die nooit stoppen. Werk starten met
context.Background()(of de ctx helemaal weggooien) betekent dat het blijft draaien nadat het request is geannuleerd. Geef de request-ctx door naar goroutines enselectopctx.Done(). - Deadlines die te kort zijn voor echt verkeer. Een 50ms-timeout werkt misschien op je laptop maar faalt in productie tijdens een kleine spike, wat retries, meer load en een door jezelf veroorzaakte mini-outage veroorzaakt. Kies timeouts op basis van normale latencies plus marge.
- De echte fout verbergen.
context.DeadlineExceededbehandelen als een generieke 500 maakt debugging en clientgedrag erger. Map het naar een duidelijke timeout-response en log het verschil tussen “geannuleerd door client” en “getimed out”. - Resources openlaten bij vroege exits. Als je vroeg terugkeert, zorg dat je nog steeds
defer rows.Close()doet en de cancel-functie vancontext.WithTimeoutaanroept. Gelekte rows of lingering werk kan verbindingen onder load uitputten.
Een snel voorbeeld: een endpoint start een rapportquery. Als de gebruiker het tabblad sluit, wordt de handler-ctx geannuleerd. Als je SQL-call een nieuwe background-context gebruikte, draait de query nog steeds en houdt een verbinding vast, waardoor iedereen vertraagt. Wanneer je dezelfde ctx in QueryContext doorgeeft, wordt de database-call afgebroken en herstelt het systeem sneller.
Snelle checklist voor betrouwbare timeout-gedragingen
Timeouts helpen alleen als ze consistent zijn. Eén gemiste call kan een goroutine bezet houden, een DB-verbinding vasthouden en volgende requests vertragen.
- Stel één duidelijke deadline aan de rand in (meestal de HTTP-handler). Alles binnen het request erft die.
- Geef dezelfde
ctxdoor in je service- en repositorylagen. Vermijdcontext.Background()in request-code. - Gebruik context-aware DB-methoden overal:
QueryContext,QueryRowContextenExecContext. - Bevestig dezelfde
ctxaan outbound-calls (HTTP-clients, caches, queues). Als je een child-context maakt, maak hem korter, niet langer. - Handel cancelaties en timeouts consistent af: geef een schone fout terug, stop werk en vermijd retry-loops binnen een geannuleerd request.
Controleer daarna gedrag onder druk. Een timeout die afgaat maar resources niet snel genoeg vrijgeeft, schaadt nog steeds de betrouwbaarheid.
Dashboards moeten timeouts duidelijk maken, niet verbergen in gemiddelden. Volg een paar signalen die de vraag beantwoorden “worden deadlines echt afgedwongen?”: request-timeouts en DB-timeouts (apart), latency-percentielen (p95, p99), DB-poolstatistieken (in-use connections, wait count, wait duration) en een uitsplitsing van foutoorzaken (context deadline exceeded vs andere failures).
Als je interne tools bouwt op een platform zoals AppMaster, geldt dezelfde checklist voor alle Go-services die je ermee verbindt: definieer deadlines aan de rand, propagateer ze en bevestig in metrics dat vastgelopen requests snelle failures worden in plaats van langzame opstoppingen.
Voorbeeldscenario en volgende stappen
Een veelvoorkomende plek waar dit rendeert is een zoek-endpoint. Stel GET /search?q=printer vertraagt wanneer de database druk is met een grote rapportquery. Zonder deadline kan elk inkomend request blijven wachten op een lange SQL-query. Onder load stapelen die vastgelopen requests zich op, bezetten worker-goroutines en verbindingen en voelt de hele API zich vast.
Met een duidelijke deadline in de HTTP-handler en dezelfde ctx naar je repository, stopt het systeem met wachten wanneer het budget op is. Wanneer de deadline bereikt is, annuleert de database-driver de query (indien ondersteund), geeft de handler terug en kan de server nieuwe requests blijven bedienen in plaats van eeuwig te wachten.
Het gebruikerszicht is beter, zelfs wanneer er iets misgaat. In plaats van 30–120 seconden te draaien en dan rommelig te falen, krijgt de client een snelle, voorspelbare fout (vaak 504 of 503 met een korte boodschap zoals “request timed out”). Belangrijker: het systeem herstelt sneller omdat nieuwe requests niet achter oude geblokkeerde requests komen te staan.
Volgende stappen om dit door endpoints en teams heen te verankeren:
- Kies standaard timeouts per endpointtype (search vs writes vs exports).
- Vereis
QueryContextenExecContextin code reviews. - Maak timeout-fouten expliciet aan de rand (duidelijk statuscode, eenvoudige boodschap).
- Voeg metrics toe voor timeouts en cancelaties zodat regressies vroeg worden opgemerkt.
- Schrijf één helper die contextcreatie en logging wrappet zodat elke handler hetzelfde gedrag vertoont.
Als je services en interne tools bouwt met AppMaster, kun je deze timeout-regels consistent toepassen over gegenereerde Go-backends, API-integraties en dashboards op één plek. AppMaster is beschikbaar op appmaster.io (no-code, met echte Go-brongecode-generatie), dus het kan praktisch zijn wanneer je consistente request-afhandeling en observability wilt zonder elke admin-tool handmatig te bouwen.
FAQ
Een request is “vastgelopen” wanneer het wacht op iets dat niet terugkeert, zoals een trage SQL-query, een geblokkeerde verbinding uit de pool, DNS-problemen of een upstream-service die niet reageert. Onder load stapelen vastgelopen requests zich op, bezetten workers en verbindingen, en kunnen een kleine vertraging uitgroeien tot een bredere storing.
Stel de totale deadline in bij de HTTP-grens en geef diezelfde ctx door aan elke laag die kan blokkeren. Die gedeelde deadline voorkomt dat enkele trage operaties resources te lang vasthouden en zo overal timeouts veroorzaken.
Gebruik ctx, cancel := context.WithTimeout(r.Context(), d) en doe altijd defer cancel() in de handler (of middleware). De cancel-aanroep maakt timers en andere resources vrij en helpt wachten snel te beëindigen als het request eerder klaar is.
Vervang de request-context niet door context.Background() of context.TODO() in request-code, want dat verbreekt cancelatie en deadlines. Als je de request-context weggooit, kan downstream werk zoals SQL of outbound HTTP blijven draaien nadat de client is weggegaan.
Behandel context.DeadlineExceeded en context.Canceled als normale uitkomsten en geef ze onveranderd omhoog. Aan de rand map je ze naar duidelijke responses (vaak 504 voor timeouts), zodat clients niet blindelings opnieuw proberen op wat lijkt op een willekeurige 500.
Gebruik overal de context-gevoelige methoden: QueryContext, QueryRowContext, ExecContext en PrepareContext. Als je Query() of Exec() zonder context aanroept, kan je handler timen outen terwijl de database-call je goroutine en verbinding blijft bezetten.
Veel drivers ondersteunen het, maar verifieer het in jouw stack door een opzettelijk trage query te draaien en te controleren of deze snel terugkeert nadat de deadline is bereikt. Het is ook verstandig om een database-side statement timeout als backstop te gebruiken voor het geval ergens context vergeten wordt.
Bouw de outbound request met http.NewRequestWithContext(ctx, ...) zodat dezelfde deadline en cancelatie automatisch meeliften. Configureer daarnaast de HTTP-client en transport (timeouts voor dial, TLS-handshake, response header, en een http.Client.Timeout) als harde bovengrens, want context beschermt je niet als iemand per ongeluk background gebruikt of er een lagere-level stall optreedt.
Vermijd het creëren van nieuwe contexts die het tijdsbudget in lagere lagen verlengen; child-timeouts moeten korter zijn, niet langer. Als het request weinig tijd over heeft, sla optionele downstream-calls over, geef gedeeltelijke data terug wanneer dat passend is of faal snel met een duidelijk error.
Houd timeouts en cancelaties apart bij endpoint en afhankelijkheid, plus latency-percentielen en in-flight requests. In traces moet dezelfde context van handler naar outbound calls en QueryContext volgen, zodat je kunt zien of tijd is verdwenen in het wachten op een DB-verbinding, het uitvoeren van een query of blokkades bij een andere service.


