Module 4 · Lesson 2 · ~25 min read
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.
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.
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.
// 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.
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 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.
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.
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.
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.
http.Handler (ServeHTTP) and http.HandlerFunc (function adapter).srv.Shutdown(ctx).http.Flusher.