access_log
Logs structured access records for each request and response.
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.
HTTP filters receive an HttpFilterContext containing:
client_addr: downstream IP (from the TCP connection)downstream_tls: whether the client connection uses TLShealth_registry: endpoint health staterequest: method, URI, and headersresponse_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)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.
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.| Hook | Direction | Phase |
|---|---|---|
on_request | Forward (pipeline order) | Request |
on_response | Reverse (pipeline order) | Response |
on_request_body | Forward | Request body chunks |
on_bound_upstream_request_body | Forward, at most once | Complete body after logical binding, before endpoint selection (experimental bound-upstream-request-body builds only) |
on_selected_upstream_request_body | Forward, per exchange | Complete body after endpoint selection |
on_response_body | Reverse | Response 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 access | Has bound_upstream condition | Runs at |
|---|---|---|
| Pre-read only | No | pre-read (on_request_body) |
| Pre-read only | Yes | rejected at load |
| Bound-upstream only | Either | barrier (on_bound_upstream_request_body) |
| Both | No | pre-read (on_request_body) |
| Both | Yes | barrier (on_bound_upstream_request_body) |
See examples/configs/ for working examples of every
pattern. A few highlights:
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"]
endThis 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.
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 Protocol | HTTP Filters | TCP Filters |
|---|---|---|
http (default) | Yes | Yes |
tcp | No | Yes |
flowchart TD
AnyFilter --> HttpFilter
AnyFilter --> TcpFilter
HttpListener["HTTP Listener"] -->|supports| HttpFilter
HttpListener -->|supports| TcpFilter
TcpListener["TCP Listener"] -->|supports| TcpFilterpraxis-coreHttpFilterContext: praxis-filterEvery 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.
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.
Continue : pass to next filterReject(rejection) : stop pipeline, respond nowRelease : forward accumulated StreamBuffer data to
upstream; behaves as Continue in non-StreamBuffer
contextsBodyDone : signal that this filter has finished body
processing; subsequent body chunks skip this filter
while other filters continue normallyFilterAction::Reject(Rejection::status(429)
.with_header("Retry-After", "60")
.with_body(b"rate limit exceeded" as &[u8]))
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.
}
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.
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.
Filters see headers only by default. Opt in:
fn request_body_access(&self) -> BodyAccess {
BodyAccess::ReadOnly // or ReadWrite
}
| Access | Hooks? | Modify? |
|---|---|---|
None (default) | No | No |
ReadOnly | Yes | No |
ReadWrite | Yes | Yes |
| Mode | Behavior | Use case |
|---|---|---|
Stream (default) | Per chunk | Logging, transforms |
StreamBuffer { max_bytes } | Deferred stream | Inspection before forward |
SizeLimit { max_bytes } | Stream + ceiling | Global 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 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:
// 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.
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.
| Field | Matches when |
|---|---|
grpc | Request is (true) or is not (false) gRPC |
path | URI exactly equals value |
path_prefix | URI starts with value |
methods | Method in list |
headers | All listed headers match |
selected_upstream | Load-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 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-field | Matches when |
|---|---|
application_protocol | Selected upstream’s protocol equals value |
application_provider | Selected 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.
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).
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.
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.
Logs structured access records for each request and response.
HTTP Basic Authentication filter (RFC 7617).
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.
Rejects requests to clusters whose circuit is open.
Generate structured CloudEvents and ship them to a configured HTTP endpoint.
Enables Pingora’s built-in response compression when present in a filter chain.
Spec-compliant CORS filter implementing origin validation, preflight handling, and response header injection.
Injects per-cluster API credentials into upstream requests.
CSRF protection filter that validates request origins against a trusted allowlist.
Selects an upstream endpoint from a trusted mutation source.
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.
Built-in filters organized by protocol and category.
Injects X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host headers into upstream requests.
Detects the gRPC variant from the request content-type header and records it for branch-chain routing and observability.
Answers proxy-generated errors in the shape gRPC clients expect.
Honours the grpc-timeout request header as a real deadline.
Translates gRPC-Web calls to native gRPC and back.
Rejects requests matching string, regex, or PII rules against headers and/or body content.
Adds, sets, or removes headers on upstream requests and downstream responses.
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-based access control filter.
Framework-level filter for iterative sub-request execution.
Rewrites JSON request bodies with JSON Pointer add, remove, replace, and extract, and response bodies with remove and extract.
Extracts top-level fields from a JSON request body and promotes their values to request headers using [StreamBuffer] mode.
Extracts JSON-RPC 2.0 envelope metadata from request bodies and promotes method, id, and kind to request headers and filter results for routing.
Selects an upstream endpoint using the cluster’s configured strategy.
Rewrites the request path before forwarding to the upstream.
Validates that the downstream mTLS peer identity matches a configured trusted peer before allowing the request to continue.
Embeds the Policy Engine to enforce identity checks and APL rules, exchange tokens, redact fields, track session taint, and emit audit events.
Token bucket rate limiter that rejects excess traffic with 429.
Returns a redirect response without contacting any upstream.
Ensures every request carries a correlation ID.
Routes requests to clusters based on path prefix and host header.
Routes TCP connections by SNI hostname.
Returns a fixed response without contacting any upstream.
Sticky sessions HTTP filter.
Logs TCP connection events.
Selects an upstream TCP endpoint using the cluster’s configured strategy.
Enforces a maximum end-to-end latency from request receipt to response headers.
Propagates W3C Trace Context and x-request-id correlation.
Rewrites request URLs using regex substitution and query parameter manipulation before the request reaches upstream.