Filters

Filters are the core processing units in Praxis. Each filter is a small (preferably), composable function that inspects or transforms traffic at a single point in the request/response lifecycle.
On this page

Filter Model

Filters are the core processing units in Praxis. Each filter is a small (preferably), composable function that inspects or transforms traffic at a single point in the request/response lifecycle.

Filters are chained into pipelines; the pipeline executor calls each filter in order on requests and in reverse on responses.

New to filter development? Start with the HTTP Filter Tutorial to build, register, test, and run a small filter end to end.

For listener and chain resolution architecture, see the architecture overview.

What Filters Receive

HTTP filters receive an HttpFilterContext containing:

  • client_addr: downstream IP (from the TCP connection)
  • downstream_tls: whether the client connection uses TLS
  • health_registry: endpoint health state
  • request: method, URI, and headers
  • response_header: response status and headers (only in response phase)
  • cluster / upstream: current routing selections (may be set by earlier filters)
  • rewritten_path: path set by a preceding rewrite filter (Praxis will include this in routing decisions)
  • Request and response body chunks (only if the filter declares BodyAccess::ReadOnly or BodyAccess::ReadWrite)

TCP filters receive a TcpFilterContext with connection metadata: remote_addr, local_addr, sni (SNI hostname from TLS ClientHello), upstream_addr (mutable via Cow), timing, and byte counters.

What Filters Can Do

Every filter hook returns a FilterAction:

  • Continue: pass to the next filter in the pipeline.
  • Reject: short-circuit with an HTTP response (status code, optional headers and body). Used by static_response, redirect, rate_limit, guardrails, cors preflight, and similar filters.
  • Release: forward accumulated body data to upstream when using StreamBuffer mode. Behaves as Continue when body data is not relevant.

Filters also mutate HttpFilterContext fields to influence downstream processing:

  • ctx.cluster: select which upstream cluster to route to.
  • ctx.upstream: select a specific endpoint.
  • ctx.rewritten_path: rewrite the upstream request path.
  • ctx.extra_request_headers: inject headers into the upstream request.
  • ctx.request_headers_to_set: overwrite headers on the upstream request.
  • ctx.request_headers_to_remove: remove headers from the upstream request.
  • ctx.filter_metadata: write durable per-request metadata.
  • ctx.filter_results: write key-value results for branch chain evaluation. Results are keyed by the filter’s TYPE name (the return value of HttpFilter::name()). See Pipeline Concepts: Filter Results for the full lifecycle.
  • ctx.response_header: mutate response headers and status directly in on_response.
  • ctx.response_headers_modified: optional hint that response headers were changed. Not required for correctness; the protocol layer detects changes on its own.

Lifecycle Hooks

HookDirectionPhase
on_request_headForward (pipeline order), at most onceRequest head, before any stream_buffer pre-read and before the request phase (top-level filters only)
on_requestForward (pipeline order)Request
on_responseReverse (pipeline order)Response
on_request_bodyForwardRequest body chunks
on_bound_upstream_request_bodyForward, at most onceComplete body after logical binding, before endpoint selection (experimental bound-upstream-request-body builds only)
on_selected_upstream_request_bodyForward, per exchangeComplete body after endpoint selection
on_response_bodyReverseResponse body chunks

on_request_head runs once, before any body is read, for filters that opt in via runs_request_head. It only runs for top-level filters: a filter nested inside a branch chain never runs its head hook (a head_classifier placed inside a branch chain silently does nothing), and a head filter inside an iterative_request_router step is rejected at config build — the head phase runs once at ingress, never inside an iterative exchange. Facts it publishes (extensions, metadata, filter results, trusted header mutations) are visible both during a later stream_buffer pre-read and the request phase.

Request conditions gate the request and ordinary body hooks. Response response_conditions gate only the response hooks. A filter skipped on request is also skipped on response and on every body hook, ordinary and selected-upstream. The bound-upstream body hook is the deliberate exception: its conditions are evaluated at the freeze, right after the binding router, so a later top-level participant receives the body before its own on_request position is reached, and a condition on a header or result that a later filter would produce does not match, because that filter has not run yet.

The one exception is a stream_buffer pre-read, which runs the request-body hooks before the request phase. Nothing has been skipped at that point, so every filter declaring request-body access runs.

