Go4 min lezen

Betrouwbare HTTP-services in Go: timeouts, cancellation en shutdown

Een praktische gids voor Go HTTP-services die vastlopende requests, verspild werk en onrustige deployments voorkomt.

JDoor Jeffrey Klaassen van Oorschot

Een Go HTTP-server lijkt klaar zodra een handler JSON teruggeeft. Problemen zitten meestal eromheen: een trage client houdt een verbinding vast, een outbound request heeft geen bruikbare deadline, databasewerk loopt door nadat de caller weg is of een deployment breekt actieve requests af. Daar is geen groot framework voor nodig. Een paar expliciete grenzen maken een kleine service veel betrouwbaarder.

Behandel timeouts als verschillende budgetten

Er bestaat niet één juiste HTTP-timeout. Headers lezen, een upload verwerken, wachten op een upstream API, een idle verbinding openhouden en afsluiten tijdens shutdown zijn verschillende acties. Eén getal voor alles weigert geldig werk of laat juist een onbegrensd pad bestaan.

Aan serverzijde stel ik meestal eerst ReadHeaderTimeout en IdleTimeout in. ReadHeaderTimeout beperkt trage headers zonder automatisch de volledige body te begrenzen. WriteTimeout kan passen bij gewone JSON-endpoints, maar vraagt aandacht bij streaming. Een deadline voor businesswerk hoort in de handler of service die de verwachte duur kent.

Een kleine server met expliciete verbindingslimieten
server := &http.Server{
    Addr:              ":8080",
    Handler:           routes,
    ReadHeaderTimeout: 5 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20,
}

Laat de request-context de volledige operatie stoppen

Iedere inkomende request heeft al een context. Die wordt geannuleerd wanneer de clientverbinding sluit, de request stopt of de server klaar is. Ik geef die context als eerste argument door aan services en repositories en gebruik context-aware calls zoals QueryContext en NewRequestWithContext. Als de browser opgeeft, mogen database- en upstreamwerk dat ook doen.

Een context bewaar ik niet op een langlevende servicestruct en ik vervang een request-context onderweg niet door context.Background. Beide breken cancellation. Een lokale timeout is alleen nuttig als deze operatie een korter budget nodig heeft dan de parent; defer cancel volgt direct.

Een kortere deadline die client-cancellation respecteert
func (s *Service) Quote(ctx context.Context, id string) (Quote, error) {
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    return s.prices.Fetch(ctx, id)
}

Hergebruik een HTTP-client en begrens outbound calls

http.Client en Transport zijn bedoeld voor hergebruik. Voor iedere call een nieuwe client maken gooit connection pooling weg. Ik gebruik meestal één client per upstream of transportbeleid en zet de specifieke deadline op de request-context. Client.Timeout kan als brede vangrail blijven gelden en omvat de volledige uitwisseling.

Na een succesvolle Do-call moet de response body worden gesloten. Voor connection reuse lees ik ook de verwachte body uit in plaats van data ongelezen achter te laten. Onverwacht grote responses begrens ik en fouten krijgen genoeg operationele context zonder secrets of volledige payloads te loggen.

  • Hergebruik clients in plaats van overal http.DefaultClient te gebruiken
  • Zet de zakelijke deadline op de request-context
  • Sluit iedere niet-lege response body
  • Begrens bodies waarvan de grootte niet vaststaat
  • Meet timeouts en cancellation afzonderlijk

Graceful shutdown heeft een vaste volgorde

Bij SIGTERM stopt de service eerst met readiness adverteren, accepteert daarna geen nieuw verkeer en geeft actieve requests een begrensde tijd om te eindigen. http.Server.Shutdown sluit listeners en idle verbindingen en wacht op actief verkeer. ListenAndServe retourneert tijdens dit normale pad http.ErrServerClosed.

Het proces moet blijven leven totdat Shutdown terugkomt. Langlevende of hijacked verbindingen zoals WebSockets hebben een eigen stopsignaal nodig, want Shutdown wacht daar niet op. In Kubernetes houd ik rekening met de tijd die readiness nodig heeft om de loadbalancer te bereiken, gebaseerd op het echte platformpad en niet op een willekeurige sleep.

Wacht op een signaal en rond actieve requests af
signalCtx, stop := signal.NotifyContext(
    context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()

serveErr := make(chan error, 1)
go func() { serveErr <- server.ListenAndServe() }()

select {
case err := <-serveErr:
    return err
case <-signalCtx.Done():
}

ready.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()

if err := server.Shutdown(ctx); err != nil {
    return fmt.Errorf("shut down HTTP server: %w", err)
}
if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
    return fmt.Errorf("serve HTTP: %w", err)
}
return nil

Test bewust de ongelukkige paden

Een cancellation-pad dat nooit in een test draaide is vooral hoop. Met httptest kan een upstream wachten tot zijn context stopt, langzaam antwoorden en aantonen dat de handler geen achtergrondwerk achterlaat. Repository-fakes kunnen ctx.Done observeren zonder echte sleeps.

Voor shutdown start ik een request dat op een channel blokkeert, activeer ik Shutdown, geef ik de request vrij en controleer ik dat shutdown eindigt. Ook de verlopen deadline wordt getest. Zo vang je de fout waarbij main eindigt zodra ListenAndServe stopt en actieve handlers te vroeg worden beëindigd.

Praktische checklist

  • Stel serverlimieten bewust in
  • Geef request-contexts door aan database- en HTTP-calls
  • Hergebruik geconfigureerde outbound clients
  • Sluit en verwerk response bodies correct
  • Stop readiness vóór draining
  • Test succesvolle en verlopen shutdown

Verder lezen