feat(01-03): cmd/web entrypoint with graceful shutdown
- Reads DATABASE_URL (required), PORT (default 8080), ENV (default development) from os.Getenv only — no third-party env loader (D-15: .env exported by just dev, prod injects real env) - slog.SetDefault wired before fatal-on-missing-DSN so even startup errors emit structured output; sanitization per T-01-12 (never log DSN) - pgxpool opened via db.NewPool; chi router mounted with ./static as the asset root - http.Server: ReadTimeout=15s, WriteTimeout=15s, IdleTimeout=60s (T-01-10 slow-client mitigation) - signal.NotifyContext (Go 1.21+) traps SIGINT/SIGTERM, propagates through ctx; on signal: srv.Shutdown with 10s timeout, then explicit pool.Close (Pitfall 4 — never via defer) - No /readyz route (Phase 7 scope)
This commit is contained in:
parent
3a12f8f47d
commit
08a2c3cd96
1 changed files with 89 additions and 0 deletions
89
backend/cmd/web/main.go
Normal file
89
backend/cmd/web/main.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Command web is the Phase 1 walking-skeleton HTTP server. It loads env,
|
||||
// builds a slog handler, opens a pgxpool, mounts the chi router, and serves
|
||||
// /, /healthz, /demo/time, and /static/* with graceful shutdown on
|
||||
// SIGINT/SIGTERM (CONTEXT D-19).
|
||||
//
|
||||
// No .env parser lives here — `.env` is exported into the process
|
||||
// environment by `just dev`; production injects real env vars (D-15).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"backend/internal/db"
|
||||
"backend/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
env := os.Getenv("ENV")
|
||||
if env == "" {
|
||||
env = "development"
|
||||
}
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
|
||||
// Logger first so even fatal-on-missing-DSN paths produce structured
|
||||
// output. Per Pattern 3: JSON in production, text everywhere else.
|
||||
slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout)))
|
||||
|
||||
if dsn == "" {
|
||||
slog.Error("DATABASE_URL is required but unset")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// signal.NotifyContext (Go 1.21+) is the canonical idiom — equivalent
|
||||
// to signal.Notify + a channel but the resulting ctx propagates the
|
||||
// cancellation through to handlers, pgxpool dialing, etc.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pool, err := db.NewPool(ctx, dsn)
|
||||
if err != nil {
|
||||
// T-01-12: never log the DSN — only the error type/message.
|
||||
slog.Error("db connect failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
router := web.NewRouter(pool, "./static")
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
// T-01-10 slow-client mitigation per RESEARCH Security Domain.
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("listening", "addr", srv.Addr, "env", env)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
slog.Info("shutting down")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("shutdown error", "err", err)
|
||||
}
|
||||
|
||||
// Pitfall 4: close the pool AFTER Shutdown returns, NOT via defer in
|
||||
// main — defer ordering is unreliable on fatal-exit paths.
|
||||
pool.Close()
|
||||
slog.Info("shutdown complete")
|
||||
}
|
||||
Loading…
Reference in a new issue