Skip to main content

Part 4: Configuration that fails fast

Updated 12 min readMarkdown
Part of seriesBuild a production-grade REST API in Go

Ports, timeouts and limits don't belong in code. Move them to the environment, and refuse to start with nonsense values.


Series: Build a production-grade REST API in Go · Part 4 of 17 Where we are: the hardened SQLite API from Part 3.

In Part 3 we scattered numbers through the code: a port in main, timeouts in main, maxBodyBytes as a constant in the handlers package. That is fine until you need a different value somewhere: a slower timeout in staging, a tiny body limit in a test, a different port on your colleague's laptop. Recompiling to change a number is a smell.

The principle: config comes from the environment

The twelve-factor app guideline says: store config in the environment. The same compiled binary (or the same Docker image, which we will build later) must run unchanged in development, staging and production; only the environment differs. Environment variables are language-agnostic, easy to set in every platform (Docker, Kubernetes, systemd, CI), and they never accidentally get committed to git like a config file might.

Rule: only main reads configuration, and it passes plain values into components. Handlers do not call os.Getenv. That keeps components easy to test (a test just passes a small number) and means you can read the whole configuration surface in one file.

The config package

Create internal/config/config.go

// Package config loads all tunables in one place, from environment variables
// (twelve-factor style). Centralising them means main wires values into
// components explicitly; no component reads os.Getenv or hides a magic number.
package config

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Addr   string
	DBPath string

	// http.Server timeouts: enforced by net/http, below the handler layer.
	ReadHeaderTimeout time.Duration
	ReadTimeout       time.Duration
	WriteTimeout      time.Duration
	IdleTimeout       time.Duration
	ShutdownTimeout   time.Duration

	// RequestTimeout is the deadline put on each request's context, so slow DB
	// work is cancelled. Keep it below WriteTimeout so we can still reply 503.
	RequestTimeout time.Duration

	// Handler limits.
	MaxBodyBytes int64
	MaxNameLen   int
}

func Load() (Config, error) {
	c := Config{
		Addr:              env("ADDR", ":7800"),
		DBPath:            env("DB_PATH", "users.db"),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      15 * time.Second,
		IdleTimeout:       60 * time.Second,
		ShutdownTimeout:   15 * time.Second,
		RequestTimeout:    10 * time.Second,
		MaxBodyBytes:      1 << 20,
		MaxNameLen:        100,
	}

	var err error
	for _, d := range []struct {
		key string
		dst *time.Duration
	}{
		{"READ_HEADER_TIMEOUT", &c.ReadHeaderTimeout},
		{"READ_TIMEOUT", &c.ReadTimeout},
		{"WRITE_TIMEOUT", &c.WriteTimeout},
		{"IDLE_TIMEOUT", &c.IdleTimeout},
		{"SHUTDOWN_TIMEOUT", &c.ShutdownTimeout},
		{"REQUEST_TIMEOUT", &c.RequestTimeout},
	} {
		if v := os.Getenv(d.key); v != "" {
			if *d.dst, err = time.ParseDuration(v); err != nil {
				return c, fmt.Errorf("%s: %w", d.key, err)
			}
		}
	}
	if v := os.Getenv("MAX_BODY_BYTES"); v != "" {
		if c.MaxBodyBytes, err = strconv.ParseInt(v, 10, 64); err != nil {
			return c, fmt.Errorf("MAX_BODY_BYTES: %w", err)
		}
	}
	if v := os.Getenv("MAX_NAME_LEN"); v != "" {
		if c.MaxNameLen, err = strconv.Atoi(v); err != nil {
			return c, fmt.Errorf("MAX_NAME_LEN: %w", err)
		}
	}

	// Fail fast on nonsense values instead of misbehaving at runtime.
	if c.MaxBodyBytes <= 0 || c.MaxNameLen <= 0 || c.RequestTimeout <= 0 {
		return c, fmt.Errorf("limits and request timeout must be positive")
	}
	if c.RequestTimeout >= c.WriteTimeout {
		return c, fmt.Errorf("REQUEST_TIMEOUT (%s) must be below WRITE_TIMEOUT (%s)", c.RequestTimeout, c.WriteTimeout)
	}
	return c, nil
}

