package proxy
import (
"log"
"net"
"net/http"
"net/http/httputil"
"time"
)
func New(pool *Pool) *httputil.ReverseProxy {
p := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
target := pool.Next()
pr.SetURL(target)
pr.SetXForwarded()
pr.Out.Host = target.Host
pr.Out.Header.Set("X-Proxy-By", "go-edge/1.0")
},
ModifyResponse: func(resp *http.Response) error {
resp.Header.Del("Server")
resp.Header.Set("X-Cache", "MISS")
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("proxy error for %s: %v", r.URL.Path, err)
http.Error(w, "upstream unavailable", http.StatusBadGateway)
},
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
},
}
return p
}
package proxy
import (
"fmt"
"net/url"
"sync/atomic"
)
type Pool struct {
targets []*url.URL
counter uint64
}
func NewPool(raw []string) (*Pool, error) {
targets, err := parseBackends(raw)
if err != nil {
return nil, err
}
return &Pool{targets: targets}, nil
}
func (p *Pool) Next() *url.URL {
n := atomic.AddUint64(&p.counter, 1)
return p.targets[(n-1)%uint64(len(p.targets))]
}
func parseBackends(raw []string) ([]*url.URL, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("no backends configured")
}
targets := make([]*url.URL, 0, len(raw))
for _, s := range raw {
u, err := url.Parse(s)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("invalid backend %q: %v", s, err)
}
targets = append(targets, u)
}
return targets, nil
}
package main
import (
"log"
"net/http"
"time"
"example.com/edge/proxy"
)
func main() {
pool, err := proxy.NewPool([]string{
"http://127.0.0.1:9001",
"http://127.0.0.1:9002",
})
if err != nil {
log.Fatalf("config: %v", err)
}
p := proxy.New(pool)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.Handle("/", p)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Println("edge proxy listening on :8080")
if err := srv.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
A reverse proxy sits in front of one or more backend servers and forwards client requests to them, often adding cross-cutting concerns like TLS termination, header normalization, and upstream selection. Go's standard library ships net/http/httputil.ReverseProxy, which handles the hard parts — streaming bodies, connection reuse, and hop-by-hop header stripping — while leaving the routing and rewriting policy to the caller.
The proxy package tab builds a proxy with a custom Rewrite function rather than the older Director. The Rewrite callback receives a *httputil.ProxyRequest exposing both the inbound In request and the outbound Out request; pr.SetURL points the outbound request at the chosen backend and pr.SetXForwarded populates X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto from the inbound connection. Using SetXForwarded instead of hand-writing those headers avoids the classic spoofing bug where a client-supplied X-Forwarded-For is trusted and appended to. The code also injects an X-Proxy-By header and rewrites Host so the backend sees its own name.
Backend selection lives in Backends pool, a tiny round-robin balancer. Next uses atomic.AddUint64 on a counter and mods by the slice length, so it is safe under concurrent requests without a mutex. parseBackends validates each URL up front with url.Parse so a misconfiguration fails at startup rather than on the first request.
Two other hooks make the proxy production-shaped. ModifyResponse strips a leaky Server header from upstream responses and stamps an X-Cache marker. ErrorHandler converts upstream failures — a downed backend, a context cancellation — into a clean 502 Bad Gateway instead of leaking Go's default error text, and logs the cause.
The Transport is customized too: bounded dial timeouts, capped idle connections, and ForceAttemptHTTP2 keep a single misbehaving backend from exhausting resources. The main server tab wires it into an http.Server with explicit ReadHeaderTimeout and WriteTimeout, which the default server lacks and which matter when proxying slow clients. Together the files show the real division of labor: ReverseProxy streams bytes, and the surrounding code owns policy — who to talk to, what headers to trust, and how to fail.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
package api
import (
"io"
"net/http"
"os"
Safe multipart uploads using temp files (bounded memory)
Share this code
Here's the card — post it anywhere.