Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Go  Module 18 – HTTP servers and clients with net/http

Production servers: timeouts, shutdown, TLS, CSRF

Run a Go 1.27 HTTP server in production: timeouts and header limits, a SIGTERM shutdown that drops no request, and TLS and CSRF checks for browsers.

  • Advanced
  • 25 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 8 of 25 minutes free
Free: A zero http.Server waits for ever · Graceful shutdown finishes the requests in flight · TLS and cross-origin checks protect browser users. With a pass: 3 in-depth parts and 3 interview answers, about 17 minutes.

What you will learn

  • Configure http.Server timeouts and header limits, and predict which one ends a slow connection
  • Implement a graceful shutdown on SIGTERM with signal.NotifyContext and Server.Shutdown
  • Configure TLS and HTTP/2, and protect browser forms with http.CrossOriginProtection
  • Set session cookies and read client addresses behind a proxy so that neither can be forged

Before you start

What you’ll learn · Before you start

What you will learn

  • Configure http.Server timeouts and header limits, and predict which one ends a slow connection
  • Implement a graceful shutdown on SIGTERM with signal.NotifyContext and Server.Shutdown
  • Configure TLS and HTTP/2, and protect browser forms with http.CrossOriginProtection
  • Set session cookies and read client addresses behind a proxy so that neither can be forged

Before you start

On this page

A Go server started with http.ListenAndServe(":8080", mux) has no timeouts at all: in timeouts.go below, a client that sent its headers one byte every 50 ms still held its connection after a second, and nothing in the server would end it in an hour either. Stopped by a deploy, the same server drops every request in flight.

You will set a server’s timeouts and limits, stop it on SIGTERM without dropping a request, and turn on what browsers need, TLS and cross-origin checks. Every program here ran on Go 1.27.1, over in-memory pipes instead of a network.

A zero http.Server waits for ever

The zero http.Server is a valid configuration with no time limits. Four fields bound the phases of a connection:

  • ReadHeaderTimeout bounds reading the request line and headers.
  • ReadTimeout bounds reading the whole request, body included.
  • WriteTimeout bounds writing the response, counted from the end of the request headers.
  • IdleTimeout bounds the wait for the next request on a kept-alive connection.

Two more bound the size of the headers: MaxHeaderBytes, 1 MB by default, and MaxHeaderValueCount, new in Go 1.27, 500 values by default. timeouts.go sends headers one byte every 50 ms to a zero server and to one with a ReadHeaderTimeout of 200 ms:

timeouts.go: a client that sends its headers slowly, against two servers Go · timeouts.go
// timeouts.go: a client that sends its request headers one byte every 50 ms, against a zero http.Server and against
// one with a ReadHeaderTimeout of 200 ms. The network is in-memory pipes, so the program needs no port.
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"sync"
	"time"
)

// slowHeaders sends a request line, then one header byte every 50 ms, while it reads whatever the server sends.
// It reports whether the server closed the connection within the budget, and after how long.
func slowHeaders(ln *memListener, budget time.Duration) (closed bool, after time.Duration, sent int) {
	c, _ := ln.Dial(context.Background(), "", "")
	defer c.Close()
	start := time.Now()
	done := make(chan int, 1)
	go func() {
		n, _ := io.Copy(io.Discard, c) // returns when the server closes the connection
		done <- int(n)
	}()
	go func() {
		c.Write([]byte("GET /books HTTP/1.1\r\nHost: library.test\r\nX-Slow: "))
		for {
			time.Sleep(50 * time.Millisecond)
			if _, err := c.Write([]byte("x")); err != nil {
				return
			}
		}
	}()
	select {
	case n := <-done:
		return true, time.Since(start), n
	case <-time.After(budget):
		return false, budget, 0
	}
}

