Expand description
Reverse-proxy dataplane (DW-009, feature analysis sections 4.1 and 9.2).
Per request: snapshot lookup, route resolution (path via the compiled
RouteTable, then the route’s non-path criteria: host, methods,
headers, query, cookies), and the route action:
proxy: strip hop-by-hop headers, rebuildHostto the upstream authority (upstreamaddress:port, NOT the inbound host — v1 choice: the gateway, not the client, names the origin it dials), injectX-Forwarded-For/X-Real-IP, and stream the request body through the pooled upstream client frame-by-frame. The upstream response (Incoming) is forwarded untouched after hop-by-hop stripping. ZERO default buffering: no code on this path collects a body. Backpressure is hyper’s natural frame-based flow — the gateway never spawns unbounded buffering tasks.redirect: 3xx with aLocationbuilt from the configured scheme/host/path; when nopathis configured the inbound path AND query are preserved verbatim (v1 semantics).respond: fixed status/body straight from config.- no route / criteria miss: 404 via the unified error envelope (v1 does not model 405; a method or host mismatch reads as “no route for this request”).
Forwarded-header semantics (chosen and frozen here):
X-Forwarded-For: if the direct connection peer is insidegateway.trusted_proxies, the inbound XFF value is preserved and the peer appended ("<inbound>, <peer>"). Otherwise (including the empty default list — trust nobody) the inbound XFF is DISCARDED and replaced with exactly the peer. A spoofed chain from an untrusted peer never reaches the upstream.X-Real-IP: always the direct peer. When the peer is a trusted proxy this equals “the last trusted hop”; when it is the client itself it equals the client. One rule, no configuration.
Protocol upgrades (generic 101 tunneling, not WebSocket-specific): a
request carrying an Upgrade header on HTTP/1.1 is forwarded with its
Upgrade/Connection headers intact; a 101 upstream response is
relayed to the client and both connections are upgraded
(hyper::upgrade::on both sides) and spliced with
tokio::io::copy_bidirectional until EOF. HTTP/2 clients cannot tunnel
upgrades the HTTP/1.1 way: an Upgrade request received over h2/h2c is
answered 501 Not Implemented with a clear message (extended CONNECT is
out of scope for v1).
Upstream error classification (details are logged server-side, never leaked to the client): connect timeout / per-attempt read timeout -> 504; endpoint refused / pool failure / no endpoints -> 502; invalid TLS host -> 500. A mid-body upstream abort (connection torn down partway through a response body, or the DW-014 body-idle timeout firing) is NOT retryable — the attempt was final once its headers resolved — and any frames already forwarded to the client end abruptly (HTTP/1.1 truncation semantics, no synthesized tail). The abort IS reported as a passive-health failure for the picked endpoint (DW-014 closing the DW-012 gap), so chronically dying streams eject the endpoint.
Retries, timeouts, budgets (DW-014): per-upstream retries config
drives a bounded retry loop around the send — per attempt the balancer
re-picks the endpoint (health ejection applies naturally), the
per-attempt read_ms deadline is enforced by the upstream handle, and
backoff is exponential with full jitter. Idempotency:
GET/HEAD/OPTIONS/TRACE/PUT are retry-eligible by method; POST is
retried ONLY when the upstream sets retries.retry_post (the opt-in
governs POST exclusively — DELETE, PATCH, and every other
non-idempotent method are never retried). A request
body is replayed only when it was buffered within
retries.buffer_max_bytes (opt-in; the default proxy path never
buffers — zero-copy streaming is preserved byte-for-byte when retries
are off). Every retry is charged against the upstream’s rolling-window
retry budget; exhaustion fails requests through to the client.
Authorization (DW-020): immediately after authN (and before rate
limiting), the request runs through the authz module’s precedence
chain — consumer > route > service > listener > global
(authz::AuthzChain; every link has a config attachment:
consumers[].authorization, routes[].authorization,
services[].authorization, listeners[].authorization, and the
gateway-level authorization) — consumer/group/scope/claim rules
against the authenticated identity, IP ACLs against the EFFECTIVE
client IP (XFF-resolved behind a trusted proxy, see DW-009). A deny
at ANY level wins; otherwise the most specific level with rules
governs. Denials answer 403 (generic body, reason logged server-side
only); identity rules imply authentication, so an anonymous caller
gets 401. An ip_acl-only block is the one authorization shape that
can admit anonymous traffic.
Local rate limiting (DW-017): after route resolution (and its
criteria checks) but BEFORE cap admission, the request runs through
the rate-limit engine (RateLimitEngine, built from the generation’s
policies). Ordering rationale: rejecting early with a 429 is the
cheapest thing the gateway can do with a request, so it precedes the
more expensive admission layers; a 429 is emitted before any permit is
acquired, so rate-limited requests never hold a cap slot (though the
connection itself is held for the length of the response — accepted).
A denied request answers 429 with Retry-After (whole seconds,
rounded up, minimum 1) and X-RateLimit-Limit / -Remaining /
-Reset headers from the BINDING constraint (the window that denied;
see the rate_limiter module docs for stacked-window semantics).
Admitted requests whose policies matched carry the same three
X-RateLimit-* headers on their final response (only when a policy
actually applied — a no-match request carries no rate headers).
X-RateLimit-Reset is Unix epoch seconds of the binding window’s
estimated full replenishment. Policy resolution follows the frozen
precedence chain consumer > route > service > listener > global:
every level with an attachment applies and the applicable rules
AND together; the resolution order binds the 429 headers (the first
denying rule wins them — see the rate_limiter module docs). UNROUTED
traffic (no route / criteria miss) is NOT exempt: the listener and
global links apply to it before the 404 is answered (rate limiting
at minimum; the consumer/route/service links are unknowable before
routing, and authentication sits after route resolution in the
documented order), so 404 floods cannot bypass the limiter. The
reserved paths (/healthz, /readyz, /metrics) stay exempt. When
several rules deny, Retry-After is the maximum wait across denying
rules while the Limit/Remaining headers come from the first binding
one (see the rate_limiter module docs for multi-rule denial
semantics). When rate headers are applied the GATEWAY is the source
of truth for X-RateLimit-*: any upstream values are silently
replaced.
Consumer request budgets (DW-033): a quota-configured consumer’s
requests additionally run a budget check AFTER rate limiting and
BEFORE cap admission — an in-memory GCRA 429 is cheaper than a
store-backed one, and a refused request never holds a concurrency
slot. A budget 429 reuses the same builder (Retry-After +
X-RateLimit-* from the binding budget; reset is the UTC window
boundary), but budget headers appear on DENIALS only: admitted
responses’ X-RateLimit-* family belongs to the rate limiter when
it applies (two mechanisms racing to write the same header names on
every success would be noise, not information). Budgets apply to
authenticated CONFIG consumers exclusively (anonymous traffic and
store-managed consumers have none); counters live in the state
store, so quota config without DWARA_STATE_DB is inert (warned
once). Usage is metered through dwara_quota_* metrics, the
admin GET /quotas/usage endpoint, the analytics store’s
per-consumer axis, and the quota_near_limit event (edge-triggered
once per budget per window at 80%). See state::quotas for the
window and evaluation semantics.
Observability (DW-021): every request opens a root request span
(request id, method, path WITHOUT the query string, consumer, route,
listener) with child spans per phase — authn, authz, ratelimit,
quota, admission, and one upstream_attempt per send (the balancer’s pick
runs inside it as upstream_pick). On completion the wrapper
records requests_total/request_duration_seconds, echoes
X-Request-Id (a valid inbound value respected, printable ASCII up
to 128 bytes; anything else replaced), and emits the sampled
dwara::access log line. /metrics is reserved on every HTTP(S)
listener like /healthz (it shadows configured routes). All
gateway-generated error bodies — including the reserved health
endpoints — are the JSON envelope {"error":{code,message, request_id}} with classification-only messages (upstream internals
never leak). See the observability module for the full surface.
Load shedding by priority (DW-016): the gateway concurrency cap admits
requests ROUTE-AWARE — route resolution happens BEFORE cap admission
(so the request’s priority class is known; 404s therefore never consume
cap slots, a deliberate change from DW-015’s admit-at-entry ordering).
Priority classes are 0 (lowest) to 10 (highest); 5 is the default. The
design is a RESERVED BUCKET, not preemption — HTTP requests already in
flight cannot be displaced, so under saturation the gateway sheds
lower-priority ADMISSIONS first: requests at high_priority (>= 8) may
draw from a reserved sub-allowance of the cap (10% of the cap, minimum
one permit) that lower-priority traffic cannot use. Normal traffic is
shed (503 “gateway saturated”) as soon as the general allowance fills;
high-priority traffic survives until the reserved bucket fills too.
The bucket is carved only when a high-priority ROUTE is configured
(priority >= 8; consumer priorities are inert until authN — DW-019 —
and never trigger carving), so priority-free configs behave
byte-identically to DW-015 (full cap for everyone, same shed
response). Sheds are 503, not 429 — 429 is reserved for rate limiting
(DW-017); no Retry-After is set (immediate re-dispatch under a
saturated gateway is not advised) and no shed marker header is added
(the response stays a plain 503 status + the error envelope). Every admission and shed
is counted per priority class in atomics on the dataplane
(DataPlane::priority_counters; metrics exposure is DW-021).
Circuit breaking and caps (DW-015): three independent admission
layers wrap the send path. (1) The per-upstream BREAKER
(upstreams[].breaker, see breaker) is checked BEFORE the endpoint
pick and before every (re)attempt — an open upstream answers 503
“upstream circuit open” with a Retry-After of the seconds until
half-open; in-flight requests complete normally, and the breaker is a
layer ABOVE endpoint ejection (a fail-open pick still flows through
it; a breaker-open period ejects nothing, since health sees no
traffic). (2) The per-upstream PENDING cap (upstreams[].max_pending)
rejects requests that would have to WAIT for an outbound connection
slot: 503 “upstream saturated”, immediately, no queueing (the default
is the DW-008 queue-forever behavior). (3) The GATEWAY concurrency cap
(gateway.max_concurrent_requests) admits requests at handle()
entry — after the reserved /healthz//readyz paths, so probes
answer under saturation — rejecting over-cap requests with 503
“gateway saturated” immediately; a slot is released when the response
body completes (or the client connection drops).
Upgrade forwarding on non-tunnel requests: an Upgrade header arriving
on an ordinary proxied request (no 101 ever comes back) is forwarded
upstream, together with its Connection tokens, rather than stripped.
This is deliberate: the upstream, not the gateway, decides protocol
switches — stripping Upgrade would break legitimate upgrades (h2c,
WebSocket handshakes that begin as a normal request) the moment the
upstream wanted to accept one. RFC-strict proxies that strip
connection-oriented headers wholesale would reject this; we chose
upgrade transparency, and the behavior is pinned by the coverage suite.
Maintenance mode (DW-041): a route carrying a maintenance block is
answered by the GATEWAY, never the route action — a 503 carrying
Retry-After and the JSON envelope (maintenance code,
operator-optional message).
The check runs IMMEDIATELY after route resolution, BEFORE the route’s
request limits: maintenance is a statement about the route’s
availability, not about any request’s shape, so every matched request
gets the same answer (an over-limit request is told “we’re down”, not
“your headers are too big” — fixing the headers would still leave it
refused) and the gateway skips evaluating limits for a request it will
refuse anyway. Preflights are the one exemption: a CORS preflight on
a CORS-configured route still answers 204 (the preflight is a Fetch
handshake about the gateway’s own cross-origin policy, sent without
credentials; failing it surfaces in the browser as an opaque CORS
error and hides the 503 the operator wants clients to see on the
actual request — which DOES get 503, carrying the policy’s
actual-response CORS headers so browser clients can read the
envelope). Reserved paths answer before route resolution and are
unaffected: probes and scrapes keep working through maintenance.
Toggled per generation by config reload; unrouted traffic is
unaffected (404 stays 404).
Policy dry run (DW-041, monitor mode): every policy phase that can
reject supports a per-attachment dry_run flag — route limits
(routes[].limits.dry_run, 413/431), authorization
(authorization.dry_run at any of the five levels, 401/403), rate
limiting (policies[].dry_run, 429), and load shedding
(gateway.load_shed_dry_run, 503). A dry phase still EVALUATES, but
on a would-reject it logs one structured dwara::policy warn event
(phase, would-be status, reason, route, consumer, request id),
increments dwara_policy_dry_run_total{phase,route}, and lets the
request PROCEED. The invariant throughout: dry run never makes
enforcement more permissive — a LIVE deny always enforces (the authz
resolver walks past a dry deny and stops only at a live one; live
rate-limit bundles 429 regardless of dry bundles on the same
request). Dry rate-limit rules contribute no X-RateLimit-* headers;
a dry route-limits block leaves the streaming body guard unarmed
(only the cheap up-front checks are observable). Authentication
(401 on invalid/missing credentials, auth_required) is identity
verification, not a policy phase, and has no dry-run flag. The
metric + log events ARE the dry-run report (§9.3): no endpoint, no
buffer — scrape the counter and grep dwara::policy.
Structs§
- Counting
Body - A response body wrapper that counts outbound bytes as they stream
to the client (PERF-12, #209), WITHOUT buffering. Each data frame
that passes through
poll_frameincrements the sharedArc<AtomicU64>(the same one inAccessRecord::bytes_out). - Data
Plane - The proxy dataplane: reads config generations from
ConfigStateand keeps anUpstreamRegistrycoupled to each generation. - Priority
Counters - Per-priority-class admission/shed counters (DW-016). Plain atomics on the dataplane — they survive config reloads (unlike the cap semaphores) and are never reset. Exposure through the admin/metrics surface is DW-021; tests read them directly.
- Store
Event Durability - REL-14 (#249): concrete
EventDurabilitybacked by the SQLiteStateStore. The dataplane owns both the store and the event bus, so it provides this adapter to the events module’s trait. The implementation is best-effort: a SQLite error is logged but does not block the emit path (the event still goes to the in-memory channel).
Enums§
- Proxy
Body - Body type of every proxied/gateway-generated response: a small
fully-buffered gateway message (
Full), the untouched streaming upstream body (UpstreamBody: the pooled stream wrapped with the DW-014 write-timeout / mid-body health-report knobs), a route- compressed stream (crate::dataplane::compression::CompressedBody, DW-027 — the codec wrapper around either of the other two), or an over-cap cache passthrough (crate::dataplane::response_cache::PassthroughBody, DW-037 — the buffered prefix followed by the untouched remainder). - Proxy
Body Error - Error of a
ProxyBody: upstream stream failure or a compression codec failure (DW-027). TheFullvariant is infallible.
Constants§
- DEFAULT_
PRIORITY - The default priority class (DW-016): requests on routes without an
explicit
priorityshed and are shed as class 5. - HIGH_
PRIORITY - The priority class at or above which a request may draw from the gateway cap’s reserved sub-allowance (DW-016).
Functions§
- apply_
path_ rewrite - Apply a proxy action’s path rewrite (DW-010).
paramsare the{param}captures of the exact-template match for this request (empty for regex/prefix routes); they are available toregexsubstitutions as$namereferences that miss the pattern’s own capture groups. Returns the rewritten path; a rewrite that does not apply (e.g.strip_prefixon a path that does not start with the prefix) leaves the path unchanged. The query string is NOT touched here — the caller re-attaches it. Public for the router golden-file tests. - expand_
substitution - Expand
$1/${name}references against a regex match. Lookup order for a name: numeric -> pattern capture group by index; otherwise -> pattern named group, then the route’s{param}path capture; unknown references expand to the empty string. A lone$(end of string, or followed by a non-identifier character) is kept literally. - grpc_
request - Whether a request is gRPC (DW-039): HTTP/2 with an
application/grpccontent type (the spec’s family prefix —application/grpc+proto,+json, … all match). - handle
- Handle one request against the current generation. Never panics; every
failure path is a classified response. Generic over the request body so
tests and alternative frontends can drive it with any streaming body.
Takes the dataplane as an
Arcreference: the response cache’s stale-while-revalidate path (DW-037) spawns background revalidations that need the dataplane to outlive the request. - parse_
cookies - Parse a
Cookierequest header into (name, value) pairs. Simple RFC-6265-shaped parsing: split on;, trim spaces, split each pair on the FIRST=. No quoting/encoding handling in v1 — values are matched exactly as sent. Public for the router golden-file tests. - parse_
grpc_ timeout - Parse a
grpc-timeoutvalue (DW-039): 1..=8 ASCII decimal digits plus a unit — Hours, Minutes, Seconds, milliseconds, microseconds, nanoseconds (RFCSnHgrammar, case-exact per the spec). Values that overflow the duration space saturate at one day (a longer RPC budget than that is un-enforceable by this gateway anyway); garbage isNone(the caller treats absence as no deadline). - refresh_
quota_ gauges - Refresh the quota observation gauges at scrape time (DW-033): the
current-window usage and the configured cap of every
quota-configured consumer’s every budget, read from the state
store (the same
current_usagewalk the admin/quotas/usageendpoint and the near-limit trigger use). Series cardinality is config-bounded (quota consumers x the closed daily/monthly set). A consumer REMOVED from the config by a reload keeps its last series values until restart — the accepted staleness trade of the scrape-time snapshot model (the rate-limiter gauges carry the same caveat); the used/limit ratio of a stale pair stays correct because both series freeze together. No store (quotas inert) or a store error skips the refresh without failing the scrape. - refresh_
rate_ limiter_ gauges - Refresh the rate-limiter observation gauges from the CURRENT
engine at scrape time (#132) — the same snapshot model as
refresh_observation_gauges: the walk lives on the dataplane (the extensions-side engine must stay free of observability imports), the hot check path stays pure atomics with zero metrics coupling, and the observability side only records what it is handed. Both gauges are aggregate and unlabeled — cardinality is never per key. The eviction figure resets when a reload rebuilds the engine (documented family caveat). - resolve_
priority - Resolve a request’s load-shedding priority class (DW-016). This is the
documented PRIORITY-RESOLVER seam: consumer (once known) overrides the
route, the route overrides the default. Today authentication is not
wired (DW-019/DW-020), so the caller passes
Noneand priority is purely route-level; once authN identifies the consumer, passSome(consumer)here and itsprioritywins over the route’s — no other change to the shedding path is needed. - route_
applies - Apply the route’s non-path criteria. All criteria are AND-ed. Empty
method list = all methods; host matches the
Hostheader (case-insensitive, with or without a port); headers must all be present with exact values; query and cookie entries match on presence, or on exact value when one is configured.acceptis the route’s COMPILEDmatch.acceptmedia type (RouteTable::accept_media_type), never the raw config string — padding and case are normalized once at snapshot compile, so a padded spelling matches exactly like its trimmed form. Public so router golden-file tests (tests/router_golden.rs) can exercise the full resolution pipeline without a live upstream. - strip_
hop_ by_ hop - Header names always treated as hop-by-hop per RFC 7230 section 6.1
(plus the de-facto
Proxy-Connection), and any name listed in ANYConnectionheader (RFC 7230 allows multiple; onlyget_allsees them all).TEis dropped — EXCEPT for a gRPC request (DW-039), whose spec-mandatedTE: trailersrides through so conformant gRPC servers see the client’s dialect (h2 carries trailers natively; the header is the courtesy contract).Upgrade(and itsConnectiontoken) survives only when the request is being tunneled.