A complete inventory of what Switchyard implements today. Each feature is marked with:
- ⚙️ Config — configurable from
switchyard.json(no code). - 🧩 SDK — a Go interface you can swap to replace the default logic entirely.
Architecturally, every request flows through a three-stage pipeline — Capture (an immutable Request snapshot) → Decide (pure routing, no I/O) → Act (the only side-effecting stage). All behavior sits behind 13 pluggable stages (an interface plus a config-driven default), so the config-only binary and the SDK run off identical code. New(cfg) reproduces the turnkey binary's behavior exactly; SDK overrides are additive.
See config-reference.md for every JSON field and extending.md for the SDK.
Forwards requests to upstream servers; each backend wraps httputil.ReverseProxy with lock-free round-robin selection (atomic.Uint64), automatic X-Forwarded-For, a per-backend tuned http.Transport, and a custom error handler (unreachable upstream → configurable 502).
- ⚙️ Config:
backends[](id,url); per-backendmax_connections,timeouts,transport,disable_keep_alive,methods. - 🧩 SDK:
BackendSelector(defaultRoundRobinSelector) viap.Selector/loc.Selector;BackendPool(defaultStaticPool) viap.Pool/loc.Pool— e.g. health-checked or service-discovery pools. Global transport override viap.Transport.Backend.MaxConns()/InFlight()/Accepts()are exposed for capacity- and method-aware selectors.
Ordered locations, first-match-wins; prefix or regex matching. Three location types: proxy (own backend pool + independent round-robin), static (serve files), response (canned response). No match → configurable 404.
- ⚙️ Config:
locations[]withpath,regex,type,backends,root,strip_prefix,response. - 🧩 SDK:
Router(defaultDefaultRouter) viap.Routerfor host/header-based routing; whole-routing override viaDecider(p.Decider).
Each location carries its own pool and its own round-robin counter, so locations sharing a backend rotate independently.
- ⚙️ Config:
locations[].backends: ["api1", "api2"]. - 🧩 SDK: per-location
loc.Pool/loc.Selector.
After path→location, backends are filtered by HTTP method; each backend may declare accepted methods (empty = any, matched case-insensitively). If no backend accepts the method → 405 with an auto-generated Allow header. Reroute stays within method-eligible backends.
- ⚙️ Config:
backends[].methods;method_not_allowedresponse. - 🧩 SDK:
Backend.Accepts(method)for method-aware selectors;p.MethodNotAllowed(ResponseGenerator).
Allow/deny by client IP (single IP or CIDR, IPv4/IPv6) at two tiers: a project-wide top-level whitelist/blacklist and a per-location one. Within a tier: blacklist wins; a non-empty whitelist is allow-list-only; empty = unrestricted. The tiers stack (AND) — a request must pass both. The global tier is evaluated first, before location matching (so it gates paths that match no location); the per-location tier right after the location matches, so it applies to proxy, static, and response locations alike. Denial at either → configurable 403.
- ⚙️ Config: top-level
whitelist/blacklist(global);locations[].whitelist/blacklist(per-location);forbiddenresponse. - 🧩 SDK:
AccessController(defaultIPAccessControl) viap.Access(global) /loc.Access(per-location) — e.g. anX-Forwarded-For-aware, token, or geo check.
max_connections (a concurrent in-flight cap) is enforced independently and nested at project / location / backend scopes via counting semaphores. Over-capacity behavior is configurable.
- ⚙️ Config:
max_connections(top-level / backend / location);overflow=strategy(reject|queue|reroute),queue_timeout, plus a configurable reject response (status,headers, variable-capablebody). - 🧩 SDK:
p.MaxInFlight; a capacity-awareBackendSelector(runnableexamples/least-loaded); a customActorfor full over-capacity control.
Upstream and client-facing timeouts, plus keep-alive pool tuning. Durations are plain integer seconds in JSON.
- ⚙️ Config:
timeouts(request,tls_handshake) at project + per-backend;transport(max_idle_conns,max_idle_conns_per_host,idle_conn_timeout);server(read_header_timeout,read_timeout,write_timeout,idle_timeout). - 🧩 SDK:
p.Transportglobalhttp.RoundTripperoverride; per-request deadline via the Actor.
An abstract generator produces status + headers + body with $variable substitution. It powers the type: "response" location and every built-in error response, each with a sensible default and individually overridable.
- ⚙️ Config:
locations[].response; top-levelbackend_error(502),not_found(404),method_not_allowed(405),forbidden(403), and theoverflowreject. - 🧩 SDK:
ResponseGenerator(defaultTemplateResponder) vialoc.Responder, andp.NotFound/p.BadGateway/p.MethodNotAllowed/p.Forbidden.
nginx-style $name / ${name} placeholders resolved from the request snapshot: $remote_addr, $remote_port, $host, $scheme, $request_method, $request_uri, $uri, $args / $query_string, $http_* (any request header), $time_iso8601, $time_unix. Validated at startup (an unknown variable fails fast).
- ⚙️ Config: usable in
set_headers,set_response_headers,response/error bodies and headers, and log{var.NAME}fields. - 🧩 SDK: custom appliers / responders can compute arbitrary values.
Sets headers on the request before forwarding, with variables. Global and location set_headers stack (location wins on a conflict, other globals retained); Host is special-cased.
- ⚙️ Config:
set_headers(top-level + per-location). - 🧩 SDK:
HeaderApplier(defaultTemplateHeaderSetter) viap.Headers/loc.Headers(runnableexamples/request-id).
The response-side mirror: sets headers on the client response (proxied, static, generated, and error responses), with the same variables, Set/override + global/location stacking. Streaming- and WebSocket-safe (the lazy writer forwards Flush/Hijack).
- ⚙️ Config:
set_response_headers(top-level + per-location). - 🧩 SDK:
ResponseHeaderApplier(defaultTemplateResponseHeaderSetter) viap.ResponseHeaders/loc.ResponseHeaders.
Optional structured access logging with a {field} / {group.param} format, compiled and validated at startup. Global and location loggers both fire; request/response bodies are buffered only when the format references them.
- ⚙️ Config:
logging(format,outputs: console/file,file) at top-level + per-location; timing fields (request_duration,app_duration, and more). - 🧩 SDK:
Logger(defaultFormatLogger) viap.Logger/loc.Logger— emit JSON, metrics, or spans (NeedsRequestBody/NeedsResponseBodyto skip buffering).
In-process atomic Proxy swap. Graceful: in-flight requests finish on the old config, new requests use the new one. Force: in-flight requests are cancelled (best-effort 503). An invalid new config is rejected and the running config keeps serving (fail-safe). The listen address and server timeouts need a full restart; everything else reloads live.
- ⚙️ Config / CLI:
switchyard reload [--force](via the pid file, defaultswitchyard.pid;-pidfileto relocate), orSIGHUP/SIGUSR2;make reload/make force-reload. - 🧩 SDK:
Server{Addr, PidFile, Build}withRun()/Start()/Reload(force); plusSignalReloadandReadPidFile.Buildis re-invoked on every reload so SDK overrides re-apply.
The entire config is validated before serving (a bad URL, regex, static root, variable, duration, or negative limit exits immediately). On SIGINT / SIGTERM the server stops accepting and drains in-flight requests (15s) before exiting, and removes the pid file.
- ⚙️ Config: implicit (every field is validated).
- 🧩 SDK:
Actor(defaultDefaultActor) for response rewriting; mount viaHandler()in your own server and callsrv.Shutdownyourself.
Retries a failed forward on another backend. Three triggers: a connection error (backend down — any method), an upstream status in a configurable list (idempotent methods only unless opted in), and a backend flagged unhealthy (excluded from selection). Retries happen only before any byte reaches the client, so streaming and WebSocket upgrades are unaffected. Reselection continues through the location's normal selector (the failed backend rotates to the back of the round-robin and may be reselected by default; retry_same_backend: false forces distinct backends). Backoff is none / constant / exponential with optional full jitter. On exhaustion the real final upstream response passes through (or connection exhaustion renders backend_error 502), unless a retry.response is configured. Body replay is bounded by max_body_bytes. Global + per-location, field-merged (each set field wins, unset inherits).
- ⚙️ Config:
retry(top-level + per-location):attempts,on_connection_error,on_status,retry_non_idempotent,retry_same_backend,skip_unhealthy,max_body_bytes,backoff(strategy,base_ms,max_ms,jitter),response. - 🧩 SDK: built into
DefaultActor(default behavior; overridep.Actorto replace). Backend health hookBackend.SetHealthy(bool)/Backend.Healthy(); aretrieslog field records retries performed. No new pluggable stage.
Two per-backend detectors flip the health flag that retry's skip_unhealthy acts on. Passive ejects a backend when it returns ≥ count failures (a status in the configured list, or a connection error) within a sliding window. Active probes a health endpoint on an interval; a cycle passes iff it returns expected_status (after retries immediate retries), and unhealthy_threshold / healthy_threshold consecutive cycles flip the flag. Recovery: when an active check is configured it is the sole authority on recovery; otherwise passive restores the backend after a cooldown (half-open). Every transition is logged. Health state is in-memory and resets on reload (like the round-robin counter). Global defaults + per-backend, field-merged.
- ⚙️ Config:
health(top-level default + per-backend):passive(statuses,count,window,cooldown) andactive(path,method,interval,timeout,expected_status,retries,unhealthy_threshold,healthy_threshold,host). - 🧩 SDK:
Backend.SetHealthy(bool)/Backend.Healthy()drive/read the flag from custom logic;Proxy.StartHealthChecks(ctx)launches active probers (auto-called byServerper generation; rawHandler()users call it themselves). No new pluggable stage — health feeds the existing selection skip.
Throttles requests by a composite key (any of client IP, a header, method, path) using a token bucket (rate per period, burst capacity). A distinct axis from the concurrency cap (max_connections) — requests-per-time, not in-flight count. Two tiers, global (checked before routing, so it guards 404s too) and per-location, both enforced (AND). Over-limit → 429 with Retry-After; the draft RateLimit-Limit/Remaining/Reset headers are emitted per a configurable mode (off / on-reject / always). Both the algorithm and the storage are independently SDK-swappable behind uniform interfaces; the default store is in-memory (no external dependency) and resets on reload.
- ⚙️ Config:
rate_limit(top-level global tier + per-location):key(ip/header:<NAME>/method/path),rate,period,burst,methods,headers, and the reject response (status,response_headers,body). - 🧩 SDK:
RateLimiter(algorithm, defaultTokenBucketLimiter) viap.RateLimiter;RateLimitStore(storage, default in-memoryNewMemoryRateLimitStore) viap.RateLimitStore— the same interface backs Redis/memcached/etc.
| Stage | Interface | Default | Config | Set via |
|---|---|---|---|---|
| Decide (routing) | Decider |
DefaultDecider |
— | p.Decider |
| Act (side effects) | Actor |
DefaultActor |
— | p.Actor |
| Location detection | Router |
DefaultRouter |
locations |
p.Router |
| Backend selection | BackendSelector |
RoundRobinSelector |
(per pool) | p.Selector, loc.Selector |
| Backend pool | BackendPool |
StaticPool |
backends |
p.Pool, loc.Pool |
| Request headers | HeaderApplier |
TemplateHeaderSetter |
set_headers |
p.Headers, loc.Headers |
| Response headers | ResponseHeaderApplier |
TemplateResponseHeaderSetter |
set_response_headers |
p.ResponseHeaders, loc.ResponseHeaders |
| Static serving | StaticServer |
FileServer |
type: "static", root |
loc.Static |
| Access control | AccessController |
IPAccessControl |
whitelist / blacklist |
p.Access, loc.Access |
| Response generation | ResponseGenerator |
TemplateResponder |
response, error responses |
loc.Responder, p.NotFound / BadGateway / MethodNotAllowed / Forbidden |
| Logging | Logger |
FormatLogger |
logging |
p.Logger, loc.Logger |
| Rate-limit algorithm | RateLimiter |
TokenBucketLimiter |
rate_limit |
p.RateLimiter |
| Rate-limit storage | RateLimitStore |
in-memory | rate_limit |
p.RateLimitStore |
Two ways to run, one codebase: the config-only turnkey binary (./Switchyard -config switchyard.json) and the SDK (import the package, override any stage, compile your own binary). See architecture.md and extending.md.