Filters that need the logical route declare bound_upstream_request_body_access and implement on_bound_upstream_request_body, which requires the experimental bound-upstream-request-body build feature. That hook runs at most once after the binding router freezes BoundUpstream; a read-write participant replaces the canonical body used by direct dispatch, IRR, retries, and selected-upstream adaptation. Use binds_upstream, consumes_bound_upstream, bound_upstream_clusters, declared_cluster_metadata, and nested_bound_upstream_readers only for routing filters whose capabilities must be visible to pipeline validation.

A filter may declare both request_body_access (pre-read) and bound_upstream_request_body_access (the barrier). Core infers one effective phase per filter from whether it carries a bound_upstream request condition, and runs the hook exactly once — declaring both defers the body work to the barrier when a condition asks for it, and never runs it twice:

Declared accessHas bound_upstream conditionRuns at
Pre-read onlyNopre-read (on_request_body)
Pre-read onlyYesrejected at load
Bound-upstream onlyEitherbarrier (on_bound_upstream_request_body)
BothNopre-read (on_request_body)
BothYesbarrier (on_bound_upstream_request_body)

Common Patterns

See examples/configs/ for working examples of every pattern. A few highlights:

Filter Chains (Pipelining)

Filter chains are named, reusable groups of filters defined at the top level of the config. A listener references one or more chains by name; the filters are concatenated in order to form that listener’s pipeline. This config-time assembly is called pipelining; it decides what processing a request receives. It is distinct from routing, where the router filter selects an upstream cluster at request time.

flowchart LR
    subgraph "Listener: public"
        direction LR
        S["security chain"] --> O["observability chain"]
        O --> R["traffic chain"]
    end

    subgraph "Listener: internal"
        direction LR
        O1["observability chain"] --> R2["traffic chain"]
    end

This enables reuse without duplication. A “security” chain can be shared across public listeners while internal listeners skip it entirely.

For conditional branching within pipelines, see Branch Chains. For the full mental model of how chains become pipelines, see Pipeline Concepts.

Inherited Chain Conditions

A chain can declare a conditions: block that every filter in the chain inherits, so a shared gate is written once at the chain level instead of being repeated on each filter:

filter_chains:
  - name: api-enrichment
    conditions:
      - when:
          path_prefix: "/api/"
    filters:
      - filter: json_body_field
        field: model
        header: X-Model
        conditions:
          - when:
              methods: ["POST"]
      - filter: headers
        response_set:
          - name: "X-Enriched"
            value: "true"

Chain conditions are prepended to each filter’s own conditions and compose with them using AND semantics: the chain condition is evaluated first, then the filter’s local conditions (effective = chain AND filter). Above, json_body_field runs only when the path matches /api/ and the method is POST, while headers runs on any /api/ request. A request-phase condition gates both a filter’s request and response hooks, so the inherited condition skips every hook in the chain (request-body promotion and response tagging alike) for non-matching traffic. Listener-wide effects are not gated: a StreamBuffer body filter in a gated chain still buffers and size-limits every request (or response) body on the listener, and compression still applies to every eligible response. In this example, json_body_field pre-reads even /health request bodies to end of stream before forwarding.

Chain-level conditions get the same well-formedness and metadata validation as per-filter conditions and behave identically to conditions repeated on each filter, so existing configs are unaffected. Inheritance applies wherever a named chain is expanded: chains referenced from a listener’s filter_chains, and named references from branch chains and outbound bindings. Only request-phase conditions are inherited; a branch child still runs its request and response header hooks (on_request, on_response) but not its body hooks (on_request_body, on_response_body), exactly as before. See inherited-conditions.yaml.

Protocol-Specific Filters

Every filter belongs to exactly one protocol level. HTTP filters implement the HttpFilter trait (on_request, on_response, body hooks). TCP filters implement the TcpFilter trait (on_connect, on_disconnect). There is no generic filter that operates at both levels. The AnyFilter enum tags each filter with its protocol for storage in a unified pipeline.

Built-in filters are organized by protocol, then by category:

