Under construction

This documentation is still in progress. Some information may be incomplete or out of date. Please check back later.

Docs/Proxy & gateways

Internals

Proxy & gateways

Everything that hands a request to another process: the reverse proxy (HTTP/1, h2://, h2s://) with weighted upstream groups and active health checks, WebSocket and SSE relay, and the opt-in FastCGI gateway. LSAPI/PHP has its own page. Generic CGI execution and uWSGI are specified but deliberately not routable.

How a request reaches a backend

Three routes select a gateway, in dispatch order: a WebSocket map (<websocketList>) when the request carries an upgrade, a proxy <context> whose URI prefix matches and whose handler names a proxy extProcessor, and a rewrite [P] target (deferred so it can still participate in the page cache). Script suffixes (scriptHandler) route to LSAPI or FastCGI. A proxy context that cannot resolve its processor falls through to static; a script handler that cannot resolve its pool hard-fails 503.

Target formProtocol to the backend
host:port, ip:port, uds:///path, http://…HTTP/1.1 with keep-alive pools
https://…HTTP/1.1 over TLS; backend certificate verified; optional client cert (clientCertFile/clientKeyFile)
h2://host:port httpjetHTTP/2 prior knowledge (cleartext); multiplexes requests on one backend connection
h2s://host[:443] httpjetHTTP/2 over TLS, ALPN fail-closed

Pool keys are scheme-scoped, so an h1 and an h2 target at the same address never share a connection. Forwarded requests to h2 upstreams are re-absolutized (the h2 client derives :scheme/:authority from the URI). Proxied responses may not assert X-LiteSpeed-Purge/-Cache-Control/-Vary — those are stripped so a remote backend cannot inject cache policy. Named upstreams receive a W3C traceparent when tracing is on.

!
[P] targets are screened

Since 2026-09-16 a rewrite [P] URL that resolves (after DNS — /etc/hosts pins cannot bypass the screen) to loopback, link-local or a Unix socket is refused unless it matches an operator-configured extProcessor address. This closes the class where a .htaccess author reaches the local trust plane (metrics, admin, purge endpoints) through the server.

Upstream groups & selection

An extProcessor with several <address> children is a group. Without the options below it keeps ordered primary/failover behavior with a passive circuit breaker (3 consecutive dial failures mark a peer bad; one successful dial clears it — nginx max_fails semantics). With them it becomes a load-balanced group:

httpd_config.xml · a weighted group with active probes
<extProcessor>
  <name>application</name>
  <type>proxy</type>
  <address>127.0.0.1:9001</address>
  <address>127.0.0.1:9002</address>
  <loadBalancePolicy>weighted-round-robin</loadBalancePolicy>
  <!-- primary-first (default) | weighted-round-robin | weighted-least-active -->
  <addressWeights>2,1</addressWeights>        <!-- one integer 1..1000 per address -->
  <healthCheck>
    <mode>GET</mode>                    <!-- connect | GET | HEAD (default connect) -->
    <path>/ready</path>                 <host>application.internal</host>
    <interval>10</interval>             <timeout>2</timeout>      <!-- 1..3600 s; timeout ≤ interval -->
    <rise>2</rise>                      <fall>3</fall>            <!-- 1..100 -->
    <expectedStatus>200</expectedStatus>       <!-- 200..599 -->
  </healthCheck>
</extProcessor>
primary-first (default)weighted-round-robinweighted-least-activedial failsresponse headnone eligible

request → proxy context or [P] rewrite

resolve extProcessor group
(server or vhost scope)

eligible peers = active-health OK ∧ breaker closed

loadBalancePolicy

first eligible in address order

smooth weighted RR
addressWeights 1..1000

min(active ÷ weight), smooth-WRR tie-break

checkout pooled connection
h1 · h2:// · h2s:// (scheme-scoped pool keys)

mark bad, try next eligible
(no unsafe-body replay)

stream body / SSE / WebSocket relay
reservation released on completion

503

Selection. Eligibility is the intersection of active health and the passive breaker; the policy then picks among eligible peers. A pre-head dial failure marks the peer and moves to the next eligible one; nothing replays a request body that has already been sent.
diagram source (mermaid)
flowchart TB
  classDef pol fill:#13203a,stroke:#818cf8,color:#fff
  R["request → proxy context or [P] rewrite"] --> G["resolve extProcessor group<br/>(server or vhost scope)"]
  G --> E["eligible peers = active-health OK ∧ breaker closed"]
  E --> P("loadBalancePolicy"):::pol
  P -- "primary-first (default)" --> S1["first eligible in address order"]
  P -- weighted-round-robin --> S2["smooth weighted RR<br/>addressWeights 1..1000"]
  P -- weighted-least-active --> S3["min(active ÷ weight), smooth-WRR tie-break"]
  S1 & S2 & S3 --> C["checkout pooled connection<br/>h1 · h2:// · h2s:// (scheme-scoped pool keys)"]
  C -- "dial fails" --> F["mark bad, try next eligible<br/>(no unsafe-body replay)"]
  F --> E
  C -- "response head" --> H["stream body / SSE / WebSocket relay<br/>reservation released on completion"]
  E -- "none eligible" --> X[503]
healthCheck configuredno healthCheck (passiveonly)rise consecutive probesuccessesfall consecutive probefailures3 consecutive dialfailures (passivebreaker)successful dialrise successes (no usertraffic needed)

Cold

Eligible

Down

Broken

ineligible until the rise
threshold is met

all peers ineligible → 503

Peer states. A health-enabled peer starts cold (ineligible) until rise consecutive successes; fall failures take it down; probe success restores it without user traffic. Passive breaker state is independent. All-ineligible → 503.
diagram source (mermaid)
stateDiagram-v2
  direction LR
  [*] --> Cold: healthCheck configured
  [*] --> Eligible: no healthCheck (passive only)
  Cold --> Eligible: rise consecutive probe successes
  Eligible --> Down: fall consecutive probe failures
  Eligible --> Broken: 3 consecutive dial failures (passive breaker)
  Broken --> Eligible: successful dial
  Down --> Eligible: rise successes (no user traffic needed)
  note right of Cold
    ineligible until the rise
    threshold is met
  end note
  note right of Down
    all peers ineligible → 503
  end note

Per-peer metrics carry scope, group name and ordinal labels (never URLs or credentials): httpjet_proxy_peer_{healthy,probes_total,health_transitions_total,active_requests,selections_total,prehead_failures_total}, plus httpjet_proxy_failover_total and the httpjet_phase_upstream_seconds latency histogram. The read-only status API exposes the same peer health.

Streaming, SSE and timeouts

Upstream responses are never buffered (respBuffer is parsed but always streams — check warns). Server-sent events therefore stay real-time through compression (the per-chunk CompressStream) and through H1, H2 and H3 alike. A backend connect timeout is initTimeout; the response-head deadline is the processor's default or a context's responseTimeout override; a backend timeout is a 504. Request bodies stream to h1/h2 upstreams; a context maxReqBodySize is enforced before dispatch.

WebSocket relay

A <websocketList><websocket> map (uri → address) or an upgrade request on a proxy context performs the HTTP/1 handshake with the backend and then relays raw frames both ways. The relay supports plain TCP HTTP/1 upstreams only — not TLS, H2 or Unix-socket targets. Maps carry a stable websocket:<uri> target identity scoped to their vhost, so named-target trace propagation applies; the client span covers the handshake, not the potentially unbounded relay. TCP keepalive keeps a vanished peer from pinning fds and permits forever.

FastCGI gateway opt-in

An external processor declared <type>fcgi</type> or fastcgi is an ExtKind::FastCgi — never silently converted into an HTTP proxy or an LSAPI worker. Only an explicit cgi script handler whose named processor is FastCGI dispatches through the gateway; a cgi mapping with a missing or different processor fails closed and is never served as static source. httpjet does not spawn the application: it connects to the configured TCP or Unix endpoint of an independently supervised process.

vhost XML · FastCGI suffix routing
<extProcessor>
  <name>my_fcgi</name>
  <type>fcgi</type>
  <address>uds:///run/my-app/fcgi.sock</address>
  <maxConns>32</maxConns>
  <initTimeout>5</initTimeout>
  <pcKeepAliveTimeout>30</pcKeepAliveTimeout>
</extProcessor>

<scriptHandler>
  <suffix>fcgi</suffix>
  <type>cgi</type>
  <handler>my_fcgi</handler>
</scriptHandler>
FastCGI app(externally supervised, TCP or uds://)bounded pool(maxConns, one acquisition deadline)hj-fastcgi handlerpipelinesuffix routingvalidated script target (beneath docroot, regular file, access checks passed)build CGI params — server-derived, HTTP_* validated, protected names not overridablebuffer request body under the shared budget (exact STDIN length)acquire — poisoned by defaultBEGIN_REQUEST · PARAMS (≤65,535 B records) · STDIN · empty terminatorsSTDOUT: strict CGI head (CRLF only, one Status, Location default) + streamed bodySTDERR — byte-capped, control-escaped → error log, never to clientEND_REQUEST (exact 8-byte body)clean END_REQUEST → mark reusable — anything else → poison + closeresponse (hop-by-hop fields rejected)
One request through hj-fastcgi. The pipeline hands over an already-validated script target; the gateway never derives an executable path from the URI. Connections are poisoned by default and only a clean, exact END_REQUEST makes one reusable. Multiplexing request ids on one connection is disabled.
diagram source (mermaid)
sequenceDiagram
  participant P as pipeline<br/>suffix routing
  participant H as hj-fastcgi handler
  participant Pool as bounded pool<br/>(maxConns, one acquisition deadline)
  participant A as FastCGI app<br/>(externally supervised, TCP or uds://)
  P->>H: validated script target (beneath docroot, regular file, access checks passed)
  H->>H: build CGI params — server-derived, HTTP_* validated, protected names not overridable
  H->>H: buffer request body under the shared budget (exact STDIN length)
  H->>Pool: acquire — poisoned by default
  Pool->>A: BEGIN_REQUEST · PARAMS (≤65,535 B records) · STDIN · empty terminators
  A-->>H: STDOUT: strict CGI head (CRLF only, one Status, Location default) + streamed body
  A-->>H: STDERR — byte-capped, control-escaped → error log, never to client
  A-->>H: END_REQUEST (exact 8-byte body)
  H->>Pool: clean END_REQUEST → mark reusable — anything else → poison + close
  H-->>P: response (hop-by-hop fields rejected)

Known follow-ups: explicit CGI context-directory routing (today: script-handler suffix mappings only), a codec fragmentation corpus, and a maintained real-application interoperability fixture.

Conventional CGI and uWSGI not routable

Both remain disabled by design until a demonstrated workload exists. Existing type=cgi, rails, appserver and unknown processor types never activate either; check --strict keeps warning for configured CGI contexts. The design contracts are strict on purpose: CGI would require an absolute execution root distinct from the docroot, a fixed non-root identity, wall/CPU/memory/process/output limits, an executable allowlist, direct execve with no shell or PATH lookup, inode pinning, and bounded pipes with process-group termination; uWSGI would be a separate crate over an explicit endpoint with modifier-0 packets only, no multiplexing, and no connection reuse until a maintained responder proves the response boundary. Proposed identities are type=cgi-exec and type=uwsgi.

Reproducible checks

tests · loopback fixtures only
cargo test --locked -p hj-config -p hj-proxy
cargo test --locked -p hj-proxy --test upstream_groups     # h1/h2 weighted distribution, cold start, down/recovery, all-down 503
cargo test -p hj-fastcgi                                    # synthetic loopback responder + clean-connection reuse
cargo test -p httpjet --bin httpjet

To return a group to legacy selection, remove loadBalancePolicy, addressWeights and healthCheck and reload; removing only the health check keeps the configured policy.

httpjet · documentation · synced to the source tree on 2026-10-13 (v0.0.5).
Source: github.com/faratech/httpjet · Home: httpjet.net · Found an error? Open an issue on the repository.