agentleFS
h0rn3th0rn3t/golang-skills/skills/go-http/SKILL.md

go-http

Skill7 starsChanged 9 days agoApache-2.0View on GitHub

Use when writing or reviewing Go HTTP code — handlers, routing with net/http ServeMux, middleware, request decoding and response encoding, server timeouts and graceful shutdown, or HTTP clients. Also use when building a REST or JSON API endpoint or calling an external HTTP service, even if the user names a framework instead of net/http. Does not cover the test server helpers (see go-testing) or request-scoped logging (see go-logging).

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 0 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Only when the contract makes HEAD a 405, one package function builds the answer, registered twice per GET path — as HEAD and without a …

    line 50
  2. Very specific:

    Never http.Get, http.Post, or http.DefaultClient — no timeout.

    line 204
  3. Very specific:

    Check resp.StatusCode before decoding.

    line 240

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Go HTTP Servers and Clients

Compatibility: Baseline Go 1.27 (see COMPATIBILITY.md). ServeMux method and wildcard patterns require Go 1.22+; http.NewCrossOriginProtection Go 1.25+; http.Server.MaxHeaderValueCount and encoding/json/v2 Go 1.27+.

Resource Routing

  • references/WEB-SERVER.md - Read when assembling a complete server: routing, handler, and graceful shutdown in one main.
  • references/JSON-V2.md - Read when choosing JSON v2 I/O APIs, decoding a bounded document, migrating wire formats, or testing exact JSON bytes (Go 1.27+).
  • references/GRPC.md - Read when writing a gRPC server or client: status codes, generated-server embedding, grpc.NewClient, bounded GracefulStop, and bufconn tests.

Routing (Go 1.22+)

A package already on a framework keeps it, and the rules below still apply; go-packages owns adding a router module.

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", s.handleGetUser) // serves GET and HEAD
mux.HandleFunc("GET /users", s.handleListUsers)
mux.HandleFunc("POST /users", s.handleCreateUser)
mux.HandleFunc("GET /{$}", s.handleIndex) // exact "/", not a subtree

id := r.PathValue("id")
  • A GET pattern also serves HEAD. A method pattern answers every other method on its path with 405 and an Allow header that lists HEAD; HEAD is the one it lets through, with the GET handler's status. Under a contract that makes every method but GET a 405, every GET path gets a HEAD pattern and a pattern without a method, both answered with the methods that path serves — the health check and the index as much as the resource routes, so the file registers as many HEAD patterns as GET patterns — or a GET-only path registers its handler without a method and opens it with an r.Method != http.MethodGet check that answers 405 with Allow: GET; the test file sends HEAD and POST to each GET path and reads Allow. A pattern without a method matches every method.
  • Conflicting patterns panic at registration; overlapping patterns are valid when one is more specific, which is why HEAD /users/{id} registers beside GET /users/{id}.
  • Trailing / is a subtree; {$} pins the exact path.

Only when the contract makes HEAD a 405, one package function builds the answer, registered twice per GET path — as HEAD and without a method, so POST and DELETE get the same Allow — the index included:

// methodNotAllowed answers every method its path does not serve, HEAD among
// them; allow lists the methods the path does serve.
func methodNotAllowed(allow string) http.HandlerFunc {
    return func(w http.ResponseWriter, _ *http.Request) {
        w.Header().Set("Allow", allow)
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    }
}

mux.HandleFunc("HEAD /users/{id}", methodNotAllowed("GET"))
mux.HandleFunc("/users/{id}", methodNotAllowed("GET"))
mux.HandleFunc("HEAD /users", methodNotAllowed("GET, POST"))
mux.HandleFunc("/users", methodNotAllowed("GET, POST"))
mux.HandleFunc("HEAD /{$}", methodNotAllowed("GET"))
mux.HandleFunc("/{$}", methodNotAllowed("GET"))

Handler Shape

Preserve the existing handler structure. No package-level state.

Bound and decode → validate → call the domain with r.Context() → map the error → write once. Server below stands for the type the repository already has; in new code without one, the handler is a function literal in the constructor that builds the mux, capturing the store (go-code). With import json "encoding/json/v2" (Go 1.27):

