Skip to content

Request Lifecycle

Every HTTP request passes through Pingora’s ProxyHttp trait hooks in a fixed sequence. DwaarProxy implements each hook; feature-specific logic runs through the PluginChain. Understanding this pipeline tells you where to look when debugging latency, unexpected responses, or plugin interactions.

Pingora calls new_ctx() before the first hook. Dwaar allocates a RequestContext containing a UUID v7 request ID, a start_time instant, and zeroed PluginCtx. The request ID is stable across all hooks and appears in every log field and in X-Request-Id on both the upstream request and the downstream response.

request_filter does the bulk of per-request work. It returns Ok(true) to short-circuit (respond directly) or Ok(false) to continue to the upstream.

Slow-loris protection — sets the downstream keepalive timeout and body read timeout from config before anything else.

Identity population — extracts client IP, TLS flag, GeoIP country, Host header, method, path, Accept-Encoding, and detects WebSocket (Upgrade: websocket + Connection: upgrade) and gRPC (Content-Type: application/grpc*).

Route lookup — loads the RouteTable via a single lock-free ArcSwap load and resolves the Host header to a Route. Draining routes (removed during hot-reload with in-flight requests) return 502 immediately. For matching routes, the handler blocks are evaluated:

HandlerWhat happens
ReverseProxyupstream SocketAddr cached in ctx.route_upstream
ReverseProxyPoolLB policy selects a backend; max-conns slot acquired (503 if full)
FastCgiupstream addr + FCGI root cached; handled entirely in this hook
FileServerroot + browse flag cached; file served and hook returns
StaticResponsestatus + body cached; response written and hook returns