func main() {
	handled := make(chan struct{}, 1)
	mux := http.NewServeMux()
	mux.HandleFunc("GET /books", func(w http.ResponseWriter, r *http.Request) { handled <- struct{}{} })

	servers := []struct {
		name string
		srv  *http.Server
	}{
		{"http.Server{}", &http.Server{Handler: mux}},
		{"ReadHeaderTimeout 200ms", &http.Server{Handler: mux, ReadHeaderTimeout: 200 * time.Millisecond}},
	}
	for _, s := range servers {
		fmt.Printf("%s: ReadHeaderTimeout %v, ReadTimeout %v, WriteTimeout %v, IdleTimeout %v\n", s.name,
			s.srv.ReadHeaderTimeout, s.srv.ReadTimeout, s.srv.WriteTimeout, s.srv.IdleTimeout)
		ln := newMemListener()
		go s.srv.Serve(ln)
		closed, after, sent := slowHeaders(ln, time.Second)
		if closed {
			fmt.Printf("  the server closed the connection after about 200 ms: %t, and sent %d bytes back\n",
				after >= 200*time.Millisecond && after < 400*time.Millisecond, sent)
		} else {
			fmt.Println("  after 1s the client is still sending headers and the connection is still open")
		}
		select {
		case <-handled:
			fmt.Println("  the handler ran")
		default:
			fmt.Println("  the handler never ran")
		}
		s.srv.Close()
	}
}

// memListener is a net.Listener made of in-memory pipes: Dial hands one end of a net.Pipe to Accept.
type memListener struct {
	conns  chan net.Conn
	closed chan struct{}
	once   sync.Once
}

func newMemListener() *memListener {
	return &memListener{conns: make(chan net.Conn), closed: make(chan struct{})}
}

func (l *memListener) Accept() (net.Conn, error) {
	select {
	case c := <-l.conns:
		return c, nil
	case <-l.closed:
		return nil, net.ErrClosed
	}
}

func (l *memListener) Close() error {
	l.once.Do(func() { close(l.closed) })
	return nil
}

func (l *memListener) Addr() net.Addr { return memAddr{} }

