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_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

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.

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
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"

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.

- 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.

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.