Go HTTP Services skill for Claude Code
Stdlib Go servers with timeouts, slog and clean drains. Claude builds Go APIs on net/http with method and wildcard routes, an http.Server that sets every timeout, and contexts that flow from the request into each database and HTTP call. Errors are wrapped with %w and mapped to status codes in one function, logs are slog JSON with request IDs, and SIGTERM drains in-flight requests before exit.
- name
go-services - size214 lines · ≈2,000 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Go HTTP services on the standard library - net/http method and wildcard routing (Go 1.22+), server timeouts, context propagation and cancellation, error wrapping and mapping to HTTP responses, structured logging with log/slog, JSON decoding limits, middleware, graceful shutdown, and httptest-based tests. Use when writing or reviewing a Go API or microservice, choosing between stdlib and a router framework, fixing leaking goroutines or hanging requests, or adding logging and shutdown handling to a Go server. Try asking
- Write a Go REST service for orders using only the standard library
- Our Go server leaks goroutines and requests hang. Review main.go
- Add structured logging and graceful shutdown to this Go API
Use it when
- Building or reviewing a Go HTTP API or microservice
- Deciding whether net/http routing is enough or a router is needed
- Requests hang, goroutines leak, or deploys drop connections
- Adding slog logging, error mapping or shutdown handling
Skip it when
- The question is general Go style, generics or concurrency — use go-idioms
- The service speaks protobuf over gRPC — use grpc-services
How to check the result
go vet, staticcheck, go test -race and govulncheck pass, and SIGTERM during a slow request lets it finish before the process exits 0.
The common mistake it prevents
Serving with http.ListenAndServe and no http.Server timeouts, leaving the process open to slow clients exhausting connections.
A production Go service sets every server timeout, threads context.Context from the
request into every I/O call, wraps errors with enough context to debug and maps them to
status codes in one place, logs structured JSON with request IDs, and drains in-flight
requests on SIGTERM.
Workflow
- [ ] 1. go version; read go.mod (Go directive, router, DB driver, logger)
- [ ] 2. Map main.go: config, dependency wiring, server construction, shutdown
- [ ] 3. Routing: stdlib ServeMux unless the project already uses chi/echo/gin
- [ ] 4. Handlers: decode -> validate -> call service with ctx -> encode
- [ ] 5. Errors: sentinel/typed errors in domain, one mapping to HTTP
- [ ] 6. Server timeouts, middleware (request ID, logging, recovery), shutdown
- [ ] 7. Tests with httptest; go vet, staticcheck, go test -race, govulncheck
Routing (Go 1.22+)
The standard ServeMux handles methods and path wildcards; a third-party router is only
needed for route groups with shared middleware at scale or existing project convention.
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
mux.Handle("GET /v1/orders/{id}", h.getOrder())
mux.Handle("POST /v1/orders", h.createOrder())
mux.Handle("GET /files/{path...}", h.serveFile()) // remainder wildcard
// inside the handler: id := r.PathValue("id")
More specific patterns win regardless of registration order; conflicting patterns panic
at registration, which surfaces mistakes at startup. Requires go 1.22 or later in go.mod.
Handlers
Use a small helper set rather than a framework:
func decodeJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) {
var v T
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB cap
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, fmt.Errorf("%w: %v", ErrBadRequest, err)
}
return v, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (h *Handlers) createOrder() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req, err := decodeJSON[CreateOrderRequest](w, r)
if err != nil {
h.fail(w, r, err)
return
}
order, err := h.orders.Create(r.Context(), req) // ctx flows into DB/HTTP calls
if err != nil {
h.fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, order)
})
}
Errors
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrBadRequest = errors.New("bad request")
)
type ValidationError struct{ Field, Msg string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
func (h *Handlers) fail(w http.ResponseWriter, r *http.Request, err error) {
var ve *ValidationError
switch {
case errors.As(err, &ve):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"field": ve.Field, "error": ve.Msg})
case errors.Is(err, ErrBadRequest):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed request"})
case errors.Is(err, ErrNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, ErrConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "conflict"})
case errors.Is(err, context.Canceled):
return // client went away; nothing to write
default:
h.log.ErrorContext(r.Context(), "request failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
Rules: wrap with fmt.Errorf("load order %s: %w", id, err) at each layer that adds
context; compare with errors.Is/As, never string matching; translate driver errors
(sql.ErrNoRows, unique violations) into domain errors in the repository layer.
Server, middleware, logging, shutdown
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
Handler: withRequestID(withLogging(logger, withRecover(logger, mux))),
ReadHeaderTimeout: 5 * time.Second, // Slowloris guard; never leave unset
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, // must exceed slowest handler
IdleTimeout: 120 * time.Second, // above the LB idle timeout
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("listen", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown", "err", err)
}
// then close DB pools, flush telemetry
}
http.ListenAndServe(addr, h) without a Server has no timeouts; never use it in
production. Middleware is func(http.Handler) http.Handler. The logging middleware wraps
ResponseWriter to capture status and bytes, and logs method, path pattern
(r.Pattern, Go 1.23+), status, duration, and request ID. Put the request ID in the
context and attach it to logs via a custom slog.Handler or logger.With("request_id", id).
Context and goroutines
- Every outbound call takes the request context:
http.NewRequestWithContext(ctx, ...),db.QueryContext(ctx, ...). Set per-call deadlines withcontext.WithTimeout. - Never store contexts in structs. Never pass
context.Background()inside a handler unless the work must outlive the request (then usecontext.WithoutCancel(ctx), Go 1.21+, and bound it with its own timeout). - Fan-out with
errgroup.WithContextandg.SetLimit(n); the first error cancels siblings. - Every goroutine has a defined exit: a closed channel, a cancelled context, or a
WaitGroupthe caller waits on (wg.Go(func(){...})in Go 1.25+).
Configuration and dependencies
Read env vars once in main into a config struct, validate, fail fast. Construct
dependencies explicitly in main and pass them into handler structs; avoid package-level
globals and init() side effects. Share one *http.Client with a Timeout (the default
client has none) and one *sql.DB/pgxpool.Pool per process.
Tests
func TestCreateOrderRejectsUnknownField(t *testing.T) {
h := newTestHandlers(t)
req := httptest.NewRequest(http.MethodPost, "/v1/orders", strings.NewReader(`{"sku":"A","bogus":1}`))
rec := httptest.NewRecorder()
h.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
Use table-driven tests for handlers, httptest.NewServer for clients, and a real
database via testcontainers or a disposable schema for repositories.
Gotchas
- Writing headers after
WriteHeaderor the body is silently ignored (“superfluous WriteHeader” in logs). - Not closing
resp.Bodyleaks connections; alwaysdefer resp.Body.Close()after the error check. - A nil
*MyErrorreturned aserroris non-nil; return a literalnil. - Loop variable capture is per-iteration since Go 1.22; old workarounds can go.
jsonencodesnilslices asnull; initialize to empty when clients expect[]. Go 1.24 adds theomitzerotag option for cleaner optional fields.- Go 1.25 sets GOMAXPROCS from the container CPU limit; on older versions use
go.uber.org/automaxprocs.
Verification
go vet ./... && staticcheck ./... && go test -race -count=1 ./... && govulncheck ./...
Then run the binary, send SIGTERM during a slow request, and confirm the request completes and the process exits 0. Check logs are JSON lines carrying request IDs.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.