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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- go-http-services Installs to ~/.agents/skills/go-http-services/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install go-http-services The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project go-http-services Installs to .agents/skills/go-http-services/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/go-http-services.zipunzip -o go-http-services.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) go-http-services PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.mdandgo.mod. Thegodirective must be1.22or later for the enhanced patterns; with an older directive (orGODEBUG=httpmuxgo121=1) the mux uses legacy matching and method or wildcard patterns do not work. - Find the router (
rg -n "NewServeMux|chi.NewRouter|mux.NewRouter|gin.New" --type go). If the project uses Gin, stop and use$go-gin-apiinstead. - Run
go build ./... && go test ./...andgo 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}
| Rule | Consequence |
|---|---|
| Method in pattern | Wrong method returns 405 with an Allow header automatically |
GET pattern | Also matches HEAD |
| Most specific pattern wins | /v1/orders/latest beats /v1/orders/{id} regardless of order |
| Two patterns equally specific and overlapping | Handle 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
}
ListenAndServereturnshttp.ErrServerClosedafterShutdown; 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 withhttp.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
-
godirective at 1.22+; routes registered with method prefixes - Body size limit and
DisallowUnknownFieldson 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.PathValueon a route registered with a legacy pattern (or ago 1.21module) is always empty.- Writing headers after
WriteHeaderor the firstWriteis silently ignored. context.Background()inside a handler detaches work from client cancellation; user.Context(), and a detached context only for work that must outlive the request.- A goroutine started in a handler that writes to
wafter the handler returns is a race.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
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.