func (l *memListener) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
	server, client := net.Pipe()
	select {
	case l.conns <- server:
		return client, nil
	case <-l.closed:
		return nil, errors.New("connection refused")
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

type memAddr struct{}

func (memAddr) Network() string { return "mem" }
func (memAddr) String() string  { return "library.test:80" }

Output

http.Server{}: ReadHeaderTimeout 0s, ReadTimeout 0s, WriteTimeout 0s, IdleTimeout 0s
  after 1s the client is still sending headers and the connection is still open
  the handler never ran
ReadHeaderTimeout 200ms: ReadHeaderTimeout 200ms, ReadTimeout 0s, WriteTimeout 0s, IdleTimeout 0s
  the server closed the connection after about 200 ms: true, and sent 0 bytes back
  the handler never ran

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run timeouts.go

The zero server kept the connection, and a goroutine with it, for as long as the client wished. The second closed it at 200 ms without a reply, and the handler never ran. A server that faces the internet sets ReadHeaderTimeout, WriteTimeout and IdleTimeout, and ReadTimeout too when every route’s request bodies are small. Walk through the phases with the setting that bounds each:

Timeline · 5 events in order The phases of one connection, and the setting that bounds each Walk through a connection from its first request byte to the wait for the next request, one phase at a time.
  1. Request headers

    Reading the request line and the headers ReadHeaderTimeout

    ReadHeaderTimeout bounds this phase from the moment the server starts to read a request. MaxHeaderBytes limits how many bytes the headers may take, and MaxHeaderValueCount, new in Go 1.27, how many values they may hold. In timeouts.go, a client that sent a byte every 50 ms was cut off at 200 ms.

  2. Request body

    Reading the body ReadTimeout

    ReadTimeout bounds reading the whole request, the headers and the body together. The documentation notes that it cannot give one upload more time than another, which is why most servers set ReadHeaderTimeout and leave body limits to each handler.

  3. The handler

    Running the handler the request context

    No field of http.Server limits how long a handler runs. The request's context, with a deadline the handler sets, or http.TimeoutHandler around a route that never streams, are the tools for that.

  4. The response

    Writing the response WriteTimeout

    WriteTimeout bounds the writes of the response. It is reset when a new request's headers have been read, so it has to be longer than the slowest handler plus the time its reply takes to send.

  5. Keep-alive wait

    Waiting for the next request IdleTimeout

    After a response, an HTTP/1 connection may be kept open for the next request. IdleTimeout bounds that wait; when it is zero, ReadTimeout is used, and when both are zero, an idle connection stays open for as long as the client keeps it.

The timeline has 5 events, from Request headers to Keep-alive wait.

Checkpoint

Check: Server timeouts and header limits

  1. Question 1 of 2 What time limits does http.Server{} apply to a connection?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: None; ReadHeaderTimeout, ReadTimeout, WriteTimeout and IdleTimeout are all zero

    The zero Server is valid but has no time limits, so every field has to be set by the program. The headers' size is bounded by default, 1 MB and 500 values, but their time is not.

  2. Question 2 of 2 A client sends its headers one byte every 50 ms to a server with ReadHeaderTimeout: 200 * time.Millisecond. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The server closes the connection at about 200 ms, sends nothing back, and the handler never runs

    ReadHeaderTimeout is a deadline measured from the start of the request: a client that sends its headers slowly, on purpose or not, is cut off at that time however busy it keeps the connection.

Graceful shutdown finishes the requests in flight

A supervisor stops a process with SIGTERM, and Ctrl+C sends an interrupt. signal.NotifyContext turns either into a context that ends, and srv.Shutdown(ctx) stops the server graceful shutdown: Stopping a server without dropping work: it stops accepting new connections, lets the requests in flight finish within a time budget, and only then exits. In Go, http.Server.Shutdown does this after a signal such as SIGTERM.: it closes the listener, closes idle connections, and waits for active ones to finish. If the context passed to Shutdown runs out first, Shutdown returns its error. shutdown.go sends itself SIGTERM 100 ms into a request that takes 300 ms:

shutdown.go: SIGTERM during a request Go · shutdown.go
// shutdown.go: a server that stops on SIGTERM the way a supervisor asks it to. signal.NotifyContext ends a context,
// Shutdown closes the listener and waits for the request in flight, and a request that comes after that is refused.
// The program sends SIGTERM to itself, 100 ms into a request that takes 300 ms.
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	mux := http.NewServeMux()
	mux.HandleFunc("GET /report", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(300 * time.Millisecond) // a request that is still running when the signal arrives
		fmt.Fprintln(w, "report ready")
	})
	ln := newMemListener()
	srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	served := make(chan error, 1)
	go func() { served <- srv.Serve(ln) }()

	inFlight := make(chan string, 1)
	go func() { inFlight <- get(ln, "http://library.test/report") }()

	time.Sleep(100 * time.Millisecond)
	syscall.Kill(os.Getpid(), syscall.SIGTERM) // what a supervisor sends to stop the process

	<-ctx.Done()
	fmt.Println("1. the signal context ended:", context.Cause(ctx))

	shutdownErr := make(chan error, 1)
	go func() {
		sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		shutdownErr <- srv.Shutdown(sctx)
	}()

	time.Sleep(50 * time.Millisecond) // Shutdown has closed the listener by now
	fmt.Println("2. a new request during the shutdown:", get(ln, "http://library.test/report"))
	fmt.Println("3. the request that was in flight:", <-inFlight)
	fmt.Println("4. Shutdown returned:", <-shutdownErr)
	err := <-served
	fmt.Printf("5. Serve returned %q, which is http.ErrServerClosed: %t\n", err, errors.Is(err, http.ErrServerClosed))
}

