Module 4 · Lesson 2 · ~25 min read

net/http Deep Dive

Go's standard library HTTP server is production-grade. You don't need a framework to ship a service. Here's enough net/http to write the kinds of HTTP-side glue you'll bolt onto Canton — health endpoints, REST wrappers, internal admin APIs.

A server in five lines

http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("ok"))
})
log.Fatal(http.ListenAndServe(":8080", nil))

Two pieces: a handler function with the signature (w http.ResponseWriter, r *http.Request), and a server bound to a port. The default mux (passing nil to ListenAndServe) is global; in production you make your own.

Handlers — interface-first

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

http.Handler is the central interface. http.HandlerFunc is an adapter that turns a plain function into a Handler. This duality is why you can write handlers as functions but also as methods on structs (when they need state):

type cantonHandler struct {
    client *ledger.Client
}

func (h *cantonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    cmd, err := parseCommand(r.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    if err := h.client.Submit(r.Context(), cmd); err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    w.WriteHeader(http.StatusAccepted)
}

State that the handler needs (the Canton client, a logger, a metric collector) lives on the struct. The function-style handlers are fine for stateless endpoints (/healthz); structs are right when there's anything to inject.

Request & response basics

// Path parameters: don't exist in net/http itself.
// You parse them yourself, or use a router (see below).

// Query parameters:
val := r.URL.Query().Get("key")

// JSON body:
var body MyShape
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
    http.Error(w, "bad json", http.StatusBadRequest)
    return
}

// Headers:
auth := r.Header.Get("Authorization")

// Response:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(reply)

Note the order: set headers first, then call WriteHeader, then write body. WriteHeader cannot be called twice; the first Write that happens auto-calls WriteHeader(200) if you forgot.

Routing

Go 1.22+ added pattern-matching routes to the standard library:

mux := http.NewServeMux()
mux.HandleFunc("GET /parties/{id}", partyHandler)
mux.HandleFunc("POST /commands", submitHandler)

// Inside a handler:
id := r.PathValue("id")

For most internal services this is enough. For richer routing (regex paths, method-specific middleware chains, sub-routers), gorilla/mux, chi, or echo are popular choices, all of which build on net/http rather than replacing it.

Middleware — wrap a handler

Middleware in Go is just a function that takes a Handler and returns a Handler:

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func authn(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("X-Token") == "" {
            http.Error(w, "missing token", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Compose:
handler := logging(authn(mux))
http.ListenAndServe(":8080", handler)

This is the entire middleware system. Go didn't need a "middleware framework" — it has function composition. Many Go HTTP frameworks just give you syntactic sugar for nesting these.

Production-grade server setup

The default http.ListenAndServe uses a server with no timeouts. For anything real:

srv := &http.Server{
    Addr:           ":8080",
    Handler:        handler,
    ReadTimeout:    15 * time.Second,
    WriteTimeout:   30 * time.Second,
    IdleTimeout:    120 * time.Second,
    MaxHeaderBytes: 1 << 14,  // 16 KiB
}
go srv.ListenAndServe()

// Graceful shutdown:
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)  // stops accepting new conns; waits for in-flight to finish

Without timeouts, a single slow client can hold a connection forever (slowloris attack). Without graceful shutdown, in-flight requests get killed mid-flight when your container restarts. Both bite in production.

The default client is fine, mostly

resp, err := http.Get("https://api.example.com/foo")
if err != nil { return err }
defer resp.Body.Close()
// ...

For one-off scripts, fine. For services, build a client with explicit timeouts:

client := &http.Client{
    Timeout: 30 * time.Second,
}
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)

Pass the context. Set a Timeout on the client. Always close the response body. The default client has no timeout, which means a stuck connection leaks goroutines and file descriptors forever.

Streaming responses

For long-running responses (server-sent events, streaming JSON, log tailing), flush as you go:

func streamHandler(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }
    for i := 0; i < 10; i++ {
        fmt.Fprintf(w, "chunk %d\n", i)
        flusher.Flush()
        time.Sleep(200 * time.Millisecond)
    }
}

Type-assert the writer to http.Flusher; flush after each write to push bytes downstream immediately. This is also how a Canton transaction-stream proxy might forward updates as they arrive.

Takeaways