Boni KYC Lab 2 · Go backend ~7 min Step 1
Lab 2 · Go backend

Routing, middleware, logging, errors

a small httpx package: JSON write/read helpers, RFC 7807

problem+json errors, a middleware chain (request id, structured access log, panic recovery, rate limit), all wired into the server from Lab 1.

Concepts — Go's http.Handler interface, middleware as func(http.Handler) http.Handler, context.Context value passing, closures over shared state.

Note

Idiom note: middleware in Go is just a function that wraps a handler and returns a handler. No decorators, no framework magic. You compose them by nesting. Understanding this once means you never need a web framework.

Step 2 · Build

Build

internal/httpx/respond.go:

internal/httpx/respond.go
package httpx

import (
	"encoding/json"
	"net/http"
)

func JSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v) // ⟵ YOU: should an encode error be logged? decide & justify
}

// Problem is RFC 7807. detail must NEVER contain internals or PII (02-security §9).
type Problem struct {
	Type    string `json:"type"`
	Title   string `json:"title"`
	Status  int    `json:"status"`
	Detail  string `json:"detail,omitempty"`
	TraceID string `json:"trace_id"`
}

func Error(w http.ResponseWriter, r *http.Request, status int, detail string) {
	// ⟵ YOU: pull trace id from context (set by middleware below),
	//        set Content-Type application/problem+json, write the Problem.
}
Step 3 · Build

internal/httpx/middleware.go

internal/httpx/middleware.go:

internal/httpx/middleware.go
package httpx

import (
	"context"
	"log/slog"
	"net/http"
	"time"

	"github.com/google/uuid" // ⟵ YOU: go get this
)

type ctxKey int

const traceIDKey ctxKey = 0

func TraceID(r *http.Request) string { v, _ := r.Context().Value(traceIDKey).(string); return v }

func WithTraceID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := uuid.NewString()
		w.Header().Set("X-Trace-Id", id)
		ctx := context.WithValue(r.Context(), traceIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func AccessLog(logger *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: 200} // ⟵ YOU: implement this type
			next.ServeHTTP(rec, r)
			logger.Info("request",
				"method", r.Method, "path", r.URL.Path,
				"status", rec.status, "dur_ms", time.Since(start).Milliseconds(),
				"trace_id", TraceID(r),
				// ⟵ YOU: NEVER log query params or body — could carry phone/OMANG. Why?
			)
		})
	}
}

// ⟵ YOU: Recover(logger) — recover() from panics, log with trace id, return 500 problem.
// ⟵ YOU: RateLimit(...) — token-bucket per client IP. golang.org/x/time/rate. Auth routes only.
Step 4 · Research checkpoints

Research checkpoints

`statusRecorder`

you can't read the status back off an http.ResponseWriter. Wrap it. (Search "go http middleware capture status code".) Bonus: why is Hijack/Flush pass-through sometimes needed?

Context keys

why an unexported ctxKey int type rather than a string? (Search "go context key collision".)

`x/time/rate`

token bucket: what do Limit and Burst mean? Where do per-IP limiters get stored, and how do you stop that map growing forever?

Panic in a goroutine

does Recover middleware catch a panic in a goroutine you spawned inside a handler? (No — and know why.)

Step 5 · Verify

Verify

  • Hitting an error path returns application/problem+json with a

trace_id that matches the X-Trace-Id header and a log line.

  • A handler that panics returns 500, logs once, server stays up.
  • 11 rapid hits to an auth route → the 11th gets 429.
  • No request body or query string ever appears in logs (grep to confirm).