// get sends one request on a client of its own, so that it opens a connection of its own.
func get(ln *memListener, url string) string {
	client := &http.Client{Transport: &http.Transport{DialContext: ln.Dial}}
	resp, err := client.Get(url)
	if err != nil {
		return "error: " + err.Error()
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	return fmt.Sprintf("%s %q", resp.Status, body)
}

// memListener is a net.Listener made of in-memory pipes: Dial hands one end of a net.Pipe to Accept.
type memListener struct {
	conns  chan net.Conn
	closed chan struct{}
	once   sync.Once
}

func newMemListener() *memListener {
	return &memListener{conns: make(chan net.Conn), closed: make(chan struct{})}
}

func (l *memListener) Accept() (net.Conn, error) {
	select {
	case c := <-l.conns:
		return c, nil
	case <-l.closed:
		return nil, net.ErrClosed
	}
}

func (l *memListener) Close() error {
	l.once.Do(func() { close(l.closed) })
	return nil
}

func (l *memListener) Addr() net.Addr { return memAddr{} }

// Dial fails once the listener is closed, as a TCP connect to a port nobody listens on is refused.
func (l *memListener) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
	server, client := net.Pipe()
	select {
	case l.conns <- server:
		return client, nil
	case <-l.closed:
		return nil, errors.New("connection refused")
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

type memAddr struct{}

func (memAddr) Network() string { return "mem" }
func (memAddr) String() string  { return "library.test:80" }

Output

1. the signal context ended: terminated signal received
2. a new request during the shutdown: error: Get "http://library.test/report": connection refused
3. the request that was in flight: 200 OK "report ready\n"
4. Shutdown returned: <nil>
5. Serve returned "http: Server closed", which is http.ErrServerClosed: true

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run shutdown.go

The signal’s context ended with the cause terminated signal received. A new request was refused, the one in flight finished with 200, Shutdown returned nil, and Serve returned http.ErrServerClosed. Step through the run:

Interactive · message by message SIGTERM during a request, and a shutdown that waits for it Step through shutdown.go, from the request in flight to the moment Serve returns.
SIGTERM during a request, and a shutdown that waits for it, step 6 of 6. Messages so far: Clients to The Go server: GET /report; Supervisor to The Go server: SIGTERM; Clients to The Go server: GET /report: refused; The Go server to Clients: 200 report ready.Supervisora deploy, systemd, a podThe Go serverShutdown: nilClientstwo requestsGET /reportSIGTERMGET /report: refused200 report ready

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A request in flight. A client asks for /report, whose handler takes 300 ms. The server is serving it on the connection the client opened.
  2. The signal arrives. 100 ms later the supervisor sends SIGTERM, as a deploy does before it replaces the process. signal.NotifyContext ends its context, and context.Cause says terminated signal received.
  3. The listener closes. Shutdown closes the listener first, so no new connection is accepted, and closes the connections that are idle. Serve returns http.ErrServerClosed at once, while Shutdown keeps waiting.
  4. A new request is refused. A second client tries to connect during the shutdown. Nothing accepts connections any more, so the attempt is refused at once instead of being left to wait.
  5. The first request finishes. The handler that was running when the signal came finishes its 300 ms and its reply goes out, 200 report ready. Its connection is now idle, and Shutdown closes it.
  6. Shutdown returns. With no connection left, Shutdown returns nil, well inside its 10 s budget, and main can exit. Nothing that was accepted was dropped.

Returning from main when Serve returns

Serve and ListenAndServe return ErrServerClosed as soon as Shutdown begins, not when it ends. A main that exits then kills every request still running. Wait for Shutdown to return, as shutdown.go does, then exit.

Checkpoint

Check: Graceful shutdown on SIGTERM

  1. Question 1 of 2 shutdown.go called Shutdown while a request was running. What happened to a new request sent 50 ms later?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It was refused, because Shutdown closes the listener first

    Shutdown closes the listeners, then the idle connections, and then waits for the active ones. The in-flight request finished; the late one was turned away at the door.

  2. Question 2 of 2 main runs srv.ListenAndServe() and returns as soon as it returns, while a goroutine calls srv.Shutdown on SIGTERM. What goes wrong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ListenAndServe returns ErrServerClosed as soon as Shutdown begins, so main exits and kills the requests Shutdown is waiting for

    Run Shutdown in main, or have main wait on a channel until Shutdown returns, and only then exit. shutdown.go printed Shutdown's nil before it read Serve's ErrServerClosed.

TLS and cross-origin checks protect browser users

TLS encrypts the connection and proves the server’s name. Serve it with srv.ListenAndServeTLS(certFile, keyFile) or a tls.Config, and Go 1.27 already chooses well: the minimum version is TLS 1.2 for clients and servers whatever the GODEBUG settings, hybrid post-quantum key exchange is on by default, and HTTP/2 is offered over TLS automatically.

A cross-site request forgery, CSRF, is a page on another site that makes a signed-in user’s browser send a request that changes something. http.CrossOriginProtection, added in Go 1.25, refuses such requests without tokens: it reads the fetch metadata headers: Request headers that browsers add and page scripts cannot set, saying where a request came from. Sec-Fetch-Site is one of them: same-origin, same-site, cross-site, or none for a request the user started directly. header that browsers send, or compares Origin with Host when that header is missing, and always lets GET, HEAD and OPTIONS through. csrf.go sends it six requests:

csrf.go: http.CrossOriginProtection and six requests Go · csrf.go
// csrf.go: http.CrossOriginProtection (Go 1.25) in front of a form that cancels an order, and the six kinds of
// request it meets: from the shop's own pages, from another site, from an older browser, from a script, a safe GET,
// and from a second site of the shop, a sibling subdomain, which is trusted on purpose.
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /orders/{id}/cancel", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "order %s cancelled\n", r.PathValue("id"))
	})
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

	cop := http.NewCrossOriginProtection()
	if err := cop.AddTrustedOrigin("https://shop.library.test"); err != nil {
		fmt.Println("AddTrustedOrigin:", err)
	}
	h := cop.Handler(mux)

	requests := []struct {
		what, method, path string
		headers            map[string]string
	}{
		{"the shop's own page", "POST", "/orders/7/cancel", map[string]string{"Sec-Fetch-Site": "same-origin"}},
		{"a page on another site", "POST", "/orders/7/cancel", map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.test"}},
		{"an older browser, Origin only", "POST", "/orders/7/cancel", map[string]string{"Origin": "https://evil.test"}},
		{"a script, no browser headers", "POST", "/orders/7/cancel", nil},
		{"another site, but a GET", "GET", "/orders/7", map[string]string{"Sec-Fetch-Site": "cross-site"}},
		{"the trusted second site", "POST", "/orders/7/cancel", map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://shop.library.test"}},
	}
	for i, rq := range requests {
		req := httptest.NewRequest(rq.method, "https://library.test"+rq.path, nil)
		for k, v := range rq.headers {
			req.Header.Set(k, v)
		}
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		fmt.Printf("%d. %s: %d %s\n", i+1, rq.what, rec.Code, strings.TrimSpace(rec.Body.String()))
	}
}

Output

1. the shop's own page: 200 order 7 cancelled
2. a page on another site: 403 cross-origin request detected from Sec-Fetch-Site header
3. an older browser, Origin only: 403 cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Host
4. a script, no browser headers: 200 order 7 cancelled
5. another site, but a GET: 200 order 7
6. the trusted second site: 200 order 7 cancelled

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run csrf.go

The other site’s POST got 403, and so did an older browser’s, caught by its Origin. A script with no browser headers got through: CSRF needs a victim’s browser, so other callers must be stopped by authentication instead.

Key idea

Go 1.27’s TLS defaults protect the connection with no extra settings, and CrossOriginProtection refuses the writes that another site’s page makes a browser send; callers that are not browsers still need authentication.

Checkpoint

Check: TLS defaults and cross-origin protection

  1. Question 1 of 2 In Go 1.27, what is the lowest TLS version a server accepts by default, and can a GODEBUG setting lower it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: TLS 1.2, and since Go 1.27 no GODEBUG setting lowers it; only an explicit MinVersion can

    The crypto/tls documentation states that TLS 1.2 is used as the minimum by default. Setting tls.Config.MinVersion is now the only way to admit older clients, and it is a choice nobody makes by accident.

  2. Question 2 of 2 An older browser sends a cross-site POST with Origin https://evil.test and no Sec-Fetch-Site header to a handler behind http.CrossOriginProtection. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 403, because without Sec-Fetch-Site it compares the Origin's host with the Host header, and they differ

    CrossOriginProtection reads Sec-Fetch-Site when a browser sends it and falls back to comparing Origin with Host when it does not. A request with neither is treated as same-origin or not from a browser at all.

Three parts go further. Each timeout is measured on its own connection. The drain around Shutdown gets a readiness check, a hook for streams and a budget. TLS, cookies and proxy headers are checked in real runs.

Every timeout and header limit, measured, and sized with numbers Pro: opens with a Pro, Premium or Ultimate pass

A setting is only as good as what it ends and when. This part measures each timeout and header limit on a connection of its own, shows the two results that surprise people, and turns the numbers into a choice of values.

In this part:

  • Every timeout and header limit, measured on its own connection
  • Sizing the timeouts, with worked numbers

1 runnable example (Go) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Shutdown under load: readiness, hooks for streams and a drain budget Pro: opens with a Pro, Premium or Ultimate pass

Shutdown waits for requests, but not every request ends by itself, and traffic does not stop the moment the signal arrives. This part adds the three pieces a deploy needs around Shutdown, measures what happens without one of them, and fits the whole drain into a supervisor’s grace period.

In this part:

  • A readiness check, a shutdown hook and a deadline for the drain
  • A drain budget that fits the grace period, with worked numbers

1 runnable example (Go) · about 4 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

TLS, cookies and proxy headers for browser traffic, measured Pro: opens with a Pro, Premium or Ultimate pass

Browser traffic adds three things a server must get exactly right: the TLS it negotiates, the cookies it sets and the client address it believes. This part measures a real handshake, follows CrossOriginProtection’s checks in their order, and tests a cookie and a proxy header against forged requests.

In this part:

  • TLS 1.3, post-quantum key exchange and HTTP/2, measured on a handshake
  • Cross-origin protection, check by check
  • Session cookies and client addresses that cannot be forged
  • Practise: a session cookie the browser holds to its rules

5 runnable examples (Go, Go (cookie.go), Go (cookie_test.go), Python) · about 6 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise: harden a server

practice/server.go has NewServer, which returns a zero http.Server for an app whose routes take small form bodies. practice/server_test.go checks its timeouts and its header limit, and that a cross-site POST is refused while a same-origin one gets through:

practice/server.go and practice/server_test.go

Go (server.go) · practice/server.go

// Package practice holds the settings of a production server to finish: timeouts and header limits, and cross-origin
// protection for browser forms.
package practice

import "net/http"

// NewServer returns the server that main starts, for an app whose routes take small form bodies only. It must not
// wait for ever on a slow client, and every request must pass http.CrossOriginProtection before it reaches h.
func NewServer(addr string, h http.Handler) *http.Server {
	return &http.Server{Addr: addr, Handler: h} // a zero Server: no timeouts, no protection
}

Go (server_test.go) · practice/server_test.go

package practice

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"time"
)