builtins/
  http/                       HTTP protocol filters
    observability/            Access logs, request IDs, trace context
    payload_processing/       Compression, JSON body rewrite, body field extraction, JSON-RPC, gRPC-Web
    security/                 Basic auth, CORS, credential injection, CSRF, forwarded headers, guardrails, IP ACL, peer identity trust, policy
    traffic_management/       Circuit breaker, endpoint selector, gRPC detection, gRPC deadlines, iterative request router, load balancer, rate limit, redirect, router, sticky sessions, static response, timeout
    transformation/           gRPC error envelope, header, path rewrite, URL rewrite
  tcp/                        TCP protocol filters
    observability/            Connection logging
    traffic_management/       SNI router, TCP load balancer

At runtime, pipeline execution dispatches to the correct filter type. HTTP execution (execute_http_request, execute_http_response, body hooks) calls only HTTP filters, skipping TCP entries. TCP execution (execute_tcp_connect, execute_tcp_disconnect) calls only TCP filters, skipping HTTP entries.

Protocol stack model. Higher-level protocols include lower levels. HTTP’s stack includes TCP, so an HTTP listener accepts both HTTP and TCP filters in its pipeline. A TCP listener accepts only TCP filters. Validation enforces this via ProtocolKind::supports().

Listener ProtocolHTTP FiltersTCP Filters
http (default)YesYes
tcpNoYes
flowchart TD
    AnyFilter --> HttpFilter
    AnyFilter --> TcpFilter

    HttpListener["HTTP Listener"] -->|supports| HttpFilter
    HttpListener -->|supports| TcpFilter
    TcpListener["TCP Listener"] -->|supports| TcpFilter

What Stays Outside Filters

  • TCP/TLS, HTTP framing, connection pooling: adapters
  • Config loading and validation: praxis-core
  • Pipeline executor and HttpFilterContext: praxis-filter

HttpFilter Trait

Every HTTP behavior in Praxis is an HttpFilter:

#[async_trait]
pub trait HttpFilter: Send + Sync {
    fn name(&self) -> &'static str;
    async fn on_request(
        &self, ctx: &mut HttpFilterContext<'_>,
    ) -> Result<FilterAction, FilterError>;
    async fn on_response(
        &self, ctx: &mut HttpFilterContext<'_>,
    ) -> Result<FilterAction, FilterError> {
        Ok(FilterAction::Continue)
    }
    // Body hooks and access/mode methods omitted for
    // brevity; see "Body Access" section below.
}

The trait also defines body access, body mode, and body hook methods. See Body Access below for the full API.

on_request runs in order, on_response in reverse.

TcpFilter Trait

TCP-level filters implement TcpFilter:

#[async_trait]
pub trait TcpFilter: Send + Sync {
    fn name(&self) -> &'static str;
    async fn on_connect(
        &self, ctx: &mut TcpFilterContext<'_>,
    ) -> Result<FilterAction, FilterError> {
        Ok(FilterAction::Continue)
    }
    async fn on_disconnect(
        &self, ctx: &mut TcpFilterContext<'_>,
    ) -> Result<(), FilterError> {
        Ok(())
    }
}

on_connect fires when a TCP connection is accepted. on_disconnect fires when the connection closes. Both hooks have default implementations that pass through.

FilterAction

  • Continue : pass to next filter
  • Reject(rejection) : stop pipeline, respond now
  • Release : forward accumulated StreamBuffer data to upstream; behaves as Continue in non-StreamBuffer contexts
  • BodyDone : signal that this filter has finished body processing; subsequent body chunks skip this filter while other filters continue normally
FilterAction::Reject(Rejection::status(429)
    .with_header("Retry-After", "60")
    .with_body(b"rate limit exceeded" as &[u8]))

HttpFilterContext

Shared state flowing through HTTP filters for a request:

