Go context.Context Done Right: Annulering, time-outs en waarden
In Go is context besturing van de uitvoeringsvolgorde, geen opslag.
Go’s context.Context is eenvoudig genoeg om verkeerd te gebruiken — en dat is precies het probleem.
De meeste Go-ontwikkelaars leren de basisregels snel: geef context door als eerste argument, controleer ctx.Done(), gebruik context.WithTimeout en geef nooit nil door.
func DoSomething(ctx context.Context) error {
// ...
}
Die regels zijn nuttig, maar ze dekken alleen het eenvoudige deel. In productie-diensten is context niet zomaar een parameterconventie — het is het controlepaneel voor de levensduur van een verzoek (request).

Context vertelt werk wanneer het moet stoppen, hoeveel tijd er nog over is, welke annuleringsroute is gekozen en welke waarden die aan het verzoek zijn gebonden, over API-grenzen moeten reizen. Goed gebruikt, voorkomt het lekken van goroutines, vermijdt het nutteloos werk, propageert het time-outs en maakt het diensten eenvoudiger af te sluiten. Slecht gebruikt, wordt het een verzameling van verborgen afhankelijkheden, nep-globalen, vergeten time-outs, lekkende timers en verwarrend annuleringsgedrag.
De licht meninghebbende versie is deze: gebruik context voor annulering, time-outs en metadata die aan een verzoek zijn gebonden, en gebruik het niet als een container voor afhankelijkheden.
Waar context voor dient
Het context-pakket heeft drie hoofdtaken — annulering, time-outs en deadlines, en waarden die aan een verzoek zijn gebonden — en deze drie taken dekken alles waarvoor het ontworpen is.
Een context moet vragen beantwoorden als:
Is dit verzoek geannuleerd?
Hoeveel tijd heeft deze operatie nog?
Welk verzoek-ID moet aan logs worden toegevoegd?
Welke geverifieerde gebruiker is aan dit verzoek gekoppeld?
Een context moet geen vragen beantwoorden als:
Waar is mijn databaseverbinding?
Waar is mijn logger?
Waar is mijn configuratie?
Welke service-implementatie moet ik gebruiken?
Dat zijn afhankelijkheden — geef ze expliciet door via functieparameters (zie Dependency Injection in Go voor patronen om dit schoon te doen). Context is voor de levensduur van een verzoek en verzoekmetadata, niet voor de bedrading van de applicatie.
De basisvorm van context
De kerninterface is klein:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
De belangrijke delen zijn:
Done()wordt gesloten wanneer de context is geannuleerd of de deadline verstrijkt.Err()legt uit waarom de context eindigde.Deadline()vertelt je of de context een deadline heeft.Value()slaat data op die aan een verzoek is gebonden.
De meeste code implementeert deze interface niet. Het ontvangt een context en geeft deze door.
De eerste regel: geef context expliciet door
Voor functies die werk uitvoeren dat aan een verzoek is gebonden of geannuleerd kan worden, geef context door als eerste parameter — dit is de standaard Go-conventie en wat elke bibliotheek en tool in het ecosysteem verwacht:
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Doe dit voor functies die mogelijk:
- Een database aanroepen
- Een andere service aanroepen
- Wachten op een wachtrij
- Achtergrondwerk starten
- Blokkeren op I/O
- Een time-out gebruiken
- Waarden nodig hebben die aan een verzoek zijn gebonden
- Annulering nodig hebben
Voeg geen context toe aan kleine pure functies die deze niet nodig hebben.
Dit is prima:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Niet elke functie heeft een context nodig. Context overal toevoegen maakt de code lawaaierig.
Sla context niet op in structs
Het opslaan van een context in een struct is een van de meest voorkomende fouten in Go-codebases, en het is de moeite waard dit expliciet te benoemen. Doe dit niet:
type UserService struct {
ctx context.Context
db *sql.DB
}
Doe dit in plaats daarvan:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Een context behoort bij een verzoek, operatie of taak, terwijl een service-struct meestal veel langer leeft dan een enkel verzoek. Het mengen van deze levensduur maakt annulering onduidelijk en maakt het moeilijk te bepalen bij welke operatie een context hoort.
Er zijn zeldzame uitzonderingen voor types die echt de levensduur van een enkele operatie vertegenwoordigen, maar ze zijn zo zeldzaam dat de standaardregel eenvoudig moet zijn:
Geef context door. Sla hem niet op.
Geef geen nil context door
Geef nooit nil door als context.
Slecht:
err := svc.DoWork(nil)
Gebruik context.Background() wanneer er geen bestaande context is:
err := svc.DoWork(context.Background())
Gebruik in tests de testcontext wanneer mogelijk:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Een nil-context kan een panic veroorzaken wanneer code methoden daarop aanroept. Een background-context is expliciet en veilig.
Background, TODO en request-contexts
Er zijn drie veelvoorkomende startpunten.
context.Background
Gebruik context.Background() op het hoogste niveau van een programma wanneer er geen oudercontext bestaat — het is de wortelcontext waaruit alle kindcontexten worden afgeleid:
func main() {
ctx := context.Background()
_ = run(ctx)
}
of:
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Gebruik context.TODO() wanneer je weet dat een context moet worden gebruikt, maar nog niet hebt besloten welke.
ctx := context.TODO()
Dit is nuttig tijdens migratie, maar het mag niet permanent worden als er een echte context bestaat.
Request context
Gebruik in HTTP-servers de request context:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
De request context wordt geannuleerd wanneer de clientverbinding sluit, het verzoek wordt geannuleerd, of de server klaar is met het verwerken van het verzoek.
Voor webdiensten is dit meestal de context die je door moet geven aan applicatiecode.
Annulering met context.WithCancel
Gebruik context.WithCancel wanneer je werk expliciet wilt stoppen.
ctx, cancel := context.WithCancel(parent)
defer cancel()
De geretourneerde cancel-functie annuleert de kindcontext en bevrijdt de aan deze gekoppelte resources. Roep deze altijd aan wanneer je klaar bent — zelfs als de context uiteindelijk een time-out zal geven, voorkomt het vroegtijdig annuleren dat resources langer actief blijven dan nodig.
Voorbeeld:
func RunWorker(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
done := make(chan error, 1)
go func() {
done <- doBackgroundWork(ctx)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return err
}
}
Het patroon is eenvoudig:
- Leid een kindcontext af.
- Roep
cancelmetdeferaan. - Geef de kindcontext door aan werk dat samen moet stoppen.
- Watch
ctx.Done().
Time-outs met context.WithTimeout
Gebruik context.WithTimeout wanneer een operatie een maximale duur heeft.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Voorbeeld met een HTTP-client:
func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Dit maakt de time-out onderdeel van de operatie, niet een verborgen globale instelling.
Roep altijd cancel aan
Wanneer je WithCancel, WithTimeout of WithDeadline aanroept, roep dan altijd de geretourneerde cancel-functie aan — dit is belangrijk voor correctheid.
Goed:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Slecht:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
Het niet aanroepen van cancel kan ervoor zorgen dat timers en kindcontexten langer actief blijven dan nodig.
Deadlines vs time-outs
Een time-out is relatief:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Een deadline is absoluut:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
De meeste applicatiecode gebruikt time-outs. Deadlines zijn nuttig wanneer een verzoek een vaste eindtijd heeft die over meerdere operaties moet worden gedeeld — bijvoorbeeld, als een verzoek nog 900 milliseconden heeft, geef dan geen verse time-out van 1 seconde aan elke downstream-aanroep; propageer het resterende budget in plaats daarvan.
Time-outbudgetten over servicelagen heen
Een veelgemaakte fout is het blind stapelen van time-outs.
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_ = service.DoWork(ctx)
}
func (s *Service) DoWork(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.repo.Query(ctx)
}
Dit ziet onschuldig uit, maar het verbergt het echte budget. De servicelaag moet meestal de deadline van de aanroeper respecteren in plaats van de timer naar dezelfde waarde terug te zetten.
Een beter patroon is:
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := service.DoWork(ctx); err != nil {
// handle error
return
}
}
En dan binnen de service:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Voeg een kind-time-out alleen toe wanneer een sub-operatie een kleiner budget nodig heeft:
func (s *Service) DoWork(ctx context.Context) error {
queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return s.repo.Query(queryCtx)
}
Het juiste mentale model is eenvoudig: het hele verzoek heeft één buitenste budget, specifieke sub-operaties kunnen kleinere budgetten hebben die daaruit zijn gehaald, en geen enkele laag verlengt het verzoek stil buiten wat de aanroeper heeft bedoeld.
Controleer ctx.Err() om annulering te onderscheiden van time-out
Wanneer een context eindigt, geeft ctx.Err() de reden terug.
Meestal is het een van de volgende:
context.Canceled
context.DeadlineExceeded
Voorbeeld:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return handle(result)
}
Dit stelt aanroepers in staat om annulering te onderscheiden van time-out, en dat onderscheid is in de praktijk belangrijk. Een geannuleerd verzoek betekent vaak dat de client is losgekoppeld, terwijl een deadline-exceeded-fout meestal betekent dat je service te traag was — ze moeten niet altijd op dezelfde manier worden gelogd, geprobeerd of gerapporteerd.
Gebruik context.Cause voor betere annuleringsredenen
Moderne Go ondersteunt ook oorzaak-bewuste annulering.
De nuttige functies zijn:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Een eenvoudige ctx.Err() vertelt je de brede reden: geannuleerd of deadline overschreden.
context.Cause(ctx) kan je de meer specifieke oorzaak vertellen.
Voorbeeld:
var ErrShutdown = errors.New("server shutting down")
func Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
go func() {
// Some shutdown signal arrived.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Gebruik oorzaak-bewuste annulering wanneer de reden van belang is voor aanroepers, logs of opschoningsgedrag, en vermijd het waar een eenvoudige ctx.Err() voldoende is — de extra details zijn alleen de moeite waard wanneer diagnose daadwerkelijk vereist is.
HTTP-servervoorbeeld
Een normale HTTP-handler moet beginnen bij r.Context(). Voor een complete rondleiding door het structureren van Go HTTP-diensten, zie Building REST APIs in Go.
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := r.PathValue("id")
user, err := svc.GetUser(ctx, id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
De service moet de context accepteren en doorgeven:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
De repository moet context-bewuste database-methoden gebruiken:
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
return nil, err
}
return &user, nil
}
Het belangrijke is de keten — elke laag geeft dezelfde context door aan de volgende:
Breek de keten niet door context.Background() in het midden te creëren.
De context.Background()-fout: het verbreken van de annuleringsketen
Dit is een veelvoorkomende bug:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(context.Background(), id)
}
Dit gooit alle annulering- en deadline-informatie van de aanroeper weg. Als de client loskoppelt, blijft de databasequery draaien. Als het verquest een time-out geeft, kan het downstream-werk nog steeds in uitvoering zijn. Als de server afsluit, negeert deze code dat volledig. Het vervangen van de ontvangen context door context.Background() binnen de bedrijfslogica is bijna altijd fout.
Gebruik de context die je kreeg:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Gebruik context.Background() alleen aan de rand waar geen oudercontext bestaat.
HTTP-clientvoorbeeld
Voor uitgaande HTTP-verzoeken, koppel de context aan het verzoek.
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Doe dit niet:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
Dit creëert een verzoek zonder de operatiecontext.
Vermijd ook om alleen te vertrouwen op http.Client.Timeout. Het kan nuttig zijn als een veiligheidslimiet, maar request-contexts geven je betere propagatie door de aanroepketen.
Een veelgebruikt patroon is:
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Gebruik dit wanneer de downstream-API-aanroep een specifiek budget heeft binnen een groter verzoek.
Databasevoorbeeld
De meeste Go database-API’s hebben context-bewuste methoden. Voor een bredere kijk op hoe Go data-toegangsbibliotheken context hanteren — inclusief GORM, Ent, Bun en sqlc — zie Comparing Go ORMs for PostgreSQL.
Gebruik ze.
Goed:
rows, err := db.QueryContext(ctx, query, args...)
Goed:
err := db.QueryRowContext(ctx, query, id).Scan(&name)
Goed:
result, err := db.ExecContext(ctx, query, args...)
Slecht:
rows, err := db.Query(query, args...)
De context-bewuste vormen stellen database-operaties in staat om te stoppen wanneer het verzoek wordt geannuleerd of een time-out geeft, wat vooral belangrijk is voor trage queries, overbelaste databases en gebruikersgerichte APIs waar latentie direct de gebruikerservaring beïnvloedt.
Transacties en context
Transacties hebben zorgvuldige contextbehandeling nodig.
Een transactie moet meestal beginnen met de operatiecontext:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Gebruik vervolgens dezelfde context voor transactie-operaties:
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Wees voorzichtig met time-outs rond transacties. Als de context wordt geannuleerd voordat Commit, kan de transactie worden teruggeset. Dat is misschien wat je wilt, maar het moet intentioneel zijn.
Voor lange transacties is het betere antwoord meestal niet een langere time-out — het is een kortere transactie die minder werk per eenheid doet.
Background workers en context
Background workers moeten een context ontvangen die hun levensduur vertegenwoordigt.
Voorbeeld:
type Worker struct {
logger *slog.Logger
}
func (w *Worker) Run(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := w.doOnce(ctx); err != nil {
w.logger.Error("worker iteration failed", "err", err)
}
}
}
}
Deze worker stopt schoon wanneer de context is geannuleerd, en zijn ticker wordt correct opgeschoond via defer ticker.Stop(). In main zou je een wortelcontext creëren die is gekoppeld aan OS-signalen:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
worker := &Worker{logger: slog.Default()}
if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("worker stopped", "err", err)
}
}
Dit is context correct gebruikt: het beschrijft de levensduur van het proceswerk, en wanneer de OS een signaal stuurt, zal de hele boom van goroutines die deze context delen samen stoppen.
Voorkomen van goroutine-lekken met context-annulering
Een goroutine-lek gebeurt wanneer een goroutine voor altijd blijft blokkeren nadat deze niet langer nuttig is.
Context helpt dit te voorkomen.
Slecht:
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
Deze goroutine heeft geen afsluitpad.
Beter:
func StartWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}()
}
Elke goroutine die in een lus loopt, moet bijna altijd een annuleringspad hebben.
Dat betekent niet dat elke goroutine direct context moet ontvangen, maar het systeem moet een duidelijke manier hebben om hem te stoppen.
context.AfterFunc
context.AfterFunc voert een functie uit nadat een context is geannuleerd.
Het kan nuttig zijn voor opschoning, het ontblokkeren van operaties, of het bruggen van APIs die context niet native ondersteunen.
Voorbeeld:
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// Wake up or clean up if needed.
})
defer stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
return nil
}
}
Gebruik AfterFunc voorzichtig — het start logica wanneer annulering plaatsvindt, wat de controleflow moeilijker te volgen kan maken. Voor de meeste applicatiecode is een normale select op ctx.Done() duidelijker en makkelijker te begrijpen. AfterFunc is het meest waardevol wanneer je context-annulering moet aanpassen aan een API die context niet al accepteert.
context.WithoutCancel
context.WithoutCancel creëert een context die niet wordt geannuleerd wanneer de ouder wordt geannuleerd.
Dit is nuttig, maar het is ook makkelijk verkeerd te gebruiken.
Voorbeeldgebruik:
func Handler(audit *AuditLog) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Handle request...
_ = ctx
auditCtx := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
defer cancel()
_ = audit.Write(ctx, "request completed")
}()
}
}
Het idee is dat de audit-schrijfwijze kort na het annuleren van de request-context mogelijk moet doorgaan. Dit moet zeldzaam en doelbewust zijn — gebruik WithoutCancel niet als een manier om omgaan met annulering te vermijden. Gebruik het alleen wanneer het kindwerk echt langer moet leven dan de ouderannulering, en voeg altijd een nieuwe time-out toe: een context die annulering negeert maar geen deadline draagt, kan easily background goroutine-lekken creëren.
Contextwaarden correct gedaan
Contextwaarden zijn voor data die aan een verzoek is gebonden en over API-grenzen gaat.
Goede voorbeelden:
- verzoek-ID
- trace-ID
- geverifieerde gebruiker-ID
- tenant-ID
- locale
- security principal
- correlatiemetadata
Slechte voorbeelden:
- databaseverbinding
- logger als verborgen afhankelijkheid
- feature flags voor gewone controleflow
- optionele functieparameters
- configuratie
- service-clients
Een nuttige regel: als de waarde deel uitmaakt van de identiteit van het verzoek of de observabiliteitscontext, kan het in context thuishoren. Als het een afhankelijkheid is die je code nodig heeft om zijn werk te doen, geef het dan expliciet door.
Gebruik getypte keys voor contextwaarden
Gebruik geen gewone strings als contextkeys.
Slecht:
ctx = context.WithValue(ctx, "userID", "123")
Dit kan botsen met andere pakketten.
Gebruik een niet-geëxporteerde custom key-type:
type userIDKey struct{}
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey{}, userID)
}
func UserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey{}).(string)
return userID, ok
}
Dit patroon geeft je typesafety op de pakketgrens, voorkomt key-collisions met andere pakketten, en houdt het context-API-surface schoon met getypte accessor-functies.
Gebruik contextwaarden niet voor optionele parameters
Dit is slecht:
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
Dit verbergt het functiecontract.
Vermeld expliciete parameters:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Contextwaarden mogen geen functie-argumenten vervangen. Verborgen invoer maakt code moeilijker te begrijpen, te testen en te reviewen — en iedereen die de functiehandtekening leest, heeft geen idee dat de parameter überhaupt bestaat.
Logging en context
Er zijn twee veelvoorkomende benaderingen voor logging met context. De voorbeelden hier gebruiken Go’s log/slog-pakket — voor een diepere duik in gestructureerde logging met slog in productie-diensten, zie Structured Logging in Go with slog.
Benadering 1: Waarden extraheren en aan logs toevoegen
func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
if requestID, ok := RequestIDFromContext(ctx); ok {
logger = logger.With("request_id", requestID)
}
logger.Info(msg)
}
Dit houdt de logger expliciet als een juiste afhankelijkheid en gebruikt context alleen voor waarden die aan een verzoek zijn gebonden en legitiem over API-grenzen moeten gaan.
Benadering 2: Logger opslaan in context
Sommige codebases slaan een logger op in context.
Dit kan handig zijn, maar ik adviseer het niet als standaard. Het maakt van context een afhankelijkheidscontainer.
Mijn voorkeur:
- Geef logger-afhankelijkheden expliciet door.
- Sla trace-IDs en verzoek-IDs op in context.
- Voeg die waarden toe aan logs op grenzen of middleware.
Dit houdt afhankelijkheden zichtbaar.
Context en tracing
Tracing is een van de sterkste use cases voor contextwaarden, en het is een echt goede match. OpenTelemetry en vergelijkbare systemen gebruiken context om trace-spans over functie-aanroepen en procesgrenzen te propageren, omdat tracedata precies het soort metadata is dat aan een verzoek is gebonden en waarvoor context is ontworpen om te dragen.
Een typisch patroon ziet er zo uit:
func (s *Service) DoWork(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "Service.DoWork")
defer span.End()
return s.repo.Query(ctx)
}
De context draagt de actieve trace-span, en de repository kan een kind-span daarvan creëren. Elke laag voegt zijn eigen span toe zonder expliciete doorgeef van tracer-objecten — de context doet dat werk transparant door de hele aanroepboom heen.
Error handling met context
Wanneer een operatie stopt vanwege context-annulering, bewaar die informatie. De patronen hier vullen de bredere error design-strategieën aan die worden behandeld in Go Error Handling Architecture.
Voorbeeld:
err := svc.DoWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Client canceled or caller stopped the work.
return err
}
if errors.Is(err, context.DeadlineExceeded) {
// Timeout.
return err
}
return err
}
Wrap context-errors niet blindeling op een manier die ze verbergt.
Wrappen met %w behoudt errors.Is, zodat aanroepers nog steeds annulering of time-out kunnen detecteren:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Het volledig vervangen van de error gooit die informatie weg en breekt elke aanroeper die controleert op specifieke context error-types:
if err != nil {
return errors.New("query user failed")
}
Mappen van context-errors naar HTTP-responses
Context-errors mappen vaak naar verschillende HTTP-resultaten.
Voorbeeld:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
// The client likely went away.
// Some systems log this as a client closed request.
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
Behandel client-annulering niet als een applicatiefout — als de gebruiker het browsertabblad sloot, is dat geen misdragen van je service, en het als een error loggen voegt lawaai toe zonder signaal.
Context in middleware
HTTP-middleware is een veelvoorkomende plek om waarden die aan een verzoek zijn gebonden toe te voegen.
Voorbeeld request-ID middleware:
type requestIDKey struct{}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey{}, requestID)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey{}).(string)
return requestID, ok
}
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
ctx := WithRequestID(r.Context(), requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Dit is een goed gebruik van context. De request-ID behoort bij het verzoek, het moet door de volledige aanroepketen reizen, en het koppelen aan logs en traces op elke laag is precies het soort cross-cutting observabiliteitszorg dat contextwaarden ontworpen zijn om te ondersteunen.
Context in tests
Gebruik in tests context.Background() niet blindeling.
Vermeld t.Context() wanneer het werk bij de levensduur van de test hoort:
func TestService(t *testing.T) {
ctx := t.Context()
err := service.DoWork(ctx)
if err != nil {
t.Fatal(err)
}
}
Voor time-outgedrag, test met een echte time-out alleen als de time-out klein en betekenisvol is.
Voor concurrente en tijd-afhankelijke code, overweeg het gebruik van testing/synctest — Testing Concurrent Go Code with synctest dekt dit tool in diepgang:
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
time.Sleep(30 * time.Second)
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("got %v, want deadline exceeded", ctx.Err())
}
})
}
Dit stelt je in staat om echte time-outwaarden te testen zonder te wachten op echte tijd.
Context en errgroup
Voor groepen goroutines die samen moeten annuleren, is errgroup vaak een goede match.
Voorbeeld:
func FetchAll(ctx context.Context, ids []string, client *Client) error {
g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
id := id
g.Go(func() error {
_, err := client.Fetch(ctx, id)
return err
})
}
return g.Wait()
}
Als één goroutine een error retourneert, wordt de group-context geannuleerd en kunnen andere goroutines die ctx.Done() respecteren vroeg stoppen. Dit is veel schoner dan het handmatig beheren van meerdere goroutines, kanalen en annuleringspaden. De sleutelzin hier is “respecteer de context” — errgroup kan werk niet stoppen dat ctx.Done() negeert.
Graceful shutdown
Context is centraal voor graceful shutdown.
Een typische serveropstelling heeft:
- een wortelcontext geannuleerd door OS-signalen
- een HTTP-server
- background workers
- een shutdown time-out
- opschoningslogica
Voorbeeld:
func main() {
root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{
Addr: ":8080",
Handler: routes(),
}
go func() {
<-root.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("server shutdown failed", "err", err)
}
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}
Merk op dat de shutdown-context niet dezelfde is als de wortelcontext — de wortel is al geannuleerd wanneer het OS-signaal aankomt. Een aparte time-outcontext geeft het shutdown-proces een begrensd bedrag aan tijd om in-flight verzoeken te drainen voordat het forceren van het afsluiten, wat het subtiele maar belangrijke onderscheid is dat graceful shutdown echt werkt.
Veelvoorkomende anti-patronen
Anti-patroon 1: Context gebruiken als afhankelijkheidscontainer
Slecht:
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
Geef afhankelijkheden expliciet door.
Anti-patroon 2: context.Background creëren binnen bedrijfslogica
Slecht:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
Dit breekt de annuleringspropagatie.
Anti-patroon 3: Vergeten om cancel aan te roepen
Slecht:
ctx, _ := context.WithTimeout(parent, time.Second)
Goed:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Anti-patroon 4: Optionele parameters in context plaatsen
Slecht:
ctx = context.WithValue(ctx, "includeDeleted", true)
Gebruik expliciete optiestructs.
Anti-patroon 5: Context te diep doorgeven in pure code
Slecht:
func Add(ctx context.Context, a, b int) int {
return a + b
}
Pure berekening heeft geen context nodig tenzij het langdurig of annuleerbaar is.
Anti-patroon 6: Annulering negeren in lussen
Slecht:
for item := range items {
process(item)
}
Beter:
for item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := process(ctx, item); err != nil {
return err
}
}
Anti-patroon 7: Context-errors slikken
Slecht:
if err != nil {
return errors.New("operation failed")
}
Goed:
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Bewaar annulering- en deadline-errors.
Een praktische context-checklist
Gebruik deze checklist voor Go backend-code.
Functiesignaturen
- Context is het eerste parameter.
- Context is niet opgeslagen in langlevende structs.
- Context is niet doorgegeven aan pure helper-functies tenzij nodig.
- Nil context is nooit gebruikt.
Annulering
- Langlopende lussen controleren
ctx.Done(). - Goroutines hebben een afsluitpad.
- Worker-levensduur is gekoppeld aan een oudercontext.
- Context-annulering is gepropageerd naar downstream-aanroepen.
Time-outs
- Buitenste request time-outs zijn ingesteld op de grens.
- Sub-operatie time-outs zijn kleiner dan het buitenste budget.
- Cancel-functies worden altijd aangeroepen.
- Time-outs worden niet blindeling gestapeld op elke laag.
Waarden
- Contextwaarden zijn gebonden aan een verzoek.
- Keys gebruiken custom types, geen gewone strings.
- Afhankelijkheden zijn niet opgeslagen in context.
- Optionele parameters zijn niet opgeslagen in context.
Errors
context.Canceledencontext.DeadlineExceededzijn bewaard.- Context-errors zijn correct gemapt op API-grenzen.
- Oorzaak-bewuste annulering wordt alleen gebruikt wanneer de reden van belang is.
Tests
- Tests gebruiken
t.Context()waar passend. - Time-out tests vermijden langzame echte sleeps.
- Concurrent time-outgedrag wordt getest met
testing/synctestwanneer nuttig. - Goroutine-lekken worden gecontroleerd door te zorgen dat afsluitpaden bestaan.
Hoe contextgebruik te auditen in een Go-codebase
Zoek naar deze patronen:
grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .
En vraag dan:
- Is
context.Background()alleen gebruikt op hoogste-niveau grenzen? - Worden cancel-functies altijd aangeroepen?
- Zijn time-outs geplaatst op verstandige grenzen?
- Zijn contextwaarden echt gebonden aan een verzoek?
- Zijn afhankelijkheden verborgen in contextwaarden?
- Kunnen goroutines worden gestopt?
- Zijn context-errors bewaard?
Dit is een goede code review-gewoonte, omdat veel context-bugs geen syntax-bugs zijn — het zijn levensduur-bugs die zich alleen manifesteren onder annulering, load of shutdown-omstandigheden.
Mijn meninghebbende regels
Deze regels zijn saai, maar ze werken.
Regel 1: Context is controleflow
Gebruik context om annulering, deadlines en verzoekmetadata te controleren.
Gebruik het niet om afhankelijkheden te smokkelen.
Regel 2: De aanroeper bezit het budget
Een functie moet meestal de context respecteren die hij ontvangt.
Creëer alleen een kortere kind-time-out wanneer de sub-operatie een specifiek kleiner budget nodig heeft.
Regel 3: Background hoort aan de rand
Gebruik context.Background() in main, tests en top-level setup.
Gebruik het niet binnen service- en repository-methoden om annulering te ontsnappen.
Regel 4: Waarden moeten saai zijn
Verzoek-ID, trace-ID, gebruiker-ID en tenant-ID behoren in context. Databaseverbindingen, loggers, config-structs en service-clients behoren niet — het zijn afhankelijkheden en moeten expliciet worden doorgegeven.
Regel 5: Elke goroutine heeft een levensduur nodig
Als een goroutine start, moet je precies weten hoe hij stopt. Context is vaak het juiste antwoord, en als het niet context is, moet er een andere duidelijke mechanisme zijn — een kanaal, een sync-primitief, of een expliciet signaal.
Eindgedachten
context.Context is niet ingewikkeld omdat de API groot is — de API is klein. Het is ingewikkeld omdat het levensduur vertegenwoordigt, en levensduur is architectuur. Elke beslissing over waar context stroomt, waar het wordt afgeleid, en waar het stopt, is een beslissing over hoe je service omgaat met fouten, load en shutdown.
Een goed gebruikte context maakt Go-diensten makkelijker te annuleren, makkelijker af te sluiten, makkelijker te observeren, en minder waarschijnlijk om goroutines te lekken. Een slecht gebruikte context verbergt afhankelijkheden, gooit deadlines weg, en maakt code moeilijker te begrijpen onder druk.
De praktische afname is eenvoudig:
Geef context door.
Sla het niet op.
Vervang geen expliciete parameters met waarden.
Respecteer annulering.
Gebruik time-outs op grenzen.
Roep altijd cancel aan.
Dat is Go context correct gedaan.
Dit artikel is onderdeel van de App Architecture in Production cluster, die code-structuur, data-toegang, integratiepatronen, en testarchitectuur dekt voor productie Go en Python systemen.