Go HTTP Services skill for Codex

Stdlib routing, real timeouts, clean shutdown. Codex builds Go web services on the standard library without reaching for a framework: method-prefixed ServeMux patterns with PathValue, one error-returning handler type, strict JSON decoding behind MaxBytesReader, and an http.Server with every timeout set. SIGTERM drains in-flight requests through Shutdown, and tests run the full mux through httptest.

  • Backend & APIs
  • $go-http-services
  • 218 lines
  • 2 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- go-http-services

Installs to ~/.agents/skills/go-http-services/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $go-http-services, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes production Go HTTP services on the standard library - Go 1.22+ ServeMux method and wildcard patterns, func(http.Handler) http.Handler middleware, JSON decoding with size limits, server timeouts, slog request logging, graceful shutdown with signal.NotifyContext, and httptest-based tests. Use when building or reviewing a net/http server, adding routes or middleware without a framework, replacing gorilla/mux or chi with the stdlib router, or fixing hung connections, leaked goroutines and unclean shutdowns.

Use it when

  • Writing or reviewing a Go HTTP server that uses net/http directly
  • Migrating from gorilla/mux or chi to the enhanced standard ServeMux
  • Requests hang, connections leak, or deploys cut off in-flight requests
  • Adding middleware such as logging, recovery or request IDs without a framework

Use something else when

  • The service is built on Gin: use go-gin-api
  • You need goroutine, channel or worker-pool design beyond HTTP handling: use go-concurrency

How to check the result

go vet and go test -race pass, a test hits a wrong-method route and gets 405, and sending SIGTERM to a locally running server lets an in-flight slow request finish before exit.

The mistake it prevents

Leaving the go directive below 1.22, so the mux falls back to legacy matching, method and wildcard patterns break, and PathValue returns empty strings.

~/.agents/skills/go-http-services/SKILL.md

--- name: go-http-services description: Writes production Go HTTP services on the standard library - Go 1.22+ ServeMux method and wildcard patterns, func(http.Handler) http.Handler… license: MIT ---

Done well means: every route is a method plus path pattern on one http.ServeMux, every server and client has explicit timeouts, request bodies are size-limited and strictly decoded, handlers return errors through one helper, and SIGTERM drains in-flight requests before the process exits.

Before changing anything

  1. Read AGENTS.md and go.mod. The go directive must be 1.22 or later for the enhanced patterns; with an older directive (or GODEBUG=httpmuxgo121=1) the mux uses legacy matching and method or wildcard patterns do not work.
  2. Find the router (rg -n "NewServeMux|chi.NewRouter|mux.NewRouter|gin.New" --type go). If the project uses Gin, stop and use $go-gin-api instead.
  3. Run go build ./... && go test ./... and go vet ./... for a baseline.

Routing with the standard mux

mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.Handle("GET /v1/orders/{id}", h.getOrder())
mux.Handle("POST /v1/orders", h.createOrder())
mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", fs))
mux.HandleFunc("GET /{$}", home) // exactly "/", not a catch-all
id := r.PathValue("id") // "" if the pattern has no {id}
RuleConsequence
Method in patternWrong method returns 405 with an Allow header automatically
GET patternAlso matches HEAD
Most specific pattern wins/v1/orders/latest beats /v1/orders/{id} regardless of order
Two patterns equally specific and overlappingHandle panics at registration: good, fix it then
Trailing slash "/files/"Matches the whole subtree; use {$} to anchor
Host patterns "api.example.com/"Take precedence over host-less ones

Stay on the stdlib mux unless the project needs something it cannot express (regex constraints on segments, per-route middleware trees across dozens of groups). For grouping, mount a sub-mux: mux.Handle("/admin/", http.StripPrefix("/admin", adminMux)).

Handlers that return errors

Keep handlers small and push error-to-status mapping into one place:

type apiFunc func(w http.ResponseWriter, r *http.Request) error

type httpError struct {
	Status int
	Msg    string
}

func (e *httpError) Error() string { return e.Msg }