Path-based config (rewrites, handle_path prefix stripping, map variable evaluation, intercept rules, copy_response_headers, IP filter, body size limits, cache config) is all extracted from the single route load — no second ArcSwap load occurs anywhere in the pipeline (Guardrail #27).

Prometheus connection trackingconnection_start() increments the active-connection gauge for the domain.

Request body size — if Content-Length is known and exceeds the configured limit, returns 413 without reading the body.

Plugin chain (request phase) — calls PluginChain::run_request(), which runs bot detection, rate limiting, IP filtering, and under-attack mode in priority order. If any plugin returns a PluginResponse, the request is short-circuited with that status.

Authentication — basic auth (credential check, bcrypt verify) then forward auth (async subrequest to external service). On denial, returns 401/403. On forward auth allow, copies the auth service’s response headers into ctx.forward_auth_headers for injection in upstream_request_filter.

Internal paths — after all guards pass:

PathAction
/_dwaar/a.jsserve the embedded analytics JS (memory, no disk I/O)
/_dwaar/collect POSTparse and enqueue beacon event; return 204
/.well-known/acme-challenge/<token>serve ACME HTTP-01 key authorization

If none of the above short-circuits, the request proceeds to upstream_peer.

Called by Pingora after request_filter. If ctx.cache_enabled is true (set during route lookup for GET requests matching a cache {} block), attaches the shared MemoryCache backend to the session. Skipped for WebSocket, gRPC, and non-GET requests.

Reads the upstream SocketAddr already cached in ctx.route_upstream. Constructs an HttpPeer with:

  • 10s connection timeout, 30s read/write timeout
  • TCP keepalive probes (60s idle, 10s interval, 3 retries) to detect dead connections without waiting for the full read timeout
  • 60s idle connection eviction (just under nginx’s default 75s keepalive)
  • mTLS client cert and custom CA bundle wired in if configured
  • ALPN::H2 forced for gRPC upstreams
  • For pool-backed routes, TLS settings and SNI come from the matching backend entry

Scale-to-zero — if the pool has a scale_to_zero config, performs a 500ms TCP probe. On failure, calls wake_and_wait() to trigger the container start and blocks until the backend becomes reachable (returns 504 on wake timeout).

upstream_request_filter — Proxy Header Injection

Section titled “upstream_request_filter — Proxy Header Injection”

Runs after the connection to upstream is established but before the request is sent.

Proxy headers added:

HeaderValue
X-Real-IPdirect client IP (written to stack buffer, no heap allocation)
X-Forwarded-Forsame IP — replaces any client-supplied value to prevent spoofing
X-Forwarded-Protohttp or https based on downstream TLS state
X-Request-IdUUID v7 from ctx
traceparentpropagated from client if valid W3C trace context; otherwise freshly generated
tracestaterelayed from client unchanged if present

Headers stripped (hop-by-hop, RFC 7230 §6.1): Proxy-Connection, Proxy-Authenticate, Proxy-Authorization, TE, Trailer, Upgrade (except on WebSocket upgrades).

Security strips: Authorization header removed when Dwaar handled basic auth (prevents credentials reaching upstream logs). Forward auth copy_headers fields are stripped from the client request before the auth service’s values are injected, preventing client header injection (CVE-2026-30851 mitigation).

URI rewrite — if a rewrite rule or handle_path produced ctx.effective_path, the upstream request URI is replaced before the request is sent.

response_filter — Response Header Processing

Section titled “response_filter — Response Header Processing”

Runs on the upstream’s response headers before they reach the client.

Tracing — injects X-Request-Id into the downstream response for client-side correlation.

HTTP/3 advertisement — injects Alt-Svc: h3=":443"; ma=86400 when QUIC is enabled and the route uses reverse_proxy (file server and FastCGI routes are excluded since they are not yet served over H3).

Cache status — writes X-Cache: HIT, X-Cache: MISS, or X-Cache: STALE from ctx.cache_status.

Intercept rules — first matching intercept rule (matched by status code range) can replace the status code, add headers, and queue a body replacement in ctx.intercept_body.

copy_response_headers — strips exclude headers; if include is non-empty, removes all headers not in the include list (preserving essential framing headers).

Response body size pre-check — if Content-Length exceeds the configured limit, immediately rewrites the status to 502 and queues an error body.

Analytics injection setup — for 2xx HTML responses: if the upstream sends a compressed body (Content-Encoding: gzip/br/zstd), installs a Decompressor and removes Content-Encoding so the injected HTML is decompressed inline. Installs an HtmlInjector to append the analytics snippet. Removes Content-Length (body length changes after injection).

Plugin chain (response phase) — calls PluginChain::run_response(). Built-in plugins: security headers (Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy), compression init (decides encoding from Accept-Encoding).

Called for each body chunk streamed from upstream.

Body size accounting — accumulates ctx.response_body_sent; aborts with 502 if the limit is exceeded mid-stream (handles chunked upstreams that don’t declare Content-Length).

5xx error body capture — on upstream 5xx responses, captures up to 1 KB of the body into ctx.upstream_error_body for structured logging. Zero overhead on 2xx/3xx/4xx paths.

Intercept body override — if ctx.intercept_body is set (from response_filter), replaces the chunk and skips all remaining body processing.

Analytics pipeline (in order):

  1. Decompressor::decompress() — inflate gzip/br/zstd if the upstream body was compressed
  2. HtmlInjector::process() — stream-scan for </body> and splice in the analytics <script> tag
  3. PluginChain::run_body() — compression plugin compresses the final body before sending to client

Runs after every request, including errors.

Connection slot release:

  • Decrements route.active_connections (drain counter) — fetch_update with checked_sub prevents u32 underflow
  • Calls pool.release_connection(addr) to return the max-conns slot

Prometheus — calls record_request() with method, status, response time, bytes sent/received; calls connection_end(); increments cache hit/miss counters.

RequestLog construction — builds a zero-copy RequestLog struct:

FieldSource
timestampUtc::now()
request_idUUID v7 from ctx
method, path, querysplit from ctx.plugin_ctx.path (move, no clone)
hostmoved from ctx.plugin_ctx.host
statusfrom session.response_written()
response_time_usctx.start_time.elapsed() in microseconds
client_ip, countryfrom ctx.plugin_ctx
user_agent, refererfrom downstream request headers
bytes_sent, bytes_receivedfrom session.body_bytes_sent/read()
tls_versionfrom session.downstream_session.digest()
http_versionmapped to &'static str (no allocation)
is_botset by BotDetectPlugin in request phase
upstream_addrformatted from ctx.route_upstream
cache_statusHIT/MISS/STALE from ctx.cache_status
trace_idfrom W3C traceparent parsed or generated in upstream_request_filter
upstream_error_bodyfirst 1 KB of 5xx body if captured

The RequestLog is sent via LogSender::send() to the batch writer background service (non-blocking channel send; drops on overflow rather than blocking the proxy thread).

AggEvent — simultaneously sends a lightweight event to the AggregationService containing host, path, status, bytes, client IP, country, and referer for in-memory analytics aggregation.

The QUIC listener does not flow through Pingora’s ProxyHttp hooks — QuicService is a Pingora BackgroundService that terminates QUIC itself via quinn + h3 and bridges each H3 request to an H2 upstream connection (or an H1 upstream via BufferedConn). The H3 bridge shares the same RouteTable and PluginChain as the TCP path, so routing, plugins, and config reloads all behave identically.

Key properties of the H3 path:

  • Streaming both directions. No request or response body is ever fully buffered. Each recv_datasend_data cycle is a refcount bump via Buf::copy_to_bytesBytes::split_to; peak per-chunk allocation is zero.
  • Three independent body deadlines. CHUNK_READ_TIMEOUT = 30s bounds each recv_data wait (slow-loris resistance). BODY_WALL_CLOCK = 5 min bounds the aggregate transfer (tail-latency cap). H2_CAPACITY_WAIT = 30s bounds await_h2_capacity (wedged-upstream detection).
  • Race-free upstream pool. H2ConnPool::get_or_connect is serialized per host so that MAX_CONNS_PER_HOST = 2 is honored even under cold-start bursts. 100 concurrent H3 streams to one upstream share ≤ 2 TCP connections.
  • Bounded h2 backpressure. await_h2_capacity polls poll_capacity until the requested window is actually granted, so send_data never queues ahead of the peer’s flow-control cursor.

File refs: crates/dwaar-core/src/quic/h2_bridge.rs, handler.rs, h2_pool.rs, stream_guard.rs (BodyDeadline, with_chunk_deadline, await_h2_capacity).

PhaseErrorResult
request_filterNo route for Host502 — no upstream configured
request_filterRoute is draining502 — connection close
request_filterContent-Length > limit413 — no body read
request_filterPool at max_conns503 with Retry-After: 1
request_filterPlugin chain blocks requestPlugin-defined status (429, 403, etc.)
request_filterBasic/forward auth denied401/403
request_filterForward auth service error502
request_filterFastCGI error502
upstream_peerScale-to-zero wake timeout504
upstream_peerNo upstream in pool502
upstream_request_filterInvalid rewritten URI500
response_filterUpstream Content-Length > limit502 (rewrites status in place)
response_body_filterAccumulated body > limit502 (aborts upstream connection)
loggingLog channel fullSilently dropped — proxy unaffected

In all error cases logging() still runs: the drain counter and connection slot are always released, and Prometheus metrics are always recorded.

  • Overview — what Dwaar is and its design goals
  • Performance — benchmarks and hot-path analysis
  • Plugins — how to write and configure plugins
  • Crate Map — which crate owns each phase