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 Rewrite hook rewrites the inbound request to point at your backend. That is the whole proxy.
target, _ := url.Parse("http://127.0.0.1:8081")
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target) // scheme + host of the backend
r.Out.Host = r.In.Host // keep the client's Host header
r.SetXForwarded() // X-Forwarded-For/-Host/-Proto, set safely
},
}
The older Director hook is what most examples still show, and Go 1.26 deprecated it as fundamentally unsafe: a client can delete a header a Director sets by naming it in the Connection header, so Director can never establish a trustworthy X-Forwarded-*. Rewrite sees both the inbound request (r.In) and the outbound one (r.Out), and SetXForwarded() overwrites rather than appends, so a spoofed header from the client cannot survive.
Give it a server with timeouts - ListenAndServe sets none, and a proxy with no read or write deadline is a slow-loris target:
srv := &http.Server{
Addr: ":8080",
Handler: proxy,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
}
log.Fatal(srv.ListenAndServe())
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 - and each addition below has a naive version that quietly makes things worse.
Retries belong on idempotent requests only
The single most useful addition is retrying a failed upstream connection, but only for methods that are idempotent - safe to send twice. Retrying a POST because the connection dropped can double-charge a customer.
ErrorHandler is where a failed connection lands: ReverseProxy calls it when it never got a response, without ever calling ModifyResponse. (ModifyResponse fires only when the backend did respond, so a 500 there means the upstream already ran the request - not something to blindly replay.)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
next.ServeHTTP(w, r) // another backend
return
}
w.WriteHeader(http.StatusBadGateway)
}
Two traps hide here. The obvious one is idempotency: GET and HEAD are safe, PUT and DELETE are idempotent by definition but not always in practice, and POST never is. The subtle one is the body: the first attempt has already consumed r.Body, so replaying a request that had one sends an empty body downstream. A correct retry buffers the body (with a cap) and rewinds it, or restricts itself to bodiless requests - which is why the handler above only retries GET and HEAD.
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. For the breaker to ever trip, a hung upstream has to count as a failure - which means giving the Transport a ResponseHeaderTimeout (or the request a context deadline), because DefaultTransport will wait on a silent backend indefinitely.
- Closed - requests flow normally, failures are counted.
- Open - the breaker returns immediately, no upstream call.
- Half-open - a single probe decides whether to close again.
Tracing is a header and a hook
Generate a request ID at the edge in the same Rewrite hook, 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.
Filled in, retries and breaker and tracing and all, this is a couple hundred lines - and unlike a black-box proxy you can read every one of them. When it misbehaves at 3am, that is worth more than any feature.