Irbisa · Cheatsheet

HTTP Services & REST APIs

net/http, ServeMux patterns, middleware, timeouts, graceful shutdown

Middle Developer · 23 questions

  1. 01

    How do you write a minimal HTTP server with net/http, and what do http.Handler, http.HandlerFunc and http.ServeMux each do?

    http.Handler is any type with a ServeHTTP(w, r) method; http.HandlerFunc turns a plain function into one; http.ServeMux is a handler that routes each request to another handler by its pattern.

    type itemsHandler struct {	store *Store} // itemsHandler is an http.Handler because it has ServeHTTP.func (h itemsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {…
  2. 02

    How do you read a path parameter, a query parameter, a header and a JSON body in a handler, and send back a JSON response?

    Path values come from r.PathValue("id"), query parameters from r.URL.Query().Get("q"), headers from r.Header.Get(...), and the body is a stream you decode with json.NewDecoder(r.Body).

    type renameReq struct {	Name string `json:"name"`} // Registered as mux.HandleFunc("PUT /items/{id}", renameItem).func renameItem(w http.ResponseWriter, r *http.Request) {…
  3. 03

    This handler should answer 201 Created with a JSON body. What status and Content-Type does the client actually get, and why?

    func createItem(w http.ResponseWriter, r *http.Request) {	item := Item{ID: 42, Name: "lamp"} 	json.NewEncoder(w).Encode(item)	w.WriteHeader(http.StatusCreated)	w.Header().Set("Content-Type", "application/json")}

    The client gets 200 OK with Content-Type: text/plain; charset=utf-8.

  4. 04

    Which HTTP methods are safe and idempotent, how does PUT differ from PATCH, and which status codes do you return for common outcomes?

    Safe methods (GET, HEAD, OPTIONS) do not change server state; idempotent methods have the same effect whether sent once or ten times: the safe ones plus PUT and DELETE.

  5. 05

    What did Go 1.22 add to http.ServeMux routing, and how does the mux pick a handler when two patterns match?

    Patterns can now include a method and wildcards, as in GET /items/{id}, and the handler reads the value with r.PathValue("id").

    func routes(s *Server) *http.ServeMux {	mux := http.NewServeMux()	mux.HandleFunc("GET /items", s.listItems)	mux.HandleFunc("POST /items", s.createItem)	mux.HandleFunc("GET /items/new", s.newItemForm) // beats {id}: more specific	mux.HandleFunc("GET /items/{id}", s.getItem)…
  6. 06

    How does net/http serve many requests at once, and what does that mean for state that handlers share?

    The server runs each connection in its own goroutine, and with HTTP/2 each request stream gets its own goroutine too, so one handler runs concurrently for many requests.

  7. 07

    What is middleware in Go, and how would you write one that logs method, path, status and duration and passes a request ID down the chain?

    Middleware is a function func(http.Handler) http.Handler that wraps the next handler and runs code before and after it.

    type ctxKey struct{} func RequestID(next http.Handler) http.Handler {	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		id := r.Header.Get("X-Request-Id") // set by our own proxy		if id == "" {…
  8. 08

    In what order does a middleware chain like a(b(c(mux))) run, and where do request ID, logging, recovery, CORS and auth belong in it?

    The outermost wrapper runs first on the way in and last on the way out: in a(b(c(mux))), a sees the request before b and c.

    // Chain(a, b, c)(h) == a(b(c(h))): a runs first on the way in.func Chain(mws ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {	return func(h http.Handler) http.Handler {		for i := len(mws) - 1; i >= 0; i-- {			h = mws[i](h)		}…
  9. 09

    Find the bug: after a panic in an export handler, some clients get a 200 with a cut-off JSON body ending in internal error.

    func recoverPanics(next http.Handler) http.Handler {	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		defer func() {			if v := recover(); v != nil {				slog.Error("panic", "err", v)				http.Error(w, "internal error", http.StatusInternalServerError)			}		}()		next.ServeHTTP(w, r)	})}func export(w http.ResponseWriter, r *http.Request) {	fmt.Fprint(w, `{"rows":[1,2,`)	var m map[string]int	m["x"] = 1 // panics mid-response}

    The middleware sends http.Error even though the handler already wrote part of the body, so the 500 cannot replace the committed 200: the server logs superfluous response.WriteHeader call and internal error is appended to the half-written JSON.

  10. 10

    How do you decode a JSON request body safely, and what is wrong with body, _ := io.ReadAll(r.Body) followed by json.Unmarshal?

    io.ReadAll buffers whatever the client sends, so one huge body can exhaust memory, and the ignored error hides a broken upload.

    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 {…
  11. 11

    How should a JSON API report errors and field-level validation failures, and why is http.Error(w, err.Error(), 500) the wrong tool for it?

    Return a stable JSON error body (RFC 9457 problem details, application/problem+json, is the standard shape) with a status that says whose fault it is.

    // RFC 9457 problem details.type Problem struct {	Title  string            `json:"title"`	Status int               `json:"status"`	Detail string            `json:"detail,omitempty"`	Errors map[string]string `json:"errors,omitempty"` // extension: field -> reason…
  12. 12

    How would you structure a Go REST service into handler, service and repository layers, and how do the handlers get their dependencies?

    Handlers deal only with HTTP, a service layer holds the business rules, and a repository hides the database.

    // Declared where it is used; *postgres.UserRepo satisfies it implicitly.type UserRepo interface {	ByID(ctx context.Context, id int64) (User, error)	Create(ctx context.Context, u User) (int64, error)}…
  13. 13

    How would you design a REST API for a resource such as blog posts: URLs, methods, pagination and versioning?

    Model nouns as resources (/posts, /posts/{id}, /posts/{id}/comments), let the HTTP method carry the action, return honest status codes, paginate every list, and version the API so existing clients never break.

  14. 14

    For a new service, would you use the standard net/http mux, chi, gin, echo or fiber, and what does each choice cost?

    Since Go 1.22 the standard mux handles methods and path parameters, so plain net/http, or chi when you also want route groups and per-route middleware, covers most services.

  15. 15

    How would you implement rate limiting as HTTP middleware, and what should the client get back when it hits the limit?

    Keep a token bucket per client (golang.org/x/time/rate provides one) and check it in middleware before the handler runs.

    type RateLimiter struct {	mu       sync.Mutex	limiters map[string]*rate.Limiter} func NewRateLimiter() *RateLimiter {…
  16. 16

    Which timeouts should a production http.Server set, and why is http.ListenAndServe(":8080", mux) unsafe on a public port?

    http.ListenAndServe builds a server with every timeout at zero, so a client that sends its headers one byte a minute (Slowloris) holds a connection and a goroutine forever.

    func newServer(h http.Handler, logger *slog.Logger) *http.Server {	return &http.Server{		Addr:              ":8080",		Handler:           http.TimeoutHandler(h, 10*time.Second, "request timed out"),		ReadHeaderTimeout: 5 * time.Second,  // the Slowloris fix		ReadTimeout:       30 * time.Second, // headers + body…
  17. 17

    How do you shut down a Go HTTP server gracefully on SIGTERM without dropping in-flight requests?

    Catch the signal with signal.NotifyContext, then call srv.Shutdown(ctx) with a fresh timeout context: it stops accepting, closes idle connections and waits for active requests to finish.

    func run(handler http.Handler, db *sql.DB) error {	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)	defer stop() 	srv := &http.Server{Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5 * time.Second}…
  18. 18

    This handler starts a slow export in the background and answers 202 Accepted. What is wrong with it?

    func (s *Server) startExport(w http.ResponseWriter, r *http.Request) {	go func() {		data, err := s.buildExport(r.Context())		if err != nil {			log.Println(err)			return		}		w.Write(data)	}()	w.WriteHeader(http.StatusAccepted)}

    The goroutine outlives the request: r.Context() is cancelled as soon as the handler returns, so the export usually stops with context canceled, and writing to w after that point is invalid.

  19. 19

    How should a service create its http.Client, and how do you give each outbound call its own deadline?

    Build one *http.Client at startup with a tuned Transport and reuse it everywhere, because the connection pool lives in the Transport.

    // Built once, at startup, and shared.func newUpstreamClient() *http.Client {	tr := http.DefaultTransport.(*http.Transport).Clone()	tr.MaxIdleConnsPerHost = 100	return &http.Client{Transport: tr, Timeout: 5 * time.Second} // backstop}…
  20. 20

    How do you stream Server-Sent Events from a Go handler, and why might the events arrive all at once or the stream die at WriteTimeout?

    Write events in the text/event-stream format and flush each one with http.NewResponseController(w).Flush().

    func (s *Server) events(w http.ResponseWriter, r *http.Request) {	rc := http.NewResponseController(w)	w.Header().Set("Content-Type", "text/event-stream")	w.Header().Set("Cache-Control", "no-cache")	w.Header().Set("X-Accel-Buffering", "no") // nginx: do not buffer this response	w.WriteHeader(http.StatusOK)…
  21. 21

    This client reuses one http.Client, yet when the upstream answers 404 to many lookups, almost every call opens a new TCP and TLS connection. Why?

    func fetchUser(ctx context.Context, id string) (*User, error) {	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/users/"+id, nil)	resp, err := client.Do(req)	if err != nil {		return nil, err	}	defer resp.Body.Close()	if resp.StatusCode != http.StatusOK {		return nil, fmt.Errorf("upstream: %s", resp.Status)	}	var u User	return &u, json.NewDecoder(resp.Body).Decode(&u)}

    On the error path the function closes the body without reading it.

  22. 22

    How do you tune http.Transport for thousands of requests per second to one upstream, and how does connection reuse differ in HTTP/1.1 and HTTP/2?

    Raise MaxIdleConnsPerHost: it defaults to 2, so under concurrency almost every connection is closed after use instead of being pooled, and each request pays a new TCP and TLS handshake.

    // wrong: a bare Transport - HTTP/1.1 only, no proxy, an idle pool of 2 per host// client := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}} func newHotClient(cfg *tls.Config) *http.Client {	tr := http.DefaultTransport.(*http.Transport).Clone()	tr.TLSClientConfig = cfg…
  23. 23

    How do you make POST /charges safe to retry, and which requests does Go's http.Transport retry on its own?

    Send one Idempotency-Key per operation, reused by every retry, and have the server store the result under that key, so a repeat returns the saved response instead of charging again.

    func charge(ctx context.Context, c *http.Client, body []byte) (*http.Response, error) {	key := rand.Text() // one key for the operation, reused by every attempt	var lastErr error	for attempt := range 4 {		// A Request is single-use; bytes.NewReader sets GetBody so it can be replayed.		req, err := http.NewRequestWithContext(ctx, http.MethodPost, chargesURL, bytes.NewReader(body))…