pub struct HttpFilterContext<'a> {
    pub client_addr: Option<IpAddr>,
    pub cluster: Option<Arc<str>>,
    pub downstream_tls: bool,
    pub extensions: RequestExtensions,
    pub extra_request_headers: Vec<(Cow<'static, str>, String)>,
    pub filter_metadata: HashMap<String, String>,
    pub filter_results: HashMap<&'static str, FilterResultSet>,
    pub filter_state: HashMap<usize, Box<dyn Any + Send + Sync>>,
    pub health_registry: Option<&'a HealthRegistry>,
    pub id_generator: &'a IdGenerator,
    pub kv_stores: Option<&'a KvStoreRegistry>,
    pub pre_read_mutations: Vec<TrustedHeaderMutation>,
    pub request: &'a Request,
    pub request_body_bytes: u64,
    pub request_body_mode: BodyMode,
    pub request_headers_to_remove: Vec<HeaderName>,
    pub request_headers_to_set: Vec<(HeaderName, HeaderValue)>,
    pub request_start: Instant,
    pub response_body_bytes: u64,
    pub response_body_mode: BodyMode,
    pub response_header: Option<&'a mut Response>,
    pub response_headers_modified: bool,
    pub rewritten_path: Option<String>,
    pub selected_endpoint_index: Option<usize>,
    pub structured_metadata: HashMap<String, serde_json::Value>,
    pub time_source: &'a dyn TimeSource,
    pub upstream: Option<Upstream>,
    // Internal pipeline tracking fields omitted.
}

TcpFilterContext

Per-connection state for TCP filters:

pub struct TcpFilterContext<'a> {
    pub remote_addr: &'a str,
    pub local_addr: &'a str,
    pub sni: Option<&'a str>,
    pub upstream_addr: Option<Cow<'a, str>>,
    pub cluster: Option<Arc<str>>,
    pub connect_time: Instant,
    pub bytes_in: u64,
    pub bytes_out: u64,
    pub health_registry: Option<&'a HealthRegistry>,
    pub kv_stores: Option<&'a KvStoreRegistry>,
}

The sni field is populated by the TCP proxy when it peeks at the first bytes of a TLS connection and extracts the SNI hostname from the ClientHello. Filters like sni_router use this to select an upstream. The upstream_addr field is an Option<Cow>, None until a static upstream or filter provides one; filters can replace it with an owned value. The cluster field names the selected cluster, and health_registry / kv_stores provide access to shared runtime state.

AnyFilter

The AnyFilter enum wraps both filter variants for storage in a unified registry and pipeline:

pub enum AnyFilter {
    Http(Box<dyn HttpFilter>),
    Tcp(Box<dyn TcpFilter>),
}

Each variant reports its protocol_level() as ProtocolKind::Http or ProtocolKind::Tcp.

Body Access (HTTP only)

Filters see headers only by default. Opt in:

fn request_body_access(&self) -> BodyAccess {
    BodyAccess::ReadOnly // or ReadWrite
}
AccessHooks?Modify?
None (default)NoNo
ReadOnlyYesNo
ReadWriteYesYes

Body Mode

ModeBehaviorUse case
Stream (default)Per chunkLogging, transforms
StreamBuffer { max_bytes }Deferred streamInspection before forward
SizeLimit { max_bytes }Stream + ceilingGlobal size enforcement

If any filter requests StreamBuffer, the pipeline defers upstream forwarding until release. SizeLimit streams chunks without buffering but enforces a byte ceiling, returning 413 on overflow. It is used when no filter needs body access but a global size limit is configured. Precedence: StreamBuffer > SizeLimit > Stream.

StreamBuffer Mode

StreamBuffer combines streaming inspection with deferred forwarding. Filters see each chunk as it arrives (like Stream) but the protocol layer accumulates them and does not forward to upstream until a filter returns FilterAction::Release or end-of-stream is reached.

fn request_body_mode(&self) -> BodyMode {
    // No limit (default):
    BodyMode::StreamBuffer { max_bytes: None }

    // With a limit (413 on overflow):
    // BodyMode::StreamBuffer { max_bytes: Some(1_048_576) }
}

A filter signals release by returning FilterAction::Release from on_request_body or on_response_body. After release, remaining chunks flow through in stream mode.

When max_bytes is None (default), StreamBuffer accumulates without limit. When Some(n), requests exceeding n bytes receive 413.

This mode is useful for:

  • API gateways: inspect request bodies for routing, content policy, or field extraction before forwarding
  • Security gateways: scan payloads for malware signatures, PII, or injection attacks with early rejection
  • Body-based routing: peek at request body content (e.g. JSON model field) to select a cluster, then release and forward

Body Hooks

