Writing a Tiny Reverse Proxy in Go

The standard library already ships a reverse proxy. With a few dozen lines around it you get retries, circuit breaking and tracing, and you understand every byte of it.

The Go standard library hands you a working reverse proxy in one struct. httputil.ReverseProxy does connection pooling, header rewriting and streaming bodies out of the box. What it leaves to you is exactly the interesting part: what happens when the upstream misbehaves.

The core is almost nothing

A director function rewrites the inbound request to point at your backend. That is the whole proxy.

proxy := &httputil.ReverseProxy{
    Director: func(r *http.Request) {
        r.URL.Scheme = "http"
        r.URL.Host = "127.0.0.1:8081"
        r.Header.Set("X-Forwarded-Host", r.Host)
    },
}
http.ListenAndServe(":8080", proxy)

This forwards everything, streams responses, and handles WebSocket upgrades. For a lot of internal use it is already enough. The production gap is entirely in failure handling.

Retries belong on idempotent requests only

The single most useful addition is retrying a failed upstream connection, but only for methods that are safe to replay. Retrying a POST because the connection dropped can double-charge a customer.

proxy.ModifyResponse = func(resp *http.Response) error {
    if resp.StatusCode >= 500 {
        return errRetry
    }
    return nil
}

Pair that with an ErrorHandler that re-dispatches GET, HEAD and PUT to the next healthy backend, and leaves everything else to fail fast.

Circuit breaking keeps a sick backend from taking you down

When an upstream starts timing out, piling more requests onto it makes everything worse. A breaker trips after N consecutive failures and short-circuits to a fast error for a cooldown window, giving the backend room to recover.

Tracing is a header and a hook

Generate a request ID at the edge, propagate it downstream, and log it on the way out. Now a single line in your logs ties the client request to every backend hop it touched.

The finished thing is maybe 200 lines, and unlike a black-box proxy you can read all of them. When it misbehaves at 3am, that is worth more than any feature.

← All writing