func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
    var req struct {
        Name string `json:"name"`
    }
    // One decode: an unknown member, a second value, junk, or an oversized body fails.
    if err := json.UnmarshalRead(r.Body, &req, json.RejectUnknownMembers(true)); err != nil {
        http.Error(w, "invalid JSON body", http.StatusBadRequest)
        return
    }
    if req.Name == "" {
        http.Error(w, "name is required", http.StatusBadRequest)
        return
    }
    user, err := s.store.Create(r.Context(), req.Name)
    switch {
    case errors.Is(err, ErrConflict):
        http.Error(w, "name already taken", http.StatusConflict)
        return
    case err != nil && errors.Is(r.Context().Err(), context.Canceled):
        slog.DebugContext(r.Context(), "create user: client gone", "err", err)
        return // nobody is left to read a status
    case err != nil:
        slog.ErrorContext(r.Context(), "create user", "err", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    body, err := json.Marshal(struct { // the response names its fields; a stored one added later stays out
        ID   string `json:"id"`
        Name string `json:"name"`
    }{user.ID, user.Name})
    if err != nil { // before any header, so an encode error is still a 500
        slog.ErrorContext(r.Context(), "encode user", "err", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    _, _ = w.Write(body) // headers are sent; a failed write is the client's disconnect
}
  • Bound every decoded body with http.MaxBytesReader. {} and null still decode, leaving req zero, so required fields are validated before the store call. The request type is declared in the handler that reads it; a second handler decoding the same document is what moves it to package level (go-code).
  • A package already decoding with encoding/json v1 keeps v1 and its two decodes: dec.DisallowUnknownFields(), dec.Decode(&req), then dec.Decode(new(any)) must return io.EOF — DisallowUnknownFields alone accepts trailing data, and the second decode allows trailing whitespace while the cap still holds.
  • v2 encodes a nil slice as [] and a nil map as {}, so a filtered list with no matches is written as it is, with no make([]T, 0, n); that line belongs to a package on v1, or under FormatNilSliceAsNull(true) (JSON-V2.md).
  • A response body is marshalled with json.Marshal before the first header, as above. v2 fails at encode time on a time.Duration field (no default representation), invalid UTF-8 in stored text, or a MarshalJSON error; json.MarshalWrite(w, v) after WriteHeader sends the status with a truncated body and nowhere to report the error.
  • A write whose error has nowhere to go is discarded in the open with its reason on the line, as the example's last line is, never bare. errcheck reads a bare w.Write, io.WriteString(w, …), json.MarshalWrite(w, v), or json.NewEncoder(w).Encode(v) as a finding, and //nolint:errcheck is not a way out: gosec G104 reports the same line.

Mapping errors to status codes

Centralize shared error-to-status rules in writeError, using errors.Is/errors.AsType; keep a single-handler mapping local. Apply each row where that failure can occur:

ErrorStatusBody
Validation, malformed input400The validation message
ErrNotFound sentinel404Generic
ErrConflict, version mismatch409Generic
Incoming r.Context().Err() is context.Canceled499-style: log at Debug, write nothing—
Downstream context.Canceled while the incoming request is alive500 unless the endpoint specifies otherwiseGeneric
context.DeadlineExceeded from downstream504Generic
Anything else500Generic — never err.Error()

The request ID reaches the 500 record only through the request-scoped logger go-logging sets up; where the repository has one, the example's slog calls go through it. This is the handle-once exception owned by go-error-handling.

Middleware

func(next http.Handler) http.Handler. Outside in: recover → request ID + logging → auth → mux. Wrap http.ResponseWriter only to capture status and keep it transparent; use http.NewResponseController(w) for deadlines and flushing instead of asserting optional interfaces.

Server Construction

Construct an http.Server; bare http.ListenAndServe sets no timeouts.

FieldWhy
ReadHeaderTimeoutSlowloris defense; must never be zero
ReadTimeout, WriteTimeoutBound slow clients; WriteTimeout exceeds the slowest handler
IdleTimeoutReclaim keep-alive connections
MaxHeaderBytes, MaxHeaderValueCount (Go 1.27+)Only to change the defaults, 1 MiB and 500 values: MaxHeaderBytes: 1 << 20 restates zero
Handler: http.NewCrossOriginProtection().Handler(mux)CSRF for state-changing requests (Go 1.25+). One per server: with CORS it is the instance go-security's withCORS builds with the trusted origins; a second, empty one around it answers every cross-origin POST with 403
BaseContextNever the signal.NotifyContext context when in-flight requests should drain: it cancels every request at the signal, before Shutdown waits for them. Handlers that must see shutdown get a separate context, cancelled once Shutdown's timeout expires

One slow route does not raise WriteTimeout for all: wrap it in http.TimeoutHandler(h, d, msg), which cancels r.Context() at d and answers 503. It buffers the response and has no Flusher, so a streaming route sets its own deadline with http.NewResponseController(w) instead.

For graceful shutdown, signal.NotifyContext owns the lifetime; ListenAndServe runs in one goroutine feeding a buffered error channel; srv.Shutdown(ctx) gets its own timeout.

Clients

  • Never http.Get, http.Post, or http.DefaultClient — no timeout. One tuned *http.Transport and one *http.Client{Timeout: d, Transport: t} per dependency, built once and reused. The Transport owns the connection pool: a Client with a nil Transport shares http.DefaultTransport, which keeps two idle connections per host (http.DefaultMaxIdleConnsPerHost). Tune a clone of it; a &http.Transport{...} literal drops ProxyFromEnvironment and the dial, TLS-handshake, and idle-connection timeouts (the SSRF dialer in go-security drops the proxy on purpose), and with DialContext or TLSClientConfig set it speaks HTTP/1.1 only unless ForceAttemptHTTP2 is true — an HTTP2 config on it is ignored. A client that keeps HTTP/2 connections open — watch streams, many multiplexed requests to one host — sets SendPingTimeout (Go 1.24+): at zero no health check runs, and every stream on a connection a NAT dropped silently hangs until its own deadline:

    t := http.DefaultTransport.(*http.Transport).Clone()
    t.MaxIdleConnsPerHost = 32
    t.HTTP2 = &http.HTTP2Config{SendPingTimeout: 30 * time.Second}
    client := &http.Client{Timeout: 10 * time.Second, Transport: t}
  • http.NewRequestWithContext(ctx, ...) — the ctx-less form is unbounded; noctx in the lint gate flags it.
  • The Transport reads the request body on its own goroutine, and that read can outlive Do: a 4xx/5xx answered from the headers, a timeout, or a cancel returns mid-upload, and on HTTP/2 the Transport's own Close of the body can overlap a Read. Each request, retry included, gets its own bytes.NewReader over bytes nobody writes again — never the previous attempt's reader rewound, never a shared or pooled buffer refilled; -race reports that write against the Transport's body writer.
  • defer resp.Body.Close() right after the err != nil return, on every status (bodyclose flags a miss); on error Do returns no body to close. Bound untrusted bodies and close them on every path. Go 1.27 drains an unread HTTP/1 body on Close — up to 256 KiB and 50 ms — to keep the connection reusable, so closing is the whole obligation; a manual read to EOF only buys reuse for responses past those bounds.
  • Check resp.StatusCode before decoding; a 5xx body is not your struct.
  • For retry eligibility, Retry-After, replay safety, and coordinated budgets, use go-resilience. HTTP status alone does not establish whether repeating an operation is safe.

Bounded Response Bodies

For a strict response-size limit, read at most the limit plus one byte, reject an oversized result, then decode the complete buffer into dst. A decoder can finish its first object before io.LimitReader reaches its limit; that alone does not enforce the size of the whole response.

const maxBody = 64 << 10
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
    return fmt.Errorf("read response: %w", err)
}
if len(body) > maxBody {
    return fmt.Errorf("response exceeds %d bytes", maxBody)
}
return json.Unmarshal(body, dst)

Partial Updates

A PATCH body names only the members it changes, and a Go struct cannot tell a missing member from a zero one: decoding into a fresh struct and saving it zeroes every field the client left out. Load the record, copy its client-writable fields into the request value, and unmarshal the body onto that value. v2 leaves an absent member as loaded and sets one sent as null to its zero value (v1 leaves it unchanged). RejectUnknownMembers(true) refuses password_hash and any member the request type does not hold, and a top-level null leaves the pointer nil. v2 decodes into what the request value already holds — it writes a non-nil slice's array, merges into a non-nil map, decodes through a non-nil pointer — so a slice, map, or pointer field is copied with slices.Clone, maps.Clone, or a new value; copied as is, the record changes before the body is rejected:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
req := &struct {
    Email string   `json:"email"`
    Phone string   `json:"phone"` // "phone": null clears it
    Tags  []string `json:"tags"`
}{acct.Email, acct.Phone, slices.Clone(acct.Tags)} // never acct.Tags: v2 writes into its array
if err := json.UnmarshalRead(r.Body, &req, json.RejectUnknownMembers(true)); err != nil || req == nil {
    http.Error(w, "invalid JSON body", http.StatusBadRequest)
    return
}
if req.Email == "" { // "email": null arrives as ""
    http.Error(w, "email is required", http.StatusBadRequest)
    return
}
acct.Email, acct.Phone, acct.Tags = req.Email, req.Phone, req.Tags // a rejected body never touched acct

Related Skills

  • go-context: derived deadlines and request lifetime.
  • go-database: handler queries and transactions.
  • go-testing: httptest and synctest.
  • go-concurrency: server goroutine and shared state.
  • go-security: input naming files, URLs or commands; cookies, SSRF, and error disclosure.
  • go-data-structures: nil versus empty slices and maps before they reach the encoder.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.