// Async
async fn on_request_body(
    &self, ctx: &mut HttpFilterContext<'_>,
    body: &mut Option<Bytes>,
    end_of_stream: bool,
) -> Result<FilterAction, FilterError>;

// Sync (upstream constraint)
fn on_response_body(
    &self, ctx: &mut HttpFilterContext<'_>,
    body: &mut Option<Bytes>,
    end_of_stream: bool,
) -> Result<FilterAction, FilterError>;

Override needs_request_context() -> true to access request headers in body hooks.

Conditional Execution

Add conditions to any HTTP filter chain entry. Fields within a condition are ANDed; all conditions must pass. TCP filters take no conditions, response_conditions or branch_chains; a config that sets them on a TCP filter is rejected.

FieldMatches when
grpcRequest is (true) or is not (false) gRPC
pathURI exactly equals value
path_prefixURI starts with value
methodsMethod in list
headersAll listed headers match
headers_presentAll listed headers are present, any value
selected_upstreamLoad-balancer-selected upstream metadata match

grpc classifies the request from its content-type header (application/grpc, application/grpc+proto, application/grpc+json, or any other application/grpc+<codec>). It reads the header directly, so it works without the grpc_detection filter and does not depend on filter ordering. application/grpc-web is a distinct protocol and does not match.

# Rate limit only gRPC calls.
- filter: rate_limit
  conditions:
    - when:
        grpc: true
  requests_per_second: 100
filter_chains:
  - name: main
    filters:
      - filter: headers
        conditions:
          - when:
              path_prefix: "/api"
          - unless:
              headers:
                x-internal: "true"
        request_add:
          - name: "X-Api-Version"
            value: "v2"

headers_present takes a list of header names and matches when every one of them is on the request, whatever the value (an empty value counts). Names are case-insensitive. Under unless it runs a filter only when a header is missing, which suits filling in defaults without overwriting what the client sent. In the pre-read body phase it also sees headers that earlier body filters promoted. Request headers come from the client, so any client can skip a filter gated this way by sending the header; only use it where that’s fine or where a trusted hop sets or strips the header. See header-presence-condition.yaml.

# Set a default model only when the client didn't pick one.
- filter: headers
  conditions:
    - unless:
        headers_present: ["X-Model"]
  request_set:
    - name: "X-Model"
      value: "default"

Use path for exact matching (e.g., health checks on /):

- filter: static_response
  conditions:
    - when:
        path: "/"
  status: 200
  body: "ok"

Skipped on request = skipped on response and on ordinary body hooks. The bound-upstream body hook is the exception described above: it is gated at the binding barrier.

Selected-Upstream Conditions

selected_upstream matches the application metadata (http.application_protocol, http.application_provider) of the routed cluster (the cluster the router selected). The load balancer publishes it when it selects an upstream, and also when an earlier filter such as endpoint_selector has already set the endpoint. It has two optional sub-fields; when both are set they are ANDed:

Sub-fieldMatches when
application_protocolSelected upstream’s protocol equals value
application_providerSelected upstream’s provider equals value
- filter: path_rewrite
  conditions:
    - when:
        selected_upstream:
          application_protocol: openai_chat_completions
          application_provider: vllm
  # ...path_rewrite config...

The metadata comes only from the load balancer’s selection, never from a request header or filter-writable metadata. If no upstream has been selected yet, or the cluster does not set the field, the predicate fails closed: an unset value never satisfies a when and never trips an unless.

Pipeline validation therefore requires an unconditional load_balancer to run before the gated filter on every reachable path. A conditional load balancer may not run, so it does not count. A load balancer in an unconditional branch, or one that hosts the branch containing the gated filter, does.

Selected-upstream predicates are resolved in the request phase. The pre-read body path runs before any selection exists, so validation rejects a request-body filter that combines a selected_upstream condition with the StreamBuffer body mode.

Response Conditions

Use response_conditions to gate on_response execution. Response predicates: status (list of status codes), headers, headers_present.

- filter: headers
  response_conditions:
    - when:
        status: [200, 201]
  response_set:
    - name: "Cache-Control"
      value: "public, max-age=60"

A filter can have both conditions (request phase) and response_conditions (response phase).

Security Filter Restrictions