func env(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

Several choices here are worth copying:

Defaults in one place. The Config{...} literal is the documentation of what the app does when you set nothing. Safe defaults mean the app starts on a laptop with no setup.

A table-driven loop for durations. Instead of ten copies of "read env var, parse, handle error", one loop over {key, destination pointer} pairs. Adding a setting later is one line.

Typed values, not strings. time.Duration for timeouts (10s, 500ms), int64 for byte sizes. The rest of the program never parses text.

Fail fast. After loading, Load validates the values and refuses to continue on nonsense. Try it:

$ REQUEST_TIMEOUT=20s WRITE_TIMEOUT=15s go run ./cmd/rest
ERROR fatal err="REQUEST_TIMEOUT (20s) must be below WRITE_TIMEOUT (15s)"

Why that particular rule? Imagine the per-request deadline were longer than the server's write timeout. The server would cut the connection before our handler could tell the client "I timed out". Catching that at startup, in the first second, costs nothing. Discovering it at 3 a.m. under load costs a lot. In general: a misconfigured service should crash immediately and loudly, not run subtly wrong.

Handler limits become injected values

Previously:

const maxBodyBytes = 1 << 20

Now the handler receives a struct:

internal/handlers/handlers.go (excerpt)

// Limits are the per-handler constraints. They are injected rather than
// hard-coded so each environment (and each test) can choose its own.
type Limits struct {
	// MaxBodyBytes caps request bodies. Without a cap, a client can stream
	// gigabytes at json.Decoder and exhaust memory.
	MaxBodyBytes int64
	MaxNameLen   int
}

type Handler struct {
	store  UserStore
	log    *slog.Logger
	limits Limits
}

func NewHandler(store UserStore, log *slog.Logger, limits Limits) *Handler {
	return &Handler{store: store, log: log, limits: limits}
}

and decodeUser uses h.limits.MaxBodyBytes. The constant became data that flows in. A test can now build a handler with a 64-byte limit and send a 90-byte body to prove the 413 path, without allocating 2 MB:

$ MAX_BODY_BYTES=64 ADDR=:7801 go run ./cmd/rest &
$ curl -i -XPOST localhost:7801/users -d '{"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
HTTP/1.1 413 Request Entity Too Large
{"error":"request body too large"}

A per-request deadline

This post also adds one new middleware:

internal/middleware/middleware.go (excerpt)

// Timeout puts a deadline on each request's context. Handlers pass r.Context()
// to the store, so a slow query is cancelled when the deadline hits instead of
// running on after the client has given up.
func Timeout(d time.Duration) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx, cancel := context.WithTimeout(r.Context(), d)
			defer cancel()
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

It wraps the request's context in context.WithTimeout. Because Part 3 made every store call take that context, a slow query is cancelled when the deadline passes, and its database connection is freed, instead of running on while the client has long given up. WriteTimeout protects the network connection; this protects the work. They guard different things, which is why the config demands REQUEST_TIMEOUT < WRITE_TIMEOUT.

Finally main wires everything from cfg:

Change cmd/rest/main.go

--- a/cmd/rest/main.go
+++ b/cmd/rest/main.go
@@ -8,23 +8,14 @@ import (
 	"os"
 	"os/signal"
 	"syscall"
-	"time"
 
+	"go-rest/internal/config"
 	"go-rest/internal/handlers"
 	"go-rest/internal/middleware"
 	"go-rest/internal/routes"
 	"go-rest/internal/storage"
 )
 
-// getenv reads configuration from the environment (twelve-factor style) so
-// the same binary runs anywhere without recompiling.
-func getenv(key, fallback string) string {
-	if v := os.Getenv(key); v != "" {
-		return v
-	}
-	return fallback
-}
-
 func main() {
 	// run() returns an error instead of calling log.Fatal inside main's body.
 	// log.Fatal calls os.Exit, which skips deferred functions, so a deferred
@@ -38,42 +29,49 @@ func main() {
 func run() error {
 	log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
 
-	addr := getenv("ADDR", ":7800")
-	// An absolute path (or one set via env) avoids silently creating a
-	// different users.db depending on the working directory.
-	dbPath := getenv("DB_PATH", "users.db")
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
 
 	// ctx is cancelled on Ctrl-C (SIGINT) or SIGTERM (what Docker/Kubernetes
 	// send on stop), which triggers graceful shutdown below.
 	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
 	defer stop()
 
-	db, err := storage.InitDB(ctx, dbPath)
+	db, err := storage.InitDB(ctx, cfg.DBPath)
 	if err != nil {
 		return err
 	}
 	defer db.Close()
 
-	h := handlers.NewHandler(storage.NewUserStore(db), log)
+	h := handlers.NewHandler(storage.NewUserStore(db), log, handlers.Limits{
+		MaxBodyBytes: cfg.MaxBodyBytes,
+		MaxNameLen:   cfg.MaxNameLen,
+	})
 	mux := http.NewServeMux()
 	routes.SetupRoutes(mux, h)
 
 	srv := &http.Server{
-		Addr:    addr,
-		Handler: middleware.Chain(mux, middleware.Logging(log), middleware.Recover(log)),
+		Addr: cfg.Addr,
+		Handler: middleware.Chain(mux,
+			middleware.Logging(log),
+			middleware.Recover(log),
+			middleware.Timeout(cfg.RequestTimeout),
+		),
 		// The zero-value http.Server has NO timeouts, so slow or malicious
 		// clients (e.g. Slowloris) can hold connections open forever.
-		ReadHeaderTimeout: 5 * time.Second,
-		ReadTimeout:       10 * time.Second,
-		WriteTimeout:      15 * time.Second,
-		IdleTimeout:       60 * time.Second,
+		ReadHeaderTimeout: cfg.ReadHeaderTimeout,
+		ReadTimeout:       cfg.ReadTimeout,
+		WriteTimeout:      cfg.WriteTimeout,
+		IdleTimeout:       cfg.IdleTimeout,
 	}
 
 	// ListenAndServe blocks, so run it in a goroutine and wait on either a
 	// server failure or a shutdown signal.
 	serverErr := make(chan error, 1)
 	go func() {
-		log.Info("server starting", "addr", addr, "db", dbPath)
+		log.Info("server starting", "addr", cfg.Addr, "db", cfg.DBPath)
 		serverErr <- srv.ListenAndServe()
 	}()
 
@@ -90,7 +88,7 @@ func run() error {
 	// Stop accepting new connections and wait (bounded) for in-flight requests
 	// to finish, so a half-done write is not killed mid-request. Only then does
 	// the deferred db.Close() run.
-	shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
 	defer cancel()
 	if err := srv.Shutdown(shutdownCtx); err != nil {
 		return err

Complete code for this part

Everything you need to type. main.go and config.go are shown above; the rest is below. Paste, then go mod tidy && go build ./....

Already given earlier in this post: internal/config/config.go, cmd/rest/main.go.

Change internal/handlers/handlers.go

--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"io"
 	"log/slog"
 	"net/http"
@@ -14,13 +15,14 @@ import (
 	"go-rest/internal/storage"
 )
 
-const (
-	// maxBodyBytes caps request bodies. Without a cap, a client can stream
+// Limits are the per-handler constraints. They are injected rather than
+// hard-coded so each environment (and each test) can choose its own.
+type Limits struct {
+	// MaxBodyBytes caps request bodies. Without a cap, a client can stream
 	// gigabytes at json.Decoder and exhaust memory.
-	maxBodyBytes = 1 << 20 // 1 MiB
-
-	maxNameLen = 100
-)
+	MaxBodyBytes int64
+	MaxNameLen   int
+}
 
 // UserStore is the behaviour the handlers need, declared *here, where it is
 // used* ("accept interfaces, return structs"). Benefits: handlers can be
@@ -34,12 +36,13 @@ type UserStore interface {
 }
 
 type Handler struct {
-	store UserStore
-	log   *slog.Logger
+	store  UserStore
+	log    *slog.Logger
+	limits Limits
 }
 
-func NewHandler(store UserStore, log *slog.Logger) *Handler {
-	return &Handler{store: store, log: log}
+func NewHandler(store UserStore, log *slog.Logger, limits Limits) *Handler {
+	return &Handler{store: store, log: log, limits: limits}
 }
 
 // userInput is the request-body shape. Using a separate type (instead of
@@ -117,7 +120,7 @@ func (h *Handler) parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
 func (h *Handler) decodeUser(w http.ResponseWriter, r *http.Request) (userInput, bool) {
 	// MaxBytesReader (unlike io.LimitReader) makes reads fail with an error
 	// once the limit is hit and tells the server to close the connection.
-	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+	r.Body = http.MaxBytesReader(w, r.Body, h.limits.MaxBodyBytes)
 
 	dec := json.NewDecoder(r.Body)
 	// Reject unknown fields so typos ("nmae") fail loudly instead of being
@@ -146,8 +149,8 @@ func (h *Handler) decodeUser(w http.ResponseWriter, r *http.Request) (userInput,
 	case in.Name == "":
 		h.writeError(w, r, http.StatusUnprocessableEntity, "name is required")
 		return in, false
-	case utf8.RuneCountInString(in.Name) > maxNameLen:
-		h.writeError(w, r, http.StatusUnprocessableEntity, "name must be at most 100 characters")
+	case utf8.RuneCountInString(in.Name) > h.limits.MaxNameLen:
+		h.writeError(w, r, http.StatusUnprocessableEntity, fmt.Sprintf("name must be at most %d characters", h.limits.MaxNameLen))
 		return in, false
 	}
 	return in, true

Change internal/middleware/middleware.go

--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -4,6 +4,7 @@
 package middleware
 
 import (
+	"context"
 	"log/slog"
 	"net/http"
 	"time"
@@ -55,6 +56,19 @@ func Logging(log *slog.Logger) func(http.Handler) http.Handler {
 	}
 }
 
+// Timeout puts a deadline on each request's context. Handlers pass r.Context()
+// to the store, so a slow query is cancelled when the deadline hits instead of
+// running on after the client has given up.
+func Timeout(d time.Duration) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			ctx, cancel := context.WithTimeout(r.Context(), d)
+			defer cancel()
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
+}
+
 // Chain applies middlewares so the first listed is the outermost.
 func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
 	for i := len(mws) - 1; i >= 0; i-- {

What we deliberately did not do

  • No config file format (YAML/TOML). Env vars are enough here, and fewer formats means less to learn. Large systems often add a file for structured settings; the principle of "load once, validate, inject" is identical.
  • No config library. About 90 lines of standard library; there is nothing a dependency would add yet.
  • Secrets. Passwords and keys also arrive through env vars in this series for simplicity, but in production they should come from a secrets manager (Vault, AWS Secrets Manager, Kubernetes Secrets), never be committed, and never be logged. We will revisit this with database URLs and signing keys.

Recap

  • Config comes from the environment; only main reads it.
  • Typed config struct, defaults in one literal, loop for repetitive parsing.
  • Validate at startup; crash loudly on invalid combinations.
  • Inject limits into components; don't hide constants inside them.
  • WriteTimeout guards the connection, a context deadline guards the work.

Exercises

  1. Add LOG_LEVEL (debug|info|warn|error) and apply it to the slog handler. (slog.Level has an UnmarshalText method.)
  2. Write a table-driven test for config.Load using t.Setenv. Cover: defaults, a bad duration, and the timeout-ordering rule.
  3. What happens if MAX_BODY_BYTES is smaller than a typical valid request? Think about what the right validation rule would be.

One extra table-driven case for the exercise: READ_HEADER_TIMEOUT=-1s. It parses successfully and the current validation accepts it, but net/http treats a negative ReadHeaderTimeout as no header timeout.

I would either reject that value at startup or make disabling the timeout an explicit documented option. Zero deserves its own case too: it falls back to ReadTimeout rather than disabling the header timeout outright. That distinction is easy to miss when every duration shares the same parser. Reference: https://pkg.go.dev/net/http#Server

More from this blog