func (f apiFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := f(w, r); err != nil {
		var he *httpError
		if errors.As(err, &he) {
			writeJSON(w, he.Status, map[string]string{"error": he.Msg})
			return
		}
		slog.ErrorContext(r.Context(), "handler failed", "err", err, "path", r.URL.Path)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
	}
}

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 decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		var mbe *http.MaxBytesError
		if errors.As(err, &mbe) {
			return &httpError{http.StatusRequestEntityTooLarge, "body too large"}
		}
		return &httpError{http.StatusBadRequest, "invalid JSON: " + err.Error()}
	}
	if dec.More() {
		return &httpError{http.StatusBadRequest, "body must contain one JSON object"}
	}
	return nil
}

Dependencies go on a struct (type handlers struct { store Store; log *slog.Logger }), never package globals, so tests can substitute fakes.

Middleware

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 // first middleware listed runs first
}

func recoverer(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if v := recover(); v != nil && v != http.ErrAbortHandler {
				slog.Error("panic", "value", v, "stack", string(debug.Stack()))
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Order: request ID, then logging, then recover, then auth, then the mux. To log status codes, wrap the ResponseWriter in a struct that records WriteHeader, and expose Unwrap() http.ResponseWriter so http.NewResponseController can still reach Flush and deadlines. Go 1.25 adds http.CrossOriginProtection for CSRF checks on cookie-authenticated browser endpoints; use it when the go directive allows.

Server and shutdown

func run(ctx context.Context) error {
	ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	defer stop()

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           chain(mux, requestID, logRequests, recoverer),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       120 * time.Second,
		BaseContext:       func(net.Listener) context.Context { return ctx },
	}

	errCh := make(chan error, 1)
	go func() { errCh <- srv.ListenAndServe() }()

	select {
	case err := <-errCh:
		return err
	case <-ctx.Done():
	}
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx) // stops accepting, waits for in-flight requests
}
  • ListenAndServe returns http.ErrServerClosed after Shutdown; treat it as success.
  • Shutdown does not wait for hijacked connections (WebSockets); track and close those yourself with srv.RegisterOnShutdown.
  • Keep the shutdown timeout below the orchestrator’s grace period (Kubernetes default 30 s), and fail readiness first so the load balancer stops sending traffic.
  • Streaming or SSE handlers conflict with a global WriteTimeout; extend the deadline per request with http.NewResponseController(w).SetWriteDeadline(time.Time{}).

Outbound HTTP

Never use http.DefaultClient or http.Get in services: they have no timeout. Create one &http.Client{Timeout: 10 * time.Second} per dependency, pass r.Context() through with http.NewRequestWithContext, always defer resp.Body.Close(), and drain the body before closing if you want connection reuse.

Testing

func TestCreateOrder(t *testing.T) {
	h := &handlers{store: newFakeStore()}
	srv := httptest.NewServer(h.routes())
	t.Cleanup(srv.Close)

	resp, err := srv.Client().Post(srv.URL+"/v1/orders", "application/json",
		strings.NewReader(`{"sku":"A1","qty":2}`))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("status = %d", resp.StatusCode)
	}
}

Test the full mux, not single handler functions, so pattern matching and PathValue are exercised. For unit tests without a listener, httptest.NewRecorder() plus mux.ServeHTTP(rec, req) is enough. Add cases for 405, oversized body, unknown field.

Checklist

  • go directive at 1.22+; routes registered with method prefixes
  • Body size limit and DisallowUnknownFields on every JSON endpoint
  • All five server timeouts considered; no http.DefaultClient
  • Graceful shutdown wired to SIGTERM and tested by sending the signal locally
  • go vet ./..., go test -race ./... pass

Gotchas

  • r.PathValue on a route registered with a legacy pattern (or a go 1.21 module) is always empty.
  • Writing headers after WriteHeader or the first Write is silently ignored.
  • context.Background() inside a handler detaches work from client cancellation; use r.Context(), and a detached context only for work that must outlive the request.
  • A goroutine started in a handler that writes to w after the handler returns is a race.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Go HTTP Services"
  short_description: "Stdlib routing, real timeouts, clean shutdown"
  default_prompt: "$go-http-services Build a small JSON API in Go using only net/http, no frameworks"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem