HTTP and Networking
HTTP and Networking
net/http is the client and the server. The boring default is a ServeMux with method-and-path patterns (Go 1.22+), a handler that writes a status and a body, and tests against httptest.Server so the process exits. ListenAndServe is what you run in production. It is not what you run in a go run example unless you shut it down.
Mental model
A client sends a request (http.Get, http.NewRequest + Client.Do) and must close resp.Body. A server registers handlers on a mux. Go 1.22 patterns look like "GET /tickets/{id}". r.PathValue("id") is the wildcard.
httptest.NewServer(h) listens on a local port, gives you srv.URL, and srv.Close() shuts it down. That is the shape of every complete program in this chapter that you are expected to run.
http.ListenAndServe(addr, mux) blocks forever (or until a fatal error). Use it in main of a real service, with http.Server and Shutdown when you have a signal. Do not leave it as the only example you type.
Worked examples
Case 1: httptest server and GET pattern
Save as ticket_get.go. The server runs, the client fetches /tickets/7, both finish, the process exits.
// ticket_get.go
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tickets/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "ticket %s\n", id)
})
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/tickets/7")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("status", resp.StatusCode)
fmt.Print(string(body))
}Run:
go run ticket_get.goOutput:
status 200
ticket 7
srv.Client() talks to this server and ignores HTTP proxies. Prefer it over http.Get in listings. The {id} wildcard only matches a single path segment. GET in the pattern means a POST will not hit this handler.
Case 2: POST and a method miss
Save as ticket_post.go. Register POST /tickets. A GET to the same path is 405.
// ticket_post.go
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /tickets", func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Fprintf(w, "accepted %s\n", raw)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := srv.Client()
resp, err := c.Post(srv.URL+"/tickets", "text/plain", strings.NewReader("7"))
if err != nil {
fmt.Println(err)
return
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("POST", resp.StatusCode, strings.TrimSpace(string(body)))
bad, err := c.Get(srv.URL + "/tickets")
if err != nil {
fmt.Println(err)
return
}
io.Copy(io.Discard, bad.Body)
bad.Body.Close()
fmt.Println("GET", bad.StatusCode)
}Run:
go run ticket_post.goOutput:
POST 200 accepted 7
GET 405
405 means the path exists for another method. 404 means no pattern matched.
Case 3: Client with a timeout
Save as client_timeout.go. A Client is not the default. It has a timeout so a stuck server cannot hold main forever.
// client_timeout.go
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
fmt.Fprintln(w, "done")
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := srv.Client()
c.Timeout = 10 * time.Millisecond
_, err := c.Get(srv.URL + "/slow")
fmt.Println(err != nil)
}Run:
go run client_timeout.goOutput:
true
The error text includes a URL and context deadline exceeded (wording can include the client timeout). Checking err != nil is the stable part. Raise Timeout above 50ms and you get false.
Case 4: httptest.NewRecorder without a listener
Save as handler_only.go. When you only need to test a handler, skip the server.
// handler_only.go
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func ticket(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ticket %s\n", r.PathValue("id"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tickets/{id}", ticket)
req := httptest.NewRequest("GET", "/tickets/7", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Println(rec.Code)
fmt.Print(rec.Body.String())
}Run:
go run handler_only.goOutput:
200
ticket 7
No port, no goroutine. Use this in unit tests. Use NewServer when you want the real client stack.
The trap
ListenAndServe never returns on success. This program is complete and you should not leave it running as your only check.
// hang.go
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
fmt.Println("listening on :8080")
http.ListenAndServe("127.0.0.1:8080", mux)
}If you run it, it prints listening on :8080 and waits. Stop it with Ctrl-C. The production shape that still exits in a listing is http.Server plus Shutdown:
// shutdown.go
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Println(err)
return
}
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
resp, err := http.Get("http://" + ln.Addr().String() + "/health")
if err != nil {
fmt.Println(err)
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Print(string(body))
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Println(err)
}
}Run:
go run shutdown.goOutput:
ok
Prefer Case 1 (httptest) for everyday examples. Use Shutdown when you are showing a real http.Server.
The boring rule
- Patterns:
"GET /tickets/{id}", not a manual method check unless you have to. PathValuefor wildcards.- Close the response body. Use a
Clientwith aTimeout. httptest.ServerorNewRecorderin programs and tests that must exit.ListenAndServein productionmain, withShutdownon signal.- Do not ignore
ListenAndServe’s error (http.ErrServerClosedafterShutdownis expected).
Try this
- In
ticket_get.go, request/tickets/7/extra. Print the status (404). - Add
mux.HandleFunc("GET /tickets/{id}/note", ...)and fetch that path. - In
client_timeout.go, setTimeoutto200 * time.Millisecondand print the body whenerr == nil. - In
handler_only.go, send aPOSTwithNewRequest. Printrec.Code(405).