# HTTP Patterns

> HTTP handler patterns for Go 1.22+. Covers ServeMux method routing, path parameters, middleware, ResponseWriter wrapping, request context, graceful shutdown, error responses, handler naming conventions. Use when this capability is needed.

- Skill: `tomevault-io/http-patterns` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/http-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/http-patterns/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/http-patterns

---


# HTTP Patterns

## Go 1.22: ServeMux Method Routing

No more manual method checks. Register with HTTP verb prefix:

```go
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthHandler)
mux.HandleFunc("GET /_cooked/{path...}", assetHandler)
mux.HandleFunc("GET /{upstream...}", renderHandler)
```

### Path Parameters

```go
func renderHandler(w http.ResponseWriter, r *http.Request) {
    upstream := r.PathValue("upstream")  // NEW in Go 1.22
    // ...
}
```

### Special Patterns

| Pattern | Matches |
|---------|---------|
| `/posts/{id}` | `/posts/123` (single segment) |
| `/files/{path...}` | `/files/a/b/c` (remainder of path) |
| `/posts/{$}` | `/posts/` only (not `/posts` or `/posts/x`) |

### Precedence

More specific wins:
- `/healthz` beats `/{upstream...}`
- `GET /posts/{id}` beats `/posts/{id}`

**Conflicting patterns panic at registration:**
```go
mux.HandleFunc("GET /posts/{id}", h1)
mux.HandleFunc("GET /{resource}/latest", h2)  // PANIC - both match /posts/latest
```

### Automatic 405

Unmatched methods return `405 Method Not Allowed` with `Allow` header.

### go.mod Requirement

Go 1.22+ patterns require `go 1.22` or later in `go.mod`. Without it, patterns are treated literally (braces aren't wildcards).

Sources: [Go Blog: Routing Enhancements](https://go.dev/blog/routing-enhancements), [Eli Bendersky](https://eli.thegreenplace.net/2023/better-http-server-routing-in-go-122/)

---

## Middleware Pattern

This project uses the standard wrapper pattern:

```go
func RequestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := &byteCountingWriter{ResponseWriter: w}
        next.ServeHTTP(wrapped, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", wrapped.statusCode,
            "bytes", wrapped.bytes,
            "total_ms", time.Since(start).Milliseconds(),
        )
    })
}

// Composable
var handler http.Handler = mux
handler = RequestLogger(handler)
handler = RecoveryMiddleware(handler)

srv := &http.Server{Handler: handler}
```

---

## Request Context

Store request-scoped values using typed keys:

```go
// Define typed key (prevents collisions)
type contextKey string
const loggerKey contextKey = "logger"

// Set in middleware
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))

// Retrieve in handlers
func Logger(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
        return l
    }
    return slog.Default()
}
```

---

## ResponseWriter Wrapping

Wrap to capture bytes and status:

```go
type byteCountingWriter struct {
    http.ResponseWriter
    bytes      int64
    statusCode int
}

func (w *byteCountingWriter) WriteHeader(code int) {
    w.statusCode = code
    w.ResponseWriter.WriteHeader(code)
}

func (w *byteCountingWriter) Write(b []byte) (int, error) {
    if w.statusCode == 0 {
        w.statusCode = 200  // Default if WriteHeader not called
    }
    n, err := w.ResponseWriter.Write(b)
    w.bytes += int64(n)
    return n, err
}
```

---

## Graceful Shutdown

### Standard Pattern

```go
srv := &http.Server{
    Addr:    ":8080",
    Handler: mux,
}

// Start server
go func() {
    if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
        log.Fatal(err)
    }
}()

// Wait for signal
<-ctx.Done()

// Graceful shutdown with timeout
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Printf("shutdown error: %v", err)
}
```

### What Shutdown() Does

1. Stops accepting new connections immediately
2. Closes idle connections
3. Waits for active requests to complete
4. Returns when all handlers finish OR context expires

### Kubernetes Considerations

Add a health endpoint that fails during shutdown:

```go
healthService.MarkShuttingDown()  // Called before Shutdown()
// Readiness probe now returns 503
```

Sources: [VictoriaMetrics](https://victoriametrics.com/blog/go-graceful-shutdown/), [DEV Community](https://dev.to/mokiat/proper-http-shutdown-in-go-3fji)

---

## Error Responses

cooked returns HTML error pages, not JSON:

```go
func renderError(w http.ResponseWriter, r *http.Request, status int, errType, message string) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.WriteHeader(status)
    // Render error template with same theming as content pages
}
```

Error pages use the same HTML template with:
- Proper theming (respects user's theme choice)
- `#cooked-error` element with `data-error-type` and `data-status-code`
- Direct link to the upstream URL

---

## Handler Naming

| Pattern | Returns | Example |
|---------|---------|---------|
| `FeatureHandler(deps)` | `http.HandlerFunc` | `RenderHandler(deps)` |
| `FeatureMiddleware(next)` | `http.Handler` | `RequestLogger(next)` |

---
> Converted and distributed by [TomeVault](https://tomevault.io/claim/air-gapped) — claim your Tome and manage your conversions.
<!-- tomevault:4.0:skill_md:2026-04-13 -->

