Module plugin_dispatch

Module plugin_dispatch 

Source
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:

  1. builds the per-request RequestPlugins — a fail-closed health gate over the generation’s PluginLifecycle state, one per-request WASM instantiation through the lifecycle’s runner (wasm::runner::PluginRunner), and the unified PluginChain (native filters + WASM entries in config order, dispatched through one WasmChainAdapter);
  2. drives the chain at the four documented phase points (request_headers after route resolution before authn; request_body after authz/rate-limit before the action; response_headers after the response arrives before masking; response_body after masking before the operator transforms and compression);
  3. runs on_done cleanup on EVERY exit path via Drop.

§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_response is 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 :path is 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 forwarded Host header only (see PluginForwardHost); 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:

  1. the chain reports ChainOutcome::CalloutPending and stops at the paused entry;
  2. RequestPlugins::resolve_callouts performs each pending exchange on spawn_blocking around the synchronous callout client (wasm::callout), records dwara_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;
  3. 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§

PluginForwardHost
The Host override a plugin’s :authority write produced. Rides the request extensions from the forward build to the upstream dispatch (upstream::UpstreamHandle owns the forwarded Host): 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 the Host header, never which origin the gateway dials).
PluginStatusEntry
One plugin’s status entry (DW-158): what the admin endpoint, the CLI, and the per-state gauge are all built from.
PluginTargetRewrite
A chain’s rewrite of the forwarded request target, captured at the request_headers phase ([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_request removes it there — after the route’s own rewrite and the query transforms, with no route re-match; see the module’s “Header conventions” section). Default is the no-rewrite case (no pseudo-header changed); is_noop lets the caller skip the extension entirely on that fast path.
RequestPlugins
The per-request plugin execution state (DW-157): the unified chain plus the fail-closed helpers that drive it. Constructed by RequestPlugins::build only on routes that reference plugins; Drop runs the on_done cleanup callback on every exit path (early short-circuits, failures, and the natural tail alike).

Enums§

PluginStatusState
A plugin’s observable lifecycle state for the status surface (GET /plugins, dwara-cli status, dwara_plugin_total{state}). The three lifecycle states come from PluginHealth; 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_bytes when configured, else PLUGIN_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 is dry_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 the NativeRegistry (native names). The dataplane serves this through DataPlane::plugin_statuses (the admin GET /plugins handler) and folds it into dwara_plugin_total{state} at publish time — the same enumeration, so the endpoint, the CLI, and the metric can never disagree.

Type Aliases§

PluginExit
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 clippy result_large_err gate holds the hot-path Results small.