Go’s net/http/httputil package ships a production-capable reverse proxy in the standard library. httputil.ReverseProxy handles HTTP/1.1 and HTTP/2, connection pooling, request forwarding, and hop-by-hop header stripping out of the box. Building an API gateway means layering routing logic, authentication, rate limiting, and observability on top of this foundation.
This guide covers building from a single-host proxy up to a multi-backend gateway with load balancing, circuit breaking, and middleware. For rate limiting foundations see Go semaphores and rate limiting and for middleware patterns see Go Gin framework.
The Simplest Reverse Proxy
httputil.NewSingleHostReverseProxy creates a proxy that forwards all requests to one target:
package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
)
func main() {
target, _ := url.Parse("http://localhost:8081")
proxy := httputil.NewSingleHostReverseProxy(target)
// Optional: customize error handling
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("proxy error: %v", err)
http.Error(w, "backend unavailable", http.StatusBadGateway)
}
log.Println("proxy listening on :8080")
log.Fatal(http.ListenAndServe(":8080", proxy))
}
This alone is useful for local development — proxy API requests from a frontend dev server to a backend. For production, you need custom routing and middleware.
Custom Director: Rewriting Requests
The Director function runs before the request is forwarded. It’s where you rewrite the URL, add headers, and set forwarding metadata:
func newProxy(target *url.URL, stripPrefix string) *httputil.ReverseProxy {
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req) // sets scheme, host, and path
// Strip the gateway prefix before forwarding
// /api/users/42 → /users/42
if stripPrefix != "" {
req.URL.Path = strings.TrimPrefix(req.URL.Path, stripPrefix)
if req.URL.Path == "" {
req.URL.Path = "/"
}
}
// Add standard forwarding headers
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
if prior := req.Header.Get("X-Forwarded-For"); prior != "" {
clientIP = prior + ", " + clientIP
}
req.Header.Set("X-Forwarded-For", clientIP)
}
req.Header.Set("X-Forwarded-Host", req.Host)
req.Header.Set("X-Forwarded-Proto", "https")
// Remove the original Host header so the backend sees its own hostname
req.Host = target.Host
}
return proxy
}
Always call originalDirector(req) first — it sets the scheme and host on the URL. Failing to do so results in requests that never leave the proxy.
Path-Based Routing: The Core of an API Gateway
An API gateway routes requests to different backends based on URL prefix, method, or other request attributes:
type Route struct {
Prefix string
Target *url.URL
Methods []string // empty = all methods
}
type Gateway struct {
routes []*Route
mu sync.RWMutex
}
func (g *Gateway) AddRoute(prefix, target string, methods ...string) error {
u, err := url.Parse(target)
if err != nil {
return fmt.Errorf("invalid target %q: %w", target, err)
}
g.mu.Lock()
g.routes = append(g.routes, &Route{Prefix: prefix, Target: u, Methods: methods})
g.mu.Unlock()
return nil
}
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mu.RLock()
route := g.match(r)
g.mu.RUnlock()
if route == nil {
http.Error(w, "no route matched", http.StatusNotFound)
return
}
proxy := newProxy(route.Target, route.Prefix)
proxy.ServeHTTP(w, r)
}
func (g *Gateway) match(r *http.Request) *Route {
// Longest prefix wins — more specific routes take priority
var best *Route
for _, route := range g.routes {
if !strings.HasPrefix(r.URL.Path, route.Prefix) {
continue
}
if len(route.Methods) > 0 && !slices.Contains(route.Methods, r.Method) {
continue
}
if best == nil || len(route.Prefix) > len(best.Prefix) {
best = route
}
}
return best
}
Longest-prefix matching ensures /api/users/admin routes to the users service, not a more generic /api route.
Middleware Chain
Wrap the gateway with middleware for authentication, rate limiting, and observability. Go’s standard http.Handler interface makes this composable:
// Auth middleware: validate Bearer token, reject unauthorized requests early
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "missing or invalid authorization", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := validateJWT(token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Inject identity into the forwarded request
r.Header.Set("X-User-ID", strconv.Itoa(claims.UserID))
r.Header.Set("X-User-Role", claims.Role)
// Remove the original token — backends don't need it
r.Header.Del("Authorization")
next.ServeHTTP(w, r)
})
}
// Request logger middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rw, r)
slog.Info("gateway request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rw.code),
slog.Duration("duration", time.Since(start)),
)
})
}
type responseWriter struct {
http.ResponseWriter
code int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.code = code
rw.ResponseWriter.WriteHeader(code)
}
Compose the full stack:
gateway := &Gateway{}
gateway.AddRoute("/api/users", "http://user-service:8081")
gateway.AddRoute("/api/orders", "http://order-service:8082")
gateway.AddRoute("/api/products", "http://product-service:8083")
// Middleware applied outermost-first (loggingMiddleware runs first)
handler := loggingMiddleware(
authMiddleware(
rateLimitMiddleware(100, time.Second,
gateway,
),
),
)
http.ListenAndServe(":8080", handler)
Load Balancing Across Multiple Backends
Replace the single-host proxy with a load balancer. Round-robin is the simplest correct approach:
type RoundRobinBalancer struct {
backends []*url.URL
counter atomic.Uint64
}
func NewBalancer(targets []string) (*RoundRobinBalancer, error) {
b := &RoundRobinBalancer{}
for _, t := range targets {
u, err := url.Parse(t)
if err != nil {
return nil, err
}
b.backends = append(b.backends, u)
}
return b, nil
}
func (b *RoundRobinBalancer) Next() *url.URL {
n := b.counter.Add(1)
return b.backends[int(n-1)%len(b.backends)]
}
func (b *RoundRobinBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
target := b.Next()
proxy := newProxy(target, "")
proxy.ServeHTTP(w, r)
}
For health-aware load balancing, periodically probe /health on each backend and remove unhealthy ones from rotation. The atomic.Uint64 counter is safe for concurrent access without a mutex — the modulo operation distributes requests evenly across available backends.
Circuit Breaker
A circuit breaker prevents cascading failures by stopping requests to a backend that’s consistently failing:
type CircuitBreaker struct {
mu sync.Mutex
failures int
threshold int
openUntil time.Time
halfOpenTest time.Time
}
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
now := time.Now()
if now.Before(cb.openUntil) {
// Open: reject requests
if now.After(cb.halfOpenTest) {
// Allow one test request through
cb.halfOpenTest = now.Add(5 * time.Second)
return true
}
return false
}
return true // Closed: allow requests
}
func (cb *CircuitBreaker) Record(success bool) {
cb.mu.Lock()
defer cb.mu.Unlock()
if success {
cb.failures = 0
cb.openUntil = time.Time{}
return
}
cb.failures++
if cb.failures >= cb.threshold {
cb.openUntil = time.Now().Add(30 * time.Second)
cb.failures = 0
}
}
Wrap the proxy with circuit breaker checks:
func (b *BackendWithCB) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !b.cb.Allow() {
http.Error(w, "service temporarily unavailable", http.StatusServiceUnavailable)
w.Header().Set("Retry-After", "30")
return
}
rw := &responseWriter{ResponseWriter: w, code: 200}
b.proxy.ServeHTTP(rw, r)
b.cb.Record(rw.code < 500)
}
For production circuit breaking, use github.com/sony/gobreaker or github.com/afex/hystrix-go — they implement proper state machines with half-open testing, metric reporting, and configurable thresholds.
Timeout and Retry Configuration
The default http.Transport used by httputil.ReverseProxy has no timeout. Always configure one:
proxy.Transport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // TCP connection timeout
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second, // time to first response byte
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
}
ResponseHeaderTimeout is the most important — it prevents a slow backend from holding the proxy goroutine indefinitely.
Summary
httputil.ReverseProxyis production-capable out of the box — customize viaDirector,Transport, andErrorHandler- Strip the gateway path prefix in the Director before forwarding, and always call
originalDirector(req)first - Build routing with longest-prefix matching — more specific routes win over generic ones
- Compose middleware as
http.Handlerwrappers — auth, rate limiting, logging, circuit breaking are all independent layers - Always configure
Transporttimeouts — the zero-value transport has no timeout and will leak goroutines on slow backends - For production load balancing and circuit breaking, use
gobreakerorhystrix-gorather than the minimal implementations shown here
Resources
- net/http/httputil documentation
- sony/gobreaker — circuit breaker
- microservices.io: API Gateway pattern
- Go by Example: HTTP Servers
Comments