func TestTimeoutsAndLimits(t *testing.T) {
	srv := NewServer(":8080", http.NotFoundHandler())
	checks := []struct {
		name     string
		got, max time.Duration
	}{
		{"ReadHeaderTimeout", srv.ReadHeaderTimeout, 10 * time.Second},
		{"ReadTimeout", srv.ReadTimeout, time.Minute},
		{"WriteTimeout", srv.WriteTimeout, time.Minute},
		{"IdleTimeout", srv.IdleTimeout, 5 * time.Minute},
	}
	for _, c := range checks {
		if c.got <= 0 || c.got > c.max {
			t.Errorf("%s = %v, want more than 0 and at most %v", c.name, c.got, c.max)
		}
	}
	if srv.MaxHeaderBytes <= 0 || srv.MaxHeaderBytes > 64<<10 {
		t.Errorf("MaxHeaderBytes = %d, want a limit from 1 byte to 64 KB", srv.MaxHeaderBytes)
	}
}

func TestCrossOriginFormsRefused(t *testing.T) {
	ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
	h := NewServer(":8080", ok).Handler
	for _, site := range []string{"cross-site", "same-origin"} {
		req := httptest.NewRequest("POST", "https://library.test/orders/7/cancel", nil)
		req.Header.Set("Sec-Fetch-Site", site)
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		want := map[string]int{"cross-site": http.StatusForbidden, "same-origin": http.StatusNoContent}[site]
		if rec.Code != want {
			t.Errorf("a POST with Sec-Fetch-Site %s got %d, want %d", site, rec.Code, want)
		}
	}
}
The starter server against its tests Python · practice_session.py
from session import run

