Articles / Go

  • go
  • net-http
  • timeouts
  • http-server
  • graceful-shutdown
  • security

Build a Tiny HTTP Server That Knows When to Stop

A tiny HTTP server is easy to write. A tiny HTTP server that does not wait forever for headers, accept an enormous request body, or vanish halfway through shutdown takes a little more thought.

The useful shape is surprisingly small: configure the standard library's http.Server, cap request bodies in the handler, give work a deadline, and wait for Shutdown to finish.

Start with a deliberately boring server

The standard library already gives us most of the machinery. The important bit is that the limits belong to the server and handler, not to wishful thinking about well-behaved clients.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

const maxBodyBytes = 1 << 20

type echoRequest struct {
	Message string `json:"message"`
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /echo", echoHandler)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
		MaxHeaderBytes:    16 << 10,
	}

	serveErr := make(chan error, 1)
	go func() {
		serveErr <- srv.ListenAndServe()
	}()

	stop, stopCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stopCancel()

	select {
	case err := <-serveErr:
		if !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("HTTP server: %v", err)
		}
	case <-stop.Done():
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()

		if err := srv.Shutdown(shutdownCtx); err != nil {
			log.Printf("HTTP shutdown: %v", err)
		}
		if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Printf("HTTP server: %v", err)
		}
	}
}

func echoHandler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
	defer cancel()

	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	defer r.Body.Close()

	var req echoRequest
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&req); err != nil {
		var maxErr *http.MaxBytesError
		if errors.As(err, &maxErr) {
			http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
		return
	}

	select {
	case <-ctx.Done():
		http.Error(w, "request timed out", http.StatusGatewayTimeout)
	case <-time.After(10 * time.Millisecond):
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]string{"message": req.Message})
	}
}

There is no framework here, and no background worker pool hiding the control flow. That makes it easier to see which protection does what.

Header limits stop slow and silly clients

ReadHeaderTimeout gives the client five seconds to send the request headers. This is the setting that makes a slowloris-style connection expensive for the attacker instead of expensive for your server.

MaxHeaderBytes limits the amount of header data the server will parse. Sixteen KiB is enough for ordinary APIs, but it may be too small if you deliberately support large cookies or unusually long authorisation headers. Pick a limit from your protocol, then test the largest valid request.

These settings apply before your handler runs. That is useful because a handler cannot protect a request which never gets as far as the handler.

Body limits belong at the edge of the handler

MaxBytesReader wraps the request body and stops reads after the configured limit. In this example, the JSON body is capped at 1 MiB before the decoder gets to consume it.

That is different from checking Content-Length. Clients can omit it, lie about it, or use chunked encoding. The reader limit still applies when the bytes arrive gradually.

Quick detour: the limit is not a general memory limit. A JSON decoder can still spend CPU time parsing a body that fits inside 1 MiB, and your application can create large derived values from a small input. The limit handles one resource problem. It does not magically solve all of them.

The handler also uses DisallowUnknownFields. That is not required for safety, but it makes accidental API changes fail loudly instead of being silently ignored.

Server deadlines and request deadlines are different

ReadTimeout covers reading the request, including its body. That protects the server from clients which send a body painfully slowly, but it also limits legitimate slow uploads. If your service accepts large uploads over poor connections, use a route or server design that gives those uploads a different budget.

WriteTimeout limits how long the server can spend writing a response. IdleTimeout limits the wait for the next request on a keep-alive connection. They protect different phases, so setting one does not make the others redundant.

The handler adds a three-second context deadline for application work. A context deadline tells your code when to stop; a server write timeout limits the network operation itself. Your database calls, outbound HTTP requests and expensive computations need to use that context if they are to stop promptly.

The example uses a short time.After only to make the deadline visible. Real code would pass ctx to the operation that might take too long. If that operation ignores the context, the deadline is merely decorative, which is a rather expensive form of theatre.

Shutdown is a sequence, not a boolean

When the process receives SIGTERM or an interrupt, Shutdown stops accepting new connections, closes idle ones and waits for active handlers to return. Existing requests get a chance to finish.

ListenAndServe returns http.ErrServerClosed after shutdown. That is expected control flow, not a startup failure. Treating every non-nil error as fatal is a common way to make a clean deployment look broken.

The ten-second shutdown context is the final backstop. A handler which ignores cancellation, waits on a broken dependency, or is stuck in some other way cannot hold the process open forever.

Notice that the program waits for serveErr after calling Shutdown. Calling shutdown and immediately returning from main throws away the graceful part. The process must remain alive while the server drains.

Try the failure cases, not just the happy path

Run the server, then send a normal request:

curl -i -X POST http://127.0.0.1:8080/echo \
  -H 'Content-Type: application/json' \
  -d '{"message":"hello"}'

An unknown field should produce a bad request:

curl -i -X POST http://127.0.0.1:8080/echo \
  -H 'Content-Type: application/json' \
  -d '{"message":"hello","admin":true}'

To test the body cap, generate more than 1 MiB:

python3 -c 'print("x" * 1100000)' > body.txt
curl -i -X POST http://127.0.0.1:8080/echo \
  -H 'Content-Type: application/json' \
  --data-binary @body.txt

Finally, send Ctrl-C while a request is running. The process should stop accepting new work, let the current handler finish if it can, and then exit within the shutdown budget.

This is still a tiny server. It has no authentication, observability, rate limiting or TLS, and it should not be mistaken for a finished public service. It does have one useful property, though: its refusal to wait forever is visible in the code.