Day 50 — Stage V gate
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
- Stops accepting new conns
- Waits for in-flight handlers (until context done)
- 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:
- What it is
- Go version (
go 1.26ingo.mod)
go run/go test
- Endpoint table
- Shutdown behavior
- 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:
mainparses flags, builds logger, constructs server, blocks on signal
- No business logic in
mainbeyond wiring
- Tests import
api/store, notmain
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/day50Suggested 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=1Self-review checklist
- No
http.DefaultClientoutbound without timeouts (if any client exists)
- No unlimited
ReadAllon request bodies (io.LimitReader)
- Mutex around in-memory maps
- JSON Content-Type consistent
- Errors are JSON, not only plain text
ReadHeaderTimeoutset
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")
}Recover middleware (stretch / recommended)
func recoverMW(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Error("panic", "recover", rec)
http.Error(w, `{"error":{"code":"internal","message":"internal error"}}`,
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}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 ./... -racegreen
- 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.