Day 50 — Stage V gate

Updated

July 30, 2026

Day 50 — Stage V gate

Stage V · ~3h (integration / ship)
Goal: Close Stage V by shipping a stdlib HTTP service with structured logging, handler tests, timeouts, and signal-based graceful shutdown.

Why this day exists

Days 39–49 were skills in isolation. Gates force integration:

  • Does the service start and stop cleanly?
  • Can someone else run tests and understand the README?
  • Are I/O, JSON, slog, and http habits consistent?

You do not need a database yet (Stage VI). You need a trustworthy process that looks like something you would leave running overnight.


Gate bar (definition of done)

Requirement Evidence
HTTP JSON API (≥3 endpoints) Code + curl examples
Go 1.22+ method patterns ServeMux routes
Middleware (≥2): request ID + access log or recover Code
log/slog (JSON flag optional) Logs on request
http.Server timeouts set ReadHeaderTimeout required
Graceful shutdown on SIGINT/SIGTERM Demo or documented script
Tests via httptest go test ./... green
README: run, test, endpoints File present
Race clean go test -race

Stretch: limit request bodies; DisallowUnknownFields; /readyz vs /healthz.


Theory 1 — Graceful shutdown

srv := &http.Server{
    Addr:              addr,
    Handler:           h,
    ReadHeaderTimeout: 5 * time.Second,
}

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

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

select {
case err := <-errCh:
    if err != nil && err != http.ErrServerClosed {
        return err
    }
case <-ctx.Done():
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        _ = srv.Close() // force
        return err
    }
}

What Shutdown does

  1. Stops accepting new conns
  2. Waits for in-flight handlers (until context done)
  3. Returns; then process can exit

Long handlers should respect r.Context() cancellation so Shutdown can finish.

Why timeouts on the Server

Field Protects against
ReadHeaderTimeout Slowloris-style header drip
ReadTimeout Slow body uploads
WriteTimeout Stuck clients on response
IdleTimeout Lingering keep-alives

Never ship ListenAndServe without at least ReadHeaderTimeout.


Theory 2 — Health endpoints

Path Meaning
/healthz or /livez Process up
/readyz Ready to take traffic (deps OK later)

For Stage V, both can be static OK; structure them separately so Stage VI can diverge (DB ping on ready only).

func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
    writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (s *Server) readyz(w http.ResponseWriter, _ *http.Request) {
    // later: ping DB; today: same as live
    writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

Theory 3 — README as interface

Minimum sections:

  1. What it is
  2. Go version (go 1.26 in go.mod)
  3. go run / go test
  4. Endpoint table
  5. Shutdown behavior
  6. Design notes (in-memory store, etc.)

A gate without a README fails the “someone else can run it” bar.


Theory 4 — Process boundaries

cmd/service/main.go   → wiring only
api/                  → HTTP + middleware
internal/store/       → in-memory repo (mutex)

Rules of thumb:

  • main parses flags, builds logger, constructs server, blocks on signal
  • No business logic in main beyond wiring
  • Tests import api / store, not main

Architecture sketch

cmd/service/main.go
  → config (flags/env)
  → slog setup
  → api.New(...)
  → http.Server + signal shutdown

api/
  server.go   // routes, middleware
  handlers.go
  server_test.go

Stdlib-first: no web framework.


Lab — Ship the gate service

mkdir -p ~/lab/90daysofx/01-go/day50
cd ~/lab/90daysofx/01-go/day50
go mod init example.com/day50

Suggested endpoint set

Method Path Behavior
GET /healthz liveness
GET /readyz readiness
GET /items list
GET /items/{id} get
POST /items create
DELETE /items/{id} delete (optional)

Shutdown drill

go run ./cmd/service &
pid=$!
curl -s localhost:8080/healthz
kill -TERM $pid
wait $pid
echo exit:$?

Confirm process exits without needing kill -9, and in-flight requests get a chance to finish (optional sleep handler test).

Test drill

go test ./... -race -count=1

Self-review checklist

  • No http.DefaultClient outbound without timeouts (if any client exists)
  • No unlimited ReadAll on request bodies (io.LimitReader)
  • Mutex around in-memory maps
  • JSON Content-Type consistent
  • Errors are JSON, not only plain text
  • ReadHeaderTimeout set

Worked main skeleton

package main

import (
    "context"
    "flag"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "example.com/day50/api"
)

func main() {
    addr := flag.String("addr", ":8080", "listen address")
    jsonLogs := flag.Bool("json-log", false, "JSON logs")
    flag.Parse()

    var handler slog.Handler
    if *jsonLogs {
        handler = slog.NewJSONHandler(os.Stdout, nil)
    } else {
        handler = slog.NewTextHandler(os.Stdout, nil)
    }
    log := slog.New(handler)

    srv := &http.Server{
        Addr:              *addr,
        Handler:           api.New(log).Handler(),
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      20 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    go func() {
        log.Info("listening", "addr", *addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Error("listen", "err", err)
            os.Exit(1)
        }
    }()

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()
    <-ctx.Done()
    log.Info("shutting down")

    shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(shCtx); err != nil {
        log.Error("shutdown", "err", err)
        os.Exit(1)
    }
    log.Info("bye")
}

Common gotchas

Gotcha Fix
Only ListenAndServe in main Signal + Shutdown
Shutdown timeout too short 10s+ or tune
Handlers ignore r.Context() Check ctx on slow work
Tests require manual server httptest
README missing run steps Write them last, verify once
Leaking goroutines on exit Shutdown; avoid bare go without lifecycle
Missing ReadHeaderTimeout Always set
Data races on map store sync.Mutex / RWMutex

Checkpoint (gate)

  • All gate bar rows satisfied
  • go test ./... -race green
  • SIGTERM demo works
  • README complete
  • Personal note: three Stage V skills you will reuse in Stage VI

Commit

git add .
git commit -m "day50: stage V gate HTTP service slog tests shutdown"

Write a short retrospective (5–10 lines) in your journal: what was hardest—io composition, http routing, or testing?


Tomorrow

Stage VI begins — Day 51 database/sql: persistence, pooling, QueryContext, and a repository over SQLite or Postgres.