Stage 7 · Master
Phase 5 — API Gateway
Reverse Proxy
Stand up gateway-service as the one process residents, staff, and future mobile clients ever talk to directly, forwarding every request to the correct backend service via Go's standard-library reverse proxy.
Three Independently-Deployable Services Need One Public Address
user-service and auth-service (Phases 3 and 4) each expose their own HTTP API, each on its own port, each deployed independently. That is exactly right internally — it is what let auth-service ship a signing-key rotation without touching user-service at all. But no client outside the cluster should ever be handed three different hostnames and have to know which service owns which path. gateway-service exists to be the one thing a browser, mobile app, or third-party integrator ever points at.
Every request now pays for one additional network hop and one additional TLS termination before it reaches the actual backend. This module treats that cost as worth paying for three specific capabilities gateway-service centralizes: JWT verification (jwt-validation lesson), fair rate limiting (rate-limiting lesson), and a single point of observability (logging, monitoring lessons) — none of which should be duplicated and subtly drift across every backend service.
Scaffolding gateway-service
package config
import (
"fmt"
"net/url"
sharedconfig "github.com/hoa-platform/backend/pkg/config"
)
type rawConfig struct {
Port string `env:"GATEWAY_PORT" env-default:"8080"`
UserServiceURL string `env:"USER_SERVICE_URL" env-required:"true"`
AuthServiceURL string `env:"AUTH_SERVICE_URL" env-required:"true"`
}
type Upstreams struct {
User *url.URL
Auth *url.URL
}
type Config struct {
Port string
Upstreams Upstreams
}
func Load() (Config, error) {
var raw rawConfig
if err := sharedconfig.Load(&raw); err != nil {
return Config{}, err
}
user, err := parseUpstream("USER_SERVICE_URL", raw.UserServiceURL)
if err != nil {
return Config{}, err
}
auth, err := parseUpstream("AUTH_SERVICE_URL", raw.AuthServiceURL)
if err != nil {
return Config{}, err
}
return Config{Port: raw.Port, Upstreams: Upstreams{User: user, Auth: auth}}, nil
}
func parseUpstream(key, raw string) (*url.URL, error) {
target, err := url.ParseRequestURI(raw)
if err != nil || target.Host == "" {
return nil, fmt.Errorf("%s must be an absolute HTTP URL", key)
}
if target.Scheme != "http" && target.Scheme != "https" {
return nil, fmt.Errorf("%s uses unsupported scheme %q", key, target.Scheme)
}
if target.User != nil {
return nil, fmt.Errorf("%s must not contain credentials", key)
}
return target, nil
}pkg/config and cleanenv still own environment parsing and required/default behavior. Gateway adds one domain-specific validation step: upstream strings must be safe absolute URLs before ReverseProxy receives them. Rejecting credentials prevents accidental password disclosure through logs and errors.
Director Functions Are Where Routing Logic Actually Lives
httputil.ReverseProxy does the mechanical work of forwarding a request and streaming back the response, but it needs to be told, per request, where to send it. That decision lives in a Director function — a plain func(*http.Request) that mutates the outgoing request's URL before ReverseProxy dials it.
package proxy
import (
"net/http"
"net/http/httputil"
"net/url"
"github.com/rs/zerolog"
)
// Route is one entry in the gateway's static routing table: any request
// whose path starts with Prefix is forwarded to Target.
type Route struct {
Prefix string
Target *url.URL
}
// New builds a single http.Handler that dispatches to whichever backend a
// request's path prefix matches, returning 404 for anything unrecognized
// rather than silently forwarding to a default.
func New(routes []Route, logger zerolog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, route := range routes {
if hasPrefix(r.URL.Path, route.Prefix) {
newReverseProxy(route.Target, logger).ServeHTTP(w, r)
return
}
}
http.NotFound(w, r)
})
}
func hasPrefix(path, prefix string) bool {
return len(path) >= len(prefix) && path[:len(prefix)] == prefix
}
func newReverseProxy(target *url.URL, logger zerolog.Logger) *httputil.ReverseProxy {
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// X-Forwarded-Host preserves what the client actually requested,
// since req.Host is about to be rewritten to the backend's own host.
req.Header.Set("X-Forwarded-Host", req.Host)
}
// Without a custom ErrorHandler, a backend that is down or unreachable
// produces a bare connection-reset with no response body at all — the
// worst possible experience for a client that just gets nothing back.
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
logger.Error().
Str("target", target.String()).
Str("path", r.URL.Path).
Err(err).
Msg("upstream unreachable")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(`{"error":"upstream_unavailable","message":"the requested service is temporarily unreachable"}`))
}
return proxy
}hasPrefix is a hand-rolled byte comparison rather than strings.HasPrefix purely to keep this first version dependency-minimal; the request-routing lesson replaces the dispatch loop with Gin route groups once versioning and path ownership are introduced.
A Static Table to Start — Deliberately, Not a Missing Feature
cfg, err := config.Load()
if err != nil {
logger.Error().Err(err).Msg("gateway config invalid")
os.Exit(1)
}
routes := []proxy.Route{
{Prefix: "/api/v1/auth", Target: cfg.Upstreams.Auth},
{Prefix: "/api/v1/users", Target: cfg.Upstreams.User},
{Prefix: "/api/v1/memberships", Target: cfg.Upstreams.User},
}
engine := gin.New()
engine.Use(gin.Recovery())
engine.GET("/healthz", healthHandler)
engine.NoRoute(gin.WrapH(proxy.New(routes, logger)))
http.ListenAndServe(":"+cfg.Port, engine)The loop in New returns http.NotFound for anything matching no configured prefix, deliberately — a fallback 'forward everything else to service X' default has, in practice, caused internal debug or admin endpoints on a backend service to become accidentally internet-reachable the moment that backend added a new route nobody had explicitly gatewayed.
Confirming the Proxy Forwards Correctly and Fails Gracefully
Applied exercise
Add a circuit breaker before ErrorHandler's 502 becomes a cascading retry storm
user-service degrades and starts timing out (not immediately erroring) under load — every gateway request to it now waits the full client timeout before failing, and a naive client-side retry loop against the gateway multiplies the load on an already-struggling backend.
- Describe where a circuit breaker would sit relative to newReverseProxy — wrapping each Route's target, or shared across all routes to one target.
- State what condition should open the circuit (a count of consecutive timeouts? an error rate over a window?) and what the gateway should return to clients while it is open.
- Explain why returning a fast, explicit 503 while the circuit is open is better for the struggling backend than continuing to forward every request and waiting for each to time out.
Deliverable
A written circuit-breaker design identifying where state lives and the open-circuit client response.
Completion checks
- The design correctly scopes breaker state per backend target, not globally across all routes.
- The explanation connects fast-fail behavior to reducing load on an already-degraded backend, not just to improving gateway latency.
Why does gateway-service's routing loop return http.NotFound for any path matching no configured Route, rather than forwarding unmatched paths to a default backend?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.