# The starter server against its tests: a zero http.Server with no cross-origin protection.
run(
    """
cd practice && go test .
""",
    expect_failure=("cd practice && go test .",),
)

Output

$ cd practice && go test .
--- FAIL: TestTimeoutsAndLimits (0.00s)
    server_test.go:23: ReadHeaderTimeout = 0s, want more than 0 and at most 10s
    server_test.go:23: ReadTimeout = 0s, want more than 0 and at most 1m0s
    server_test.go:23: WriteTimeout = 0s, want more than 0 and at most 1m0s
    server_test.go:23: IdleTimeout = 0s, want more than 0 and at most 5m0s
    server_test.go:27: MaxHeaderBytes = 0, want a limit from 1 byte to 64 KB
--- FAIL: TestCrossOriginFormsRefused (0.00s)
    server_test.go:41: a POST with Sec-Fetch-Site cross-site got 204, want 403
FAIL
FAIL	example.com/practice	1.552s
FAIL
[exit status 1]

This output changes from run to run: go test prints how long each test and the whole run took, which differs from one run to the next.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

Make go test . pass in the practice folder. A first hint: wrap the handler with http.NewCrossOriginProtection().Handler(h). Then the questions on timeouts, shutdown and browser protection:

Practice that adapts to you

Practise this lesson

