Streaming HTTP proxy with request/response observation.
Philter forwards HTTP requests to upstream servers while capturing body observations (hash, size, timing, preview) without buffering. Supports conditional body accumulation for content types you want to persist.
Philter — an alchemical potion or charm; from Greek philtron (φίλτρον), "love potion." Here it evokes both filtering (the proxy inspects and forwards HTTP traffic) and the Elixir ecosystem's alchemical tradition.
HTTP Client
Philter uses a Mint-direct transport that resolves the upstream hostname,
validates the resolved addresses against the SSRF egress policy (see
Philter.Egress), and pins the connection to a validated IP. No Finch pool is
needed; the :finch_name option is deprecated and ignored.
Quick Start
# In a Phoenix controller
def proxy(conn, _params) do
Philter.proxy(conn, upstream: "http://api.example.com")
end
# Or as a Plug in your router
forward "/api", Philter.ProxyPlug, upstream: "http://api.example.com"Configuration
Set defaults in your config and override per-request. See Philter.Config for details.
# config/config.exs
config :philter,
receive_timeout: 30_000,
max_payload_size: 5_242_880Handler Callbacks
Implement Philter.Handler to hook into the proxy lifecycle:
handle_request_started/2- Called before sending to upstreamhandle_response_started/2- Called on first byte received (TTFB)handle_response_finished/2- Called when complete, with body observations
Example:
defmodule MyHandler do
use Philter.Handler
@impl true
def handle_response_finished(result, state) do
# result contains :request_observation and :response_observation
# each with :hash, :size, :body, :preview
{:ok, state}
end
end
Summary
Functions
Proxies an HTTP request to an upstream server.
Types
@type proxy_opts() :: [ upstream: String.t(), path: String.t() | (Plug.Conn.t() -> String.t()), handler: module() | {module(), term()}, headers: [{String.t(), String.t()}], extra_headers: [{String.t(), String.t()}], strip_headers: [String.t()], finch_name: atom(), receive_timeout: pos_integer(), max_payload_size: pos_integer(), persistable_content_types: [String.t()], log_level: Logger.level() | false, collect_timing: boolean(), block_private_networks: boolean(), allowed_hosts: [String.t()], dns_timeout: pos_integer(), connect_timeout: pos_integer(), transport_opts: keyword() ]
Functions
@spec proxy(Plug.Conn.t(), proxy_opts()) :: Plug.Conn.t()
Proxies an HTTP request to an upstream server.
Streams the request to upstream and the response back to the client. Call this
after authentication or other pre-processing (unlike Philter.ProxyPlug).
Examples
# Basic proxy
Philter.proxy(conn, upstream: "http://api.example.com")
# With handler for logging/persistence
Philter.proxy(conn,
upstream: "http://api.example.com",
handler: {MyHandler, %{user_id: user.id}}
)
# Override timeout for slow endpoints
Philter.proxy(conn,
upstream: "http://api.example.com",
receive_timeout: 60_000
)Options
:upstream- Base URL of the upstream server. Required.:handler- Handler module or{module, state}tuple for lifecycle callbacks. SeePhilter.Handlerfor the callback interface.:headers- Pre-assembled outbound request headers as[{name, value}]tuples. When provided, these replaceconn.req_headers(no hop-by-hop filtering) and an explicit"host"entry (case-insensitive) is preserved as-is; if no"host"is included, the upstream host is appended as a default. When omitted,conn.req_headersare filtered (hop-by-hop removed, keys lowercased) and thehostheader is always rewritten to match upstream. Cannot be combined with:extra_headersor:strip_headers.:extra_headers- Additional[{name, value}]headers to merge into the outbound request. Applied after hop-by-hop filtering and host rewriting. If an extra header matches an existing header name (case-insensitive), the existing header is replaced. Cannot be combined with:headers.:strip_headers- List of header names (case-insensitive) to remove from the outbound request. Applied after hop-by-hop filtering and host rewriting but before:extra_headers. Cannot be combined with:headers.When both
:strip_headersand:extra_headersare used, the processing order is: filter hop-by-hop headers → rewrite host → strip → merge extra.:finch_name- Deprecated and ignored. The transport uses no connection pool. Accepted so existing callers do not crash.:receive_timeout- Response timeout in milliseconds. Default:15_000.:max_payload_size- Max body size in bytes for full accumulation. Bodies exceeding this are still hashed and previewed. Default:1_048_576(1MB).:path- Override the request path sent to upstream. Can be a string or a function(Plug.Conn.t() -> String.t()). Default:conn.request_path.:persistable_content_types- Content types eligible for body accumulation. Supports wildcards like"text/*". Default: JSON, XML, and text types.:log_level- Logger level for lifecycle events (:debug,:info, etc.) orfalseto disable all logging. Default::debug.:collect_timing- Whentrue, captures a per-phase timing breakdown (connect_us,send_us,recv_us) measured directly around the Mint transport calls.queue_usandidle_time_usare alwaysnilandreused_connection?is alwaysfalse(no connection pool). Phase fields intimingarenilwhen disabled. Default:false.:block_private_networks- Whentrue(default), reject upstreams whose hostname resolves to a private, loopback, link-local or otherwise internal address (SSRF egress guard). SeePhilter.Egress.:allowed_hosts- Hosts that bypass the egress block check entirely (the escape hatch, e.g. a deliberately internal upstream). Exact match after downcase and trailing-dot strip. Default:[].:dns_timeout- Milliseconds to bound upstream DNS resolution. On timeout the request fails with504. Default:5_000.:connect_timeout- Milliseconds to bound the connection phase to a validated upstream address. Default:5_000.:transport_opts- Extra Mint transport options merged into the connection (e.g.cacertfile:to trust a custom CA bundle). Cannot be used to disable TLS certificate verification. Default:[].
Return Value
Returns the conn with response sent. Observations are stored in:
conn.private[:philter_request_observation]- Request body observationconn.private[:philter_response_observation]- Response body observation
Each observation contains :hash, :size, :body (if accumulated), and :preview.
Error Handling
On upstream errors, returns 502 Bad Gateway. On timeouts, returns 504 Gateway Timeout.
The handler's handle_response_finished/2 is still called with the :error field set.