Part 3: Production basics: validation, errors, context, middleware, graceful shutdown
Ten problems from Part 1, fixed. This is the post that turns a demo into something you could put behind a load balancer.
Series: Build a production-grade REST API in Go · Part 3 of 17 Where we are: the SQLite-backed API from Part 2.
This is the longest early post, and the most valuable. Each section below removes a specific way the app can hurt you. Remember our table from Part 1? By the end, problems 1, 3, 4, 5, 6, 7, 8, 9 and 10 are gone.
1. Errors you can test with errors.Is
In Part 2 the handler compared err.Error() == "user not found". Strings are a terrible API: reword the message and a 404 becomes a 500, with no compiler warning.
The fix is a sentinel error: one package-level value that everyone compares against.
internal/storage/storage.go (excerpt)
// ErrNotFound is a *sentinel error*: a package-level value callers compare
// against with errors.Is.
//
// Why not errors.New("user not found") inline and compare err.Error()?
// String comparison is brittle: reword the message, or wrap the error with
// fmt.Errorf("...: %w", err), and the comparison silently stops matching, so a
// 404 turns into a 500. errors.Is walks the whole wrap chain, so it keeps
// working however many layers add context.
var ErrNotFound = errors.New("user not found")
Callers use errors.Is(err, storage.ErrNotFound). The crucial property: errors.Is walks the wrap chain. When a lower layer adds context with fmt.Errorf("update user %d: %w", id, err), the %w verb keeps the original error inside, so the check still matches. A string comparison would have broken at the first wrap.
Rule of thumb: return errors with context (%w), check them with errors.Is / errors.As.
2. Pass the request's context all the way down
Every http.Request carries a context.Context. It is cancelled when the client disconnects or a deadline passes. In Part 2 our store used context.Background(), which never cancels, so abandoned requests kept occupying the database.
Now every store method takes ctx as its first parameter and hands it to QueryContext / ExecContext:
internal/storage/storage.go (excerpt)
// Update renames a user and returns the row as stored.
//
// `UPDATE ... RETURNING` (SQLite 3.35+) does the write and reads back the
// resulting row in one atomic statement. That fixes two things at once:
// - the response reflects what is actually in the database, not an echo of
// the request body;
// - "no row matched" shows up as sql.ErrNoRows, so no separate
// RowsAffected() call is needed.
func (s *UserStore) Update(ctx context.Context, id int, name string) (*User, error) {
u := &User{}
err := s.db.QueryRowContext(ctx,
"UPDATE users SET name = ? WHERE id = ? RETURNING id, name",
name, id,
).Scan(&u.ID, &u.Name)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("update user %d: %w", id, err)
}
return u, nil
}
// Delete returns only an error. The old (bool, error) signature was redundant:
// the bool could only ever be true when err == nil. Make illegal states
// unrepresentable: success is nil, absence is ErrNotFound.
func (s *UserStore) Delete(ctx context.Context, id int) error {
res, err := s.db.ExecContext(ctx, "DELETE FROM users WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete user %d: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
Two more improvements are visible here:
UPDATE ... RETURNING: one statement writes and reads back the stored row, so the API returns what is actually in the database instead of echoing the request. Zero rows means not found (sql.ErrNoRows), so no separateRowsAffectedcall is needed. (SQLite supportsRETURNINGsince 3.35; PostgreSQL has had it for years.)Deletereturns onlyerror. The old(bool, error)signature could only ever returntruewhenerr == nil: a value that carries no information. Make illegal states unrepresentable.
3. Never trust the request body
Our Part 1 API accepted {} and created a nameless user. Input validation lives in one function:
internal/handlers/handlers.go (excerpt)
// userInput is the request-body shape. Using a separate type (instead of
// decoding straight into storage.User) means a client-supplied "id" can never
// leak through: the server owns IDs.
type userInput struct {
Name string `json:"name"`
}
// decodeUser reads, size-limits, strictly decodes and validates the body.
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)
dec := json.NewDecoder(r.Body)
// Reject unknown fields so typos ("nmae") fail loudly instead of being
// silently ignored and producing an empty name.
dec.DisallowUnknownFields()
var in userInput
if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
h.writeError(w, r, http.StatusRequestEntityTooLarge, "request body too large")
return in, false
}
h.writeError(w, r, http.StatusBadRequest, "invalid JSON body")
return in, false
}
// A second Decode must hit EOF; otherwise the body had trailing garbage
// such as `{"name":"a"}{"name":"b"}`.
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
h.writeError(w, r, http.StatusBadRequest, "body must contain a single JSON object")
return in, false
}
in.Name = strings.TrimSpace(in.Name)
switch {
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")
return in, false
}
return in, true
}
Line by line, here is what each defense stops:
| Defense | Without it |
|---|---|
http.MaxBytesReader (1 MiB) |
a 5 GB body is read into memory: free denial of service. Unlike io.LimitReader, it makes reads fail and tells the server to close the connection |
DisallowUnknownFields() |
a typo like {"nmae":"Ann"} silently creates a nameless user |
second Decode must return io.EOF |
trailing junk like {"name":"a"}{"name":"b"} is accepted: ambiguous input that other components in front of you might read differently |
strings.TrimSpace + empty check |
" " passes as a name |
utf8.RuneCountInString ≤ 100 |
counts characters, not bytes, so non-English names aren't unfairly rejected |
Also notice userInput: a separate type for the request body instead of decoding straight into storage.User. If we decoded into User, a client could send {"id": 1, "name": "x"} and set an id. Request shapes and database shapes are different contracts; keep them different types. We will rely on this heavily in the authorization post.
Which status code? 400 = "I could not parse what you sent", 413 = body too large, 422 Unprocessable Entity = "I understood it, but it is not acceptable" (empty name). The distinction helps clients decide whether to fix syntax or data.
4. One place for error responses, and don't leak internals
Plain http.Error text bodies were inconsistent with our JSON successes. We now centralize responses:
internal/handlers/handlers.go (excerpt)
// storeError maps domain errors to HTTP status codes in ONE place, replacing
// the copy-pasted err.Error() == "user not found" checks.
func (h *Handler) storeError(w http.ResponseWriter, r *http.Request, op string, err error) {
if errors.Is(err, storage.ErrNotFound) {
h.writeError(w, r, http.StatusNotFound, "user not found")
return
}
h.internalError(w, r, op, err)
}
// internalError logs the real cause server-side but tells the client nothing
// about internals (paths, SQL, driver messages can leak useful info to an
// attacker). The client gets a generic message; operators get the detail.
func (h *Handler) internalError(w http.ResponseWriter, r *http.Request, op string, err error) {
// A cancelled request context is the client leaving, not a server fault.
if errors.Is(err, context.Canceled) {
h.log.Info("request cancelled", "op", op, "method", r.Method, "path", r.URL.Path)
return
}
h.log.Error("handler failed", "op", op, "method", r.Method, "path", r.URL.Path, "err", err)
h.writeError(w, r, http.StatusInternalServerError, "internal server error")
}
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, status int, msg string) {
h.writeJSON(w, r, status, map[string]string{"error": msg})
}
// writeJSON centralises response writing. Errors are not ignored: by the time
// Encode fails the status line is usually already sent so we cannot change the
// response, but we can (and must) log it.
func (h *Handler) writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
h.log.Error("encode response", "path", r.URL.Path, "err", err)
}
}
Design points:
- Clients get a stable shape (
{"error": "..."}), so they can handle failures without parsing prose. - Internal errors are logged, not returned. A raw database error can reveal table names, file paths, driver versions: gifts to an attacker. The client sees "internal server error"; the operator gets the full cause in the log.
context.Canceledis not a server fault. When a client hangs up mid-request, we log it at INFO and don't count it as an error.writeJSONchecksEncode's error. By the time it fails the status line is usually sent, so we can't change the response, but we can and must log it (problem #10).- Status mapping (
ErrNotFound→ 404) lives instoreError, once, instead of being copy-pasted into three handlers.
5. Handlers depend on an interface, not a database
Look at the top of handlers.go:
type UserStore interface {
GetAll(ctx context.Context) ([]*storage.User, error)
Create(ctx context.Context, name string) (*storage.User, error)
Update(ctx context.Context, id int, name string) (*storage.User, error)
Delete(ctx context.Context, id int) error
}
This is the Go proverb "accept interfaces, return structs" in practice. The interface is declared where it is used (the handlers), not where it is implemented (storage). *storage.UserStore satisfies it automatically, because Go interfaces are implicit. The payoff arrives in Part 6, when we replace SQLite with PostgreSQL and the handlers do not change at all.
6. Middleware: behavior around every request
Logging and panic recovery apply to every route, so they don't belong in handlers. A middleware is just a function that wraps a handler:
func(next http.Handler) http.Handler
internal/middleware/middleware.go (excerpt)
// Recover turns a panic in any handler into a 500 instead of letting it
// unwind. (net/http already recovers per-connection, but it just closes the
// connection with no useful log; we log the panic with context.)
func Recover(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 recovered", "panic", rec, "method", r.Method, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// statusRecorder remembers the status code, which http.ResponseWriter does not
// let us read back after the fact.
type statusRecorder struct {
http.ResponseWriter
status int
}
// Logging emits one structured line per request: method, path, status, latency.
func Logging(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) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
)
})
}
}
// 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-- {
h = mws[i](h)
}
return h
}
Recover: if a handler panics,net/httpprints a stack trace to stderr and drops the connection: no response for the client and no structured log. We turn it into a logged 500 with request context.statusRecorder:http.ResponseWriterhas no way to read back the status code that was written, so we wrap it and remember it. (A pattern you will see in every Go logging middleware.)Logging: one structured line per request with method, path, status and duration.Chain: applies middlewares so the first one listed is the outermost.
We use Go's log/slog for structured logging: JSON objects, one per line, that tools like Loki or Elasticsearch can query by field. Compare GET /users 200 1.2ms (text you must regex) with {"msg":"request","status":200,"duration":1203} (data you can filter and graph).
7. A server that survives the real world
The zero-value http.Server has no timeouts at all. A malicious (or just slow, mobile) client can open a connection and dribble bytes forever ("Slowloris"), tying up a goroutine and file descriptor each time. And Ctrl-C used to kill in-flight requests mid-write.
Both are fixed in main. Notice we moved the body into run() error:
cmd/rest/main.go (excerpt)
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")
// 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)
if err != nil {
return err
}
defer db.Close()
h := handlers.NewHandler(storage.NewUserStore(db), log)
mux := http.NewServeMux()
routes.SetupRoutes(mux, h)
srv := &http.Server{
Addr: addr,
Handler: middleware.Chain(mux, middleware.Logging(log), middleware.Recover(log)),
// 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,
}
// 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)
serverErr <- srv.ListenAndServe()
}()
select {
case err := <-serverErr:
// ErrServerClosed is the normal result of Shutdown, not a failure.
if !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-ctx.Done():
log.Info("shutdown signal received")
}
// 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)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
log.Info("server stopped cleanly")
return nil
}
Study it in three parts:
Timeouts. ReadHeaderTimeout (time to send headers), ReadTimeout (whole request), WriteTimeout (whole response), IdleTimeout (keep-alive connections waiting for the next request). Set all four.
Signals. signal.NotifyContext gives us a context that is cancelled on SIGINT (Ctrl-C) or SIGTERM. SIGTERM is what Docker and Kubernetes send when they stop a container, so handling it is mandatory in production.
Graceful shutdown. On the signal we call srv.Shutdown(ctx): stop accepting new connections, let in-flight requests finish (up to 15 s), then return. Only after that does the deferred db.Close() run, so no request ever loses its database mid-flight.
Why run()? log.Fatal calls os.Exit, which skips deferred functions: db.Close() would never run. By returning an error from run() and only exiting in main, every defer executes first. A small pattern that prevents a class of bugs.
8. SQLite settings that matter
InitDB now passes settings in the connection string:
journal_mode(WAL): write-ahead logging lets readers and one writer work simultaneously.busy_timeout(5000): wait up to 5 s for a lock instead of failing instantly.foreign_keys(1): SQLite ignores foreign keys unless asked. We don't use them yet; we will in Part 5.
They go in the DSN (not in a one-off db.Exec("PRAGMA ...")) because PRAGMAs are per connection and database/sql may open new connections at any time. The DSN applies to every connection in the pool.
9. Tests that exercise the real stack
internal/handlers/handlers_test.go (excerpt)
// newServer wires the real router, handlers and a temp SQLite store, so these
// tests exercise the full HTTP stack end to end.
func newServer(t *testing.T) *httptest.Server {
t.Helper()
db, err := storage.InitDB(t.Context(), filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
h := handlers.NewHandler(storage.NewUserStore(db), slog.New(slog.NewTextHandler(io.Discard, nil)))
mux := http.NewServeMux()
routes.SetupRoutes(mux, h)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func do(t *testing.T, method, url, body string) (int, string) {
t.Helper()
req, _ := http.NewRequest(method, url, strings.NewReader(body))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
httptest.NewServer starts a real HTTP server on a random port, wired with the real router, real handlers and a real (temporary) SQLite database. Then a table of cases:
internal/handlers/handlers_test.go (lines 51-69)
tests := []struct {
name, method, path, body string
want int
}{
{"create ok", "POST", u, `{"name":"ann"}`, 201},
{"create empty name", "POST", u, `{}`, 422},
{"create blank name", "POST", u, `{"name":" "}`, 422},
{"create unknown field", "POST", u, `{"name":"a","id":9}`, 400},
{"create bad json", "POST", u, `{`, 400},
{"create trailing data", "POST", u, `{"name":"a"}{"name":"b"}`, 400},
{"create too long", "POST", u, `{"name":"` + strings.Repeat("a", 101) + `"}`, 422},
{"create too big", "POST", u, `{"name":"` + strings.Repeat("a", 2<<20) + `"}`, 413},
{"list", "GET", u, "", 200},
{"update ok", "PUT", u + "/1", `{"name":"anna"}`, 200},
{"update missing", "PUT", u + "/999", `{"name":"x"}`, 404},
{"update bad id", "PUT", u + "/abc", `{"name":"x"}`, 400},
{"delete ok", "DELETE", u + "/1", "", 204},
{"delete again", "DELETE", u + "/1", "", 404},
}
Why test this way rather than mocking the store? Because most bugs live in the seams: routing patterns, status codes, JSON tags, SQL. A test that goes through all of them catches what unit tests with mocks cannot. Always run with the race detector:
go test -race ./...
Complete code for this part
Everything you need to type, file by file. Paste them in any order, then run go mod tidy and go build ./.... (The test files are shown above as the interesting parts only; the code below is complete without them.)
Replace the contents of internal/handlers/handlers.go with
package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"go-rest/internal/storage"
)
const (
// 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
)
// UserStore is the behaviour the handlers need, declared *here, where it is
// used* ("accept interfaces, return structs"). Benefits: handlers can be
// unit-tested with a fake store, and they no longer depend on the concrete
// SQLite-backed type. *storage.UserStore satisfies it implicitly.
type UserStore interface {
GetAll(ctx context.Context) ([]*storage.User, error)
Create(ctx context.Context, name string) (*storage.User, error)
Update(ctx context.Context, id int, name string) (*storage.User, error)
Delete(ctx context.Context, id int) error
}
type Handler struct {
store UserStore
log *slog.Logger
}
func NewHandler(store UserStore, log *slog.Logger) *Handler {
return &Handler{store: store, log: log}
}
// userInput is the request-body shape. Using a separate type (instead of
// decoding straight into storage.User) means a client-supplied "id" can never
// leak through: the server owns IDs.
type userInput struct {
Name string `json:"name"`
}
func (h *Handler) HandleGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.GetAll(r.Context())
if err != nil {
h.internalError(w, r, "list users", err)
return
}
h.writeJSON(w, r, http.StatusOK, users)
}
func (h *Handler) HandleCreateUsers(w http.ResponseWriter, r *http.Request) {
in, ok := h.decodeUser(w, r)
if !ok {
return
}
created, err := h.store.Create(r.Context(), in.Name)
if err != nil {
h.internalError(w, r, "create user", err)
return
}
h.writeJSON(w, r, http.StatusCreated, created)
}
func (h *Handler) HandleUpdateUsers(w http.ResponseWriter, r *http.Request) {
id, ok := h.parseID(w, r)
if !ok {
return
}
in, ok := h.decodeUser(w, r)
if !ok {
return
}
updated, err := h.store.Update(r.Context(), id, in.Name)
if err != nil {
h.storeError(w, r, "update user", err)
return
}
h.writeJSON(w, r, http.StatusOK, updated)
}
func (h *Handler) HandleDeleteUsers(w http.ResponseWriter, r *http.Request) {
id, ok := h.parseID(w, r)
if !ok {
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
h.storeError(w, r, "delete user", err)
return
}
// 204 No Content is the REST convention for a successful delete: nothing
// useful to return, so send no body (and no Content-Type).
w.WriteHeader(http.StatusNoContent)
}
// parseID extracts and validates the {id} path value. Returns ok=false after
// having already written the error response.
func (h *Handler) parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id <= 0 {
h.writeError(w, r, http.StatusBadRequest, "invalid user id")
return 0, false
}
return id, true
}
// decodeUser reads, size-limits, strictly decodes and validates the body.
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)
dec := json.NewDecoder(r.Body)
// Reject unknown fields so typos ("nmae") fail loudly instead of being
// silently ignored and producing an empty name.
dec.DisallowUnknownFields()
var in userInput
if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
h.writeError(w, r, http.StatusRequestEntityTooLarge, "request body too large")
return in, false
}
h.writeError(w, r, http.StatusBadRequest, "invalid JSON body")
return in, false
}
// A second Decode must hit EOF; otherwise the body had trailing garbage
// such as `{"name":"a"}{"name":"b"}`.
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
h.writeError(w, r, http.StatusBadRequest, "body must contain a single JSON object")
return in, false
}
in.Name = strings.TrimSpace(in.Name)
switch {
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")
return in, false
}
return in, true
}
// storeError maps domain errors to HTTP status codes in ONE place, replacing
// the copy-pasted err.Error() == "user not found" checks.
func (h *Handler) storeError(w http.ResponseWriter, r *http.Request, op string, err error) {
if errors.Is(err, storage.ErrNotFound) {
h.writeError(w, r, http.StatusNotFound, "user not found")
return
}
h.internalError(w, r, op, err)
}
// internalError logs the real cause server-side but tells the client nothing
// about internals (paths, SQL, driver messages can leak useful info to an
// attacker). The client gets a generic message; operators get the detail.
func (h *Handler) internalError(w http.ResponseWriter, r *http.Request, op string, err error) {
// A cancelled request context is the client leaving, not a server fault.
if errors.Is(err, context.Canceled) {
h.log.Info("request cancelled", "op", op, "method", r.Method, "path", r.URL.Path)
return
}
h.log.Error("handler failed", "op", op, "method", r.Method, "path", r.URL.Path, "err", err)
h.writeError(w, r, http.StatusInternalServerError, "internal server error")
}
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, status int, msg string) {
h.writeJSON(w, r, status, map[string]string{"error": msg})
}
// writeJSON centralises response writing. Errors are not ignored: by the time
// Encode fails the status line is usually already sent so we cannot change the
// response, but we can (and must) log it.
func (h *Handler) writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
h.log.Error("encode response", "path", r.URL.Path, "err", err)
}
}
Create internal/middleware/middleware.go
// Package middleware holds cross-cutting HTTP concerns. A middleware is just
// func(http.Handler) http.Handler: it wraps the next handler, running code
// before and/or after it.
package middleware
import (
"log/slog"
"net/http"
"time"
)
// Recover turns a panic in any handler into a 500 instead of letting it
// unwind. (net/http already recovers per-connection, but it just closes the
// connection with no useful log; we log the panic with context.)
func Recover(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 recovered", "panic", rec, "method", r.Method, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// statusRecorder remembers the status code, which http.ResponseWriter does not
// let us read back after the fact.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// Logging emits one structured line per request: method, path, status, latency.
func Logging(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) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
)
})
}
}
// 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-- {
h = mws[i](h)
}
return h
}
Replace the contents of internal/storage/storage.go with
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
_ "modernc.org/sqlite" // registers the "sqlite" driver with database/sql
)
// ErrNotFound is a *sentinel error*: a package-level value callers compare
// against with errors.Is.
//
// Why not errors.New("user not found") inline and compare err.Error()?
// String comparison is brittle: reword the message, or wrap the error with
// fmt.Errorf("...: %w", err), and the comparison silently stops matching, so a
// 404 turns into a 500. errors.Is walks the whole wrap chain, so it keeps
// working however many layers add context.
var ErrNotFound = errors.New("user not found")
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
// InitDB opens (creating if needed) the SQLite database at path and applies
// the schema.
//
// Per-connection settings are passed as _pragma query parameters in the DSN.
// This matters: PRAGMAs are per *connection*, and database/sql may open new
// connections at any time. Running `db.Exec("PRAGMA ...")` once would only
// configure whichever connection happened to execute it. The DSN is applied to
// every connection the pool opens.
//
// - busy_timeout(5000): if another connection/process holds the write lock,
// wait up to 5s instead of failing instantly with SQLITE_BUSY.
// - journal_mode(WAL): write-ahead logging lets readers and one writer work
// concurrently (default rollback-journal mode blocks readers during
// writes). It is also what creates the users.db-wal / -shm files that
// .gitignore already lists. The mode is persisted in the file itself.
// - synchronous(NORMAL): the recommended pairing with WAL; durable across
// application crashes, trading only a tiny window on OS crash/power loss
// for much faster commits than FULL.
// - foreign_keys(1): SQLite ignores FOREIGN KEY constraints unless this is
// on. Unused today, but enabling it now avoids a nasty surprise later.
func InitDB(ctx context.Context, path string) (*sql.DB, error) {
q := url.Values{}
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "synchronous(NORMAL)")
q.Add("_pragma", "foreign_keys(1)")
dsn := "file:" + path + "?" + q.Encode()
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// With WAL, many readers can run alongside one writer, so we no longer need
// the old SetMaxOpenConns(1), which serialised *everything* behind a single
// connection. A small bounded pool keeps concurrency without letting a
// traffic spike open unlimited file handles. SQLite itself still allows only
// one writer at a time; busy_timeout makes extra writers queue politely.
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(4)
const schema = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL CHECK (length(trim(name)) > 0)
);`
// The CHECK is defence in depth: handlers validate input, but the database
// is the last line of defence against empty names from any other code path.
// PingContext forces a real connection now, so a bad path or unwritable
// directory fails at startup rather than on the first request.
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
if _, err := db.ExecContext(ctx, schema); err != nil {
db.Close()
return nil, fmt.Errorf("create schema: %w", err)
}
return db, nil
}
// Every method takes a context.Context as its first parameter and passes it to
// the *Context database calls. When the HTTP client disconnects or the request
// times out, the context is cancelled and the query is abandoned, so the
// connection goes back to the pool instead of being held by a dead request.
// (Previously these used context.Background(), which can never be cancelled.)
func (s *UserStore) GetAll(ctx context.Context) ([]*User, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name FROM users ORDER BY id")
if err != nil {
return nil, fmt.Errorf("query users: %w", err)
}
defer rows.Close()
users := make([]*User, 0) // non-nil so JSON encodes [] rather than null
for rows.Next() {
u := &User{}
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
// rows.Err reports any error that ended iteration early (e.g. cancelled
// context). Skipping this check can silently return a truncated list.
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate users: %w", err)
}
return users, nil
}
func (s *UserStore) Create(ctx context.Context, name string) (*User, error) {
res, err := s.db.ExecContext(ctx, "INSERT INTO users (name) VALUES (?)", name)
if err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("last insert id: %w", err)
}
return &User{ID: int(id), Name: name}, nil
}
// Update renames a user and returns the row as stored.
//
// `UPDATE ... RETURNING` (SQLite 3.35+) does the write and reads back the
// resulting row in one atomic statement. That fixes two things at once:
// - the response reflects what is actually in the database, not an echo of
// the request body;
// - "no row matched" shows up as sql.ErrNoRows, so no separate
// RowsAffected() call is needed.
func (s *UserStore) Update(ctx context.Context, id int, name string) (*User, error) {
u := &User{}
err := s.db.QueryRowContext(ctx,
"UPDATE users SET name = ? WHERE id = ? RETURNING id, name",
name, id,
).Scan(&u.ID, &u.Name)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("update user %d: %w", id, err)
}
return u, nil
}
// Delete returns only an error. The old (bool, error) signature was redundant:
// the bool could only ever be true when err == nil. Make illegal states
// unrepresentable: success is nil, absence is ErrNotFound.
func (s *UserStore) Delete(ctx context.Context, id int) error {
res, err := s.db.ExecContext(ctx, "DELETE FROM users WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete user %d: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
Replace the contents of cmd/rest/main.go with
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"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
// db.Close() would never run. Returning lets every defer execute first.
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
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")
// 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)
if err != nil {
return err
}
defer db.Close()
h := handlers.NewHandler(storage.NewUserStore(db), log)
mux := http.NewServeMux()
routes.SetupRoutes(mux, h)
srv := &http.Server{
Addr: addr,
Handler: middleware.Chain(mux, middleware.Logging(log), middleware.Recover(log)),
// 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,
}
// 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)
serverErr <- srv.ListenAndServe()
}()
select {
case err := <-serverErr:
// ErrServerClosed is the normal result of Shutdown, not a failure.
if !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-ctx.Done():
log.Info("shutdown signal received")
}
// 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)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
log.Info("server stopped cleanly")
return nil
}
Replace the contents of makefile with
.PHONY: run build test clean clean-db db-shell
DB_FILE ?= users.db
BINARY := bin/server
# Export so the server and the targets below agree on the same database file.
export DB_PATH := $(abspath $(DB_FILE))
run:
go run ./cmd/rest
build:
mkdir -p bin
go build -o $(BINARY) ./cmd/rest
test:
go vet ./...
go test -race ./...
# Removes build artifacts only; data is deliberately kept.
clean:
rm -f $(BINARY)
# Destructive: deletes the database and its WAL/SHM sidecar files.
clean-db:
rm -f $(DB_FILE) $(DB_FILE)-wal $(DB_FILE)-shm
db-shell:
@command -v sqlite3 >/dev/null || { echo "sqlite3 CLI not installed"; exit 1; }
sqlite3 $(DB_PATH)
Try it
$ curl -i -XPOST localhost:7800/users -d '{}'
HTTP/1.1 422 Unprocessable Entity
{"error":"name is required"}
$ curl -i -XPOST localhost:7800/users -d '{"name":"a","id":9}'
HTTP/1.1 400 Bad Request
{"error":"invalid JSON body"}
$ curl -i -XPOST localhost:7800/users -d '{"name":" "}'
HTTP/1.1 422 Unprocessable Entity
{"error":"name is required"}
$ curl -i -XPOST localhost:7800/users --data-binary @2MB.json
HTTP/1.1 413 Request Entity Too Large
{"error":"request body too large"}
$ curl -i -XPUT localhost:7800/users/99 -d '{"name":"x"}'
HTTP/1.1 404 Not Found
{"error":"user not found"}
And in the server terminal, one JSON log line per request, then on Ctrl-C:
{"time":"...","level":"INFO","msg":"request","method":"POST","path":"/users","status":422,"duration":541291}
{"time":"...","level":"INFO","msg":"shutdown signal received"}
{"time":"...","level":"INFO","msg":"server stopped cleanly"}
Scorecard
| # | Problem from Part 1 | Status |
|---|---|---|
| 1 | No validation | ✅ strict decode + checks |
| 2 | In-memory | ✅ (Part 2) |
| 3 | Global variable | ✅ (Part 2) |
| 4 | Random list order | ✅ ORDER BY id |
| 5 | Plain-text errors | ✅ JSON error shape |
| 6 | Unbounded body | ✅ 1 MiB cap |
| 7 | No timeouts | ✅ four timeouts |
| 8 | No graceful shutdown | ✅ SIGTERM + Shutdown |
| 9 | Shared pointers | ✅ values come from the DB |
| 10 | Ignored Encode errors |
✅ logged |
Recap
- Sentinel errors +
errors.Is+%winstead of string matching. - Thread
contextfrom the request down to the database. - Validate and bound every input; separate request types from storage types.
- Log the cause, return a generic message.
- Depend on interfaces declared by the consumer.
- Middleware for cross-cutting concerns; timeouts and graceful shutdown on the server.
Exercises
- Add a middleware that rejects requests whose
Content-Typeis notapplication/jsonon POST/PUT with 415. - Write a test that proves
Recoverworks: a handler that panics must yield a 500 and the server must keep serving. - Send
kill -TERMto the server while a slow request is in flight (add atime.Sleep(5*time.Second)to a handler). Does the request finish? Now setShutdown's timeout to 1 s and repeat.
Comments
Join the discussionNo comments yet. Be the first to comment.
Build a production-grade REST API in Go
A hands-on series: start with under 200 lines of in-memory CRUD and end with Postgres, JWT auth, rate limiting, TLS and a full observability stack.