Skip to main content

API Gateways and Reverse Proxies in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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.ReverseProxy is production-capable out of the box — customize via Director, Transport, and ErrorHandler
  • 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.Handler wrappers — auth, rate limiting, logging, circuit breaking are all independent layers
  • Always configure Transport timeouts — the zero-value transport has no timeout and will leak goroutines on slow backends
  • For production load balancing and circuit breaking, use gobreaker or hystrix-go rather than the minimal implementations shown here

Resources

Comments

👍 Was this article helpful?