Expand description
Request-path plugin dispatch (DW-157, epic #274).
This module is the wiring between the config’s plugins/route
plugins lists and the live request path. For every routed request
whose route references plugins, it:
- builds the per-request
RequestPlugins— a fail-closed health gate over the generation’sPluginLifecyclestate, one per-request WASM instantiation through the lifecycle’s runner (wasm::runner::PluginRunner), and the unifiedPluginChain(native filters + WASM entries in config order, dispatched through oneWasmChainAdapter); - drives the chain at the four documented phase points
(
request_headersafter route resolution before authn;request_bodyafter authz/rate-limit before the action;response_headersafter the response arrives before masking;response_bodyafter masking before the operator transforms and compression); - runs
on_donecleanup on EVERY exit path viaDrop.
§Fast path discipline
A route with an empty plugins list builds nothing:
RequestPlugins::build returns Ok(None) before any allocation,
and handle_routed skips every phase call — the no-plugin request
path is byte-identical to the pre-DW-157 path.
§Body policy
Header phases always run. Body phases require a buffered body: a
route whose chain declares request_body (or response_body) has
that body buffered up to the route’s body cap
(plugin_body_cap — routes[].limits.max_body_bytes, default
PLUGIN_BODY_CAP_DEFAULT); an over-cap body answers 500
plugin_body_too_large (fail-closed: a plugin that declared a body
phase must see the body or the request does not proceed). Bodies on
routes without body-phase plugins stay streamed — zero buffering,
zero cost. The response side rides the same buffering masking
already establishes when masking ran; otherwise it buffers the same
way. One documented exception: a STREAMING response body
(text/event-stream, or a body with no declared length — chunked
or open-ended) or a CONTENT-ENCODED response body skips the
response_body phase (logged once, plugin_response_body_skipped):
buffering an endless stream would stall the route, an encoded body
is opaque bytes to the plugin, and the gateway never decompresses on
this path (its own compression runs later, after the phase).
§Failure semantics
Everything fails closed, on the AFFECTED route only:
- a Crashed/Disabled/not-loaded plugin, a native name missing from
the registry, a native factory that errors while the chain is
built, or a failed per-request instantiation answers 500
plugin_unavailable; - a WASM trap (fuel/epoch/memory) or native filter error answers
500
plugin_failed; - an over-cap body answers 500
plugin_body_too_large; - a plugin’s
send_http_responseis honored verbatim (status, headers, body) and the upstream is never dialed.
Every failure increments dwara_plugin_failures_total{name,reason}
and logs one server-side event naming the plugin; a plugin answering
the request (short-circuit OR failure) sets the access log’s
plugin_short_circuit flag.
§Header conventions
The proxy-wasm convention exposes HTTP framing as pseudo-headers.
Matching the host/runner tests (tests/wasm_host.rs), the request
header map carries :method and :path (path INCLUDING the query
string) ahead of the real headers (host included verbatim); the
response map carries :status. The write-back of ORDINARY headers
applies ONLY the names the chain changed (apply_header_changes):
headers no plugin touched keep their original bytes, non-UTF-8
(obs-text) values included. Pseudo-headers never enter the ordinary
map — the three target pseudo-headers a chain may rewrite are
captured separately ([pseudo_header_rewrite]) and applied when
the forwarded request is built:
:path— the FINAL upstream target, origin-form (/...), query string included (a written path without a query forwards without one). It composes with the route’s own rewrite: the route rewrite applies first, the plugin’s:pathis the final say, and routes are never re-matched. Only the upstream sees the rewritten target — route matching, authn/authz, the cache key, anomaly scoring, and the access log all evaluated the ORIGINAL target and keep it.:method— a valid method token; replaces the forwarded method.:authority— overrides the forwardedHostheader only (seePluginForwardHost); the dialed endpoint stays the balancer’s pick.
Only CHANGED values apply: writing back the value the map carried
has zero effect, and REMOVING a pseudo-header is ignored (the
request keeps its real method/path). An invalid changed value
(empty, non-/-prefixed, or unparseable :path; a non-token
:method; an unparseable :authority) fails closed: 500
plugin_failed, metric reason invalid_rewrite, upstream never
dialed.
:status writes on the response map stay IGNORED (pipeline-owned)
— a deliberate asymmetry with the request side, documented on
RequestPlugins::response_headers_phase.
§HTTP callouts: pause and resume (DW-167)
A plugin phase callback may dispatch HTTP callouts
(proxy_http_call) and return Pause. The phase method here — the
ASYNC boundary — then drives the exchange, keeping the wasm runner
synchronous:
- the chain reports
ChainOutcome::CalloutPendingand stops at the paused entry; RequestPlugins::resolve_calloutsperforms each pending exchange onspawn_blockingaround the synchronous callout client (wasm::callout), recordsdwara_plugin_callouts_total{name,outcome}(ok, timeout, error), and delivers the response to the instance with a synchronous re-entry (proxy_on_http_call_response); the plugin may dispatch again (bounded rounds) or decide the request. A mid-delivery re-dispatch never abandons the rest of the drained queue: the un-performed remainder is re-enqueued ahead of the new dispatches (dispatch order preserved), so every dispatched callout is eventually performed or the loop guard trips;- on Continue the chain resumes from the entry AFTER the paused one with the plugin’s post-callout payload.
Guardrails (hard caps, no config surface): the plugin’s timeout
clamped to [1ms, 5s]; at most [wasm::callout::MAX_CALLOUT_ROUNDS]
callout rounds per phase per request (exceeding fails closed, 500
plugin_failed, metric reason callout_loop); callout response
bodies capped at 4 MiB; redirects refused; the gateway SSRF egress
filter applied at connect time against the resolved IPs.
Callout failure is a plugin failure (a deliberate deviation
from Envoy, which delivers an empty response callback on callout
failure): a timed-out or refused callout answers 500
plugin_failed (metric reason callout_failed) — the plugin
cannot make a fail-open decision without receiving its decision
input, and dwara’s plugin posture is fail-closed end to end. Any
COMPLETED status is delivered as data (non-2xx included); recipes
wanting fail-open semantics scope them explicitly (short timeouts
plus a non-callout fallback, or an extension trait instead of a
plugin).
Structs§
- Plugin
Forward Host - The
Hostoverride a plugin’s:authoritywrite produced. Rides the request extensions from the forward build to the upstream dispatch (upstream::UpstreamHandleowns the forwardedHost): the header the plugin named replaces the picked endpoint’s authority there, and ONLY there — the dial target and the URI authority stay the balancer’s pick (a plugin shapes theHostheader, never which origin the gateway dials). - Plugin
Status Entry - One plugin’s status entry (DW-158): what the admin endpoint, the CLI, and the per-state gauge are all built from.
- Plugin
Target Rewrite - A chain’s rewrite of the forwarded request target, captured at the
request_headersphase ([pseudo_header_rewrite]) and applied when the upstream request is built: the phase runs early (before authn) but the rewrite shapes the UPSTREAM request only, so it rides the request extensions from the phase to the forward build (proxy::proxy_requestremoves it there — after the route’s own rewrite and the query transforms, with no route re-match; see the module’s “Header conventions” section).Defaultis the no-rewrite case (no pseudo-header changed);is_nooplets the caller skip the extension entirely on that fast path. - Request
Plugins - The per-request plugin execution state (DW-157): the unified chain
plus the fail-closed helpers that drive it. Constructed by
RequestPlugins::buildonly on routes that reference plugins;Dropruns theon_donecleanup callback on every exit path (early short-circuits, failures, and the natural tail alike).
Enums§
- Plugin
Status State - A plugin’s observable lifecycle state for the status surface (
GET /plugins,dwara-cli status,dwara_plugin_total{state}). The three lifecycle states come fromPluginHealth; the two extra states cover plugins that are declared in the config but unusable BEFORE the lifecycle can judge them (the same conditions the request-path gate fails closed on, so the status surface never shows a plugin as serving when its routes answer 500).
Constants§
- PLUGIN_
BODY_ CAP_ DEFAULT - The default body cap for plugin body phases on routes without an
explicit
limits.max_body_bytes(DW-157): 1 MiB, the same bound the nano-service body cap uses. Buffering must be opt-in (the plugin declaring the phase) and size-capped (always) — this is the cap when the route configures none.
Functions§
- plugin_
body_ cap - The cap for plugin body phases (DW-157): the route’s
limits.max_body_byteswhen configured, elsePLUGIN_BODY_CAP_DEFAULT. The same bound governs the request and response sides (it is the only per-route body cap in the config surface). It applies even when the limits block isdry_run— it is a buffering bound for the plugin phase, not the 413 policy. - plugin_
statuses - Enumerate the generation’s declared plugins as status entries
(DW-158), in config order. Health and digest come from the live
PluginLifecycle(WASM local and registry-resolved alike, DW-165) or theNativeRegistry(native names). The dataplane serves this throughDataPlane::plugin_statuses(the adminGET /pluginshandler) and folds it intodwara_plugin_total{state}at publish time — the same enumeration, so the endpoint, the CLI, and the metric can never disagree.
Type Aliases§
- Plugin
Exit - The fail-closed exit a plugin phase hands its caller: the response
the proxy returns immediately (a plugin’s local response or one of
the 500 envelopes). Boxed —
Response<ProxyBody>is large and the clippyresult_large_errgate holds the hot-pathResults small.