Filters registered as SecurityClass::Security (built-in examples: ip_acl, forwarded_headers; also custom filters registered as Security) reject failure_mode: open by default. Open failure mode on these filters means runtime errors would bypass security enforcement. To override this check, set insecure_options.allow_open_security_filters: true, which demotes the error to a warning.

Rewrite and Routing Interaction

Both path_rewrite and url_rewrite set ctx.rewritten_path. The router checks rewritten_path before the original URI, enabling “rewrite then route” pipelines. If both rewrite filters appear in the same pipeline, only the last one takes effect. Validation rejects this by default; set allow_rewrite_override: true on the later filter to permit it.


access_log

Logs structured access records for each request and response.

basic_auth

HTTP Basic Authentication filter (RFC 7617).

Branch Chains

Branch chains add conditional paths to your filter pipeline. A filter produces a result, and a branch condition reads it to decide whether to divert, short-circuit, skip ahead, or loop back.

circuit_breaker

Rejects requests to clusters whose circuit is open.

cloud_events

Generate structured CloudEvents and ship them to a configured HTTP endpoint.

compression

Enables Pingora’s built-in response compression when present in a filter chain.

cors

Spec-compliant CORS filter implementing origin validation, preflight handling, and response header injection.

credential_injection

Injects per-cluster API credentials into upstream requests.

csrf

CSRF protection filter that validates request origins against a trusted allowlist.

endpoint_selector

Selects an upstream endpoint from a trusted mutation source.

Extensions

Praxis is designed to be extended. The core library provides the building blocks for building bespoke proxy servers. Multiple extension mechanisms are provided to support a variety of needs.

Filter Reference

Built-in filters organized by protocol and category.

forwarded_headers

Injects X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host headers into upstream requests.

grpc_detection

Detects the gRPC variant from the request content-type header and records it for branch-chain routing and observability.

grpc_status

Answers proxy-generated errors in the shape gRPC clients expect.

grpc_timeout

Honours the grpc-timeout request header as a real deadline.

grpc_web

Translates gRPC-Web calls to native gRPC and back.

guardrails

Rejects requests matching string, regex, or PII rules against headers and/or body content.

head_classifier

Classifies a request from its head and promotes the matched class as a reserved, client-unspoofable x-praxis-head-class header.

headers

Adds, sets, or removes headers on upstream requests and downstream responses.

HTTP Filter Tutorial

This tutorial builds a small HTTP filter from scratch. The filter requires requests to contain a configured header and returns 401 Unauthorized when the header is missing.

ip_acl

IP-based access control filter.

iterative_request_router

Framework-level filter for iterative sub-request execution.

json_body

Rewrites JSON request bodies with JSON Pointer add, remove, replace, and extract, and response bodies with remove and extract.

json_body_field

Extracts top-level fields from a JSON request body and promotes their values to request headers using [StreamBuffer] mode.

json_rpc

Extracts JSON-RPC 2.0 envelope metadata from request bodies and promotes method, id, and kind to request headers and filter results for routing.

load_balancer

Selects an upstream endpoint using the cluster’s configured strategy.

path_rewrite

Rewrites the request path before forwarding to the upstream.

peer_identity_trust

Validates that the downstream mTLS peer identity matches a configured trusted peer before allowing the request to continue.

policy

Embeds the Policy Engine to enforce identity checks and APL rules, exchange tokens, redact fields, track session taint, and emit audit events.

rate_limit

Token bucket rate limiter that rejects excess traffic with 429.

redirect

Returns a redirect response without contacting any upstream.

request_id

Ensures every request carries a correlation ID.

router

Routes requests to clusters based on path prefix and host header.

sni_router

Routes TCP connections by SNI hostname.

static_response

Returns a fixed response without contacting any upstream.

sticky_sessions

Sticky sessions HTTP filter.

tcp_access_log

Logs TCP connection events.

tcp_load_balancer

Selects an upstream TCP endpoint using the cluster’s configured strategy.

timeout

Enforces a maximum end-to-end latency from request receipt to response headers.

trace_context

Propagates W3C Trace Context and x-request-id correlation.

url_rewrite

Rewrites request URLs using regex substitution and query parameter manipulation before the request reaches upstream.