6 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 6 Which http.Server setting bounds the wait for the next request on a kept-alive connection?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: IdleTimeout

    Each timeout belongs to one phase of a connection: headers, the whole request, the response, and the idle wait between requests.

  2. Question 2 of 6 A service takes 50 MB uploads on one route and small JSON everywhere else. How do you set ReadHeaderTimeout and ReadTimeout?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: A ReadHeaderTimeout of a few seconds for every request, and ReadTimeout left at zero, with the upload route bounding its own body

    Bound the headers for everyone with ReadHeaderTimeout, and bound bodies per route, where the handler knows how large and how slow its body may be.

  3. Question 3 of 6 Which signals should a Go server usually stop on? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 3 of 6

    Answer:

    • os.Interrupt, what Ctrl+C sends at a terminal
    • syscall.SIGTERM, what supervisors send to stop a process

    signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) covers both, and since Go 1.26 context.Cause says which one arrived.

  4. Question 4 of 6 The context passed to Shutdown runs out while a request is still running. What does Shutdown return?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: The context's error, and the request is still running, so the program must decide what to do next

    Give Shutdown a budget with a context, check its error, and close what is left on purpose when the budget runs out, rather than letting the supervisor kill the process.

  5. Question 5 of 6 Which requests does http.CrossOriginProtection always let through?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: GET, HEAD and OPTIONS, which must therefore never change anything

    The protection rests on HTTP's rule that safe methods do not change state. Keep every change behind POST, PUT, PATCH or DELETE.

  6. Question 6 of 6 A partner's server posts to your API with no Sec-Fetch-Site and no Origin. CrossOriginProtection lets it through. Is that a hole?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: No; CSRF needs a victim's browser, so callers that are not browsers must be stopped by authentication, such as a token check

    CrossOriginProtection solves one problem, forged browser requests. Who may call the API at all is decided by authentication, which every route that changes data needs as well.

Interview questions

Warm-up (fresher): Why does a Go server need ReadHeaderTimeout when it already has a handler timeout?

A handler timeout only starts once the handler runs, and the handler runs only after the request’s headers have arrived. A client that sends its headers slowly never reaches the handler, so only ReadHeaderTimeout ends it. Without one, each such client holds a connection and a goroutine for as long as it likes.

Three longer questions of the module bank build on this lesson:

  1. Mid to senior

    A Go API's goroutine count climbs to 50,000 overnight while its requests per second stay flat, and the process runs out of memory. It was started with http.ListenAndServe(":8080", mux). What is the likely cause, how do you confirm it, and what do you configure?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    Every deploy causes a burst of 502 errors at the load balancer for about 5 seconds, although the service calls srv.Shutdown on SIGTERM. Explain the likely causes and design a shutdown sequence that drops nothing.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A web app in Go uses cookie sessions and HTML forms to move money between a user's accounts. Design its protection against forged requests and stolen session cookies: which Go features you use, which cookie attributes you set, and what is still needed for older browsers and non-browser clients.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • The zero http.Server waits for ever: set ReadHeaderTimeout, WriteTimeout and IdleTimeout, ReadTimeout when every body is small, and limits for the headers.
  • Stop on SIGTERM with signal.NotifyContext and Shutdown, and let main exit only once Shutdown has returned.
  • Go 1.27 serves TLS 1.2 and up with post-quantum key exchange and HTTP/2 by default, and CrossOriginProtection refuses cross-site browser writes without tokens.

Terms in this lesson

fetch metadata headers
Request headers that browsers add and page scripts cannot set, saying where a request came from. Sec-Fetch-Site is one of them: same-origin, same-site, cross-site, or none for a request the user started directly.
graceful shutdown
Stopping a server without dropping work: it stops accepting new connections, lets the requests in flight finish within a time budget, and only then exits. In Go, http.Server.Shutdown does this after a signal such as SIGTERM.
Little's law
A rule of queueing theory: the average number of items in a system equals the rate at which they arrive times the average time each one stays. 200 arrivals a second that stay 5 s each make about 1,000 present at once.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.