Module proxy

Module proxy 

Source
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, rebuild Host to the upstream authority (upstream address:port, NOT the inbound host — v1 choice: the gateway, not the client, names the origin it dials), inject X-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 a Location built from the configured scheme/host/path; when no path is 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 inside gateway.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§

CountingBody
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_frame increments the shared Arc<AtomicU64> (the same one in AccessRecord::bytes_out).
DataPlane
The proxy dataplane: reads config generations from ConfigState and keeps an UpstreamRegistry coupled to each generation.
PriorityCounters
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.
StoreEventDurability
REL-14 (#249): concrete EventDurability backed by the SQLite StateStore. 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§

ProxyBody
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).
ProxyBodyError
Error of a ProxyBody: upstream stream failure or a compression codec failure (DW-027). The Full variant is infallible.

Constants§

DEFAULT_PRIORITY
The default priority class (DW-016): requests on routes without an explicit priority shed 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). params are the {param} captures of the exact-template match for this request (empty for regex/prefix routes); they are available to regex substitutions as $name references that miss the pattern’s own capture groups. Returns the rewritten path; a rewrite that does not apply (e.g. strip_prefix on 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/grpc content 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 Arc reference: 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 Cookie request 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-timeout value (DW-039): 1..=8 ASCII decimal digits plus a unit — Hours, Minutes, Seconds, milliseconds, microseconds, nanoseconds (RFC SnH grammar, 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 is None (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_usage walk the admin /quotas/usage endpoint 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 None and priority is purely route-level; once authN identifies the consumer, pass Some(consumer) here and its priority wins 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 Host header (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. accept is the route’s COMPILED match.accept media 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 ANY Connection header (RFC 7230 allows multiple; only get_all sees them all). TE is dropped — EXCEPT for a gRPC request (DW-039), whose spec-mandated TE: trailers rides through so conformant gRPC servers see the client’s dialect (h2 carries trailers natively; the header is the courtesy contract). Upgrade (and its Connection token) survives only when the request is being tunneled.