cloudwait is a high-performance Rust HTTP/1.1 reverse proxy for AI inference APIs that may take a long time to produce response headers or body bytes.
It behaves like a normal reverse proxy for ordinary routes, and adds configurable "chunk-wait" handling for selected endpoints such as Anthropic, OpenAI-compatible, and other AI streaming APIs.
- Proxies HTTP/1.1 requests using precise request and response boundary parsing.
- Listens on TCP sockets and Unix domain sockets.
- Connects to TCP or Unix upstreams.
- Supports HTTP and HTTPS TCP upstreams.
- Supports custom CA roots and opt-in invalid certificate acceptance per upstream.
- Routes by downstream
Hostheader. - Tunnels WebSocket upgrades as raw bidirectional streams after a successful
101response. - Handles special AI routes with upstream header wait, fallback headers, chunked downstream streaming, and periodic keepalives.
- Supports JSON-style and SSE-style keepalive payloads.
- Supports fixed local responses for selected routes without opening an upstream connection.
- Uses TOML configuration only.
Some clients, tunnels, and intermediaries close idle HTTP connections when an upstream inference server is slow to send response headers or the first body bytes. For configured special routes, cloudwait can keep the downstream connection alive by sending a valid chunked response and periodic keepalive chunks while it waits for the upstream response.
Normal routes are not modified this way. They are proxied with standard HTTP/1.1 request and response framing.
Prerequisites:
- Rust 1.82 or newer.
- A reachable HTTP/HTTPS upstream.
git clone <your-repo-url>
cd cloudwait
cp config.example.toml config.toml
$EDITOR config.toml
cargo run --release -- config.tomlIf no config path is passed, cloudwait reads ./config.toml.
Send a request through the proxy:
curl -v http://127.0.0.1:8080/v1/messagesThe config file is TOML. See config.example.toml for a fully commented example.
Top-level settings:
max_header_bytes = 65536
io_buffer_bytes = 16384
io_timeout = "30s"
request_header_timeout = "10s"
max_connections = 1024Routing settings:
[proxy]
default_upstream = "main"
[proxy.host_upstreams]
"api.example.com" = "main"
"local.example.test" = "local"Listeners:
[[listen]]
type = "tcp"
addr = "0.0.0.0:8080"
[[listen]]
type = "unix"
path = "/tmp/cloudwait.sock"
remove_existing = true
mode = 0o660Upstreams:
[upstreams.main]
type = "tcp"
scheme = "https"
addr = "api.example.com:443"
server_name = "api.example.com"
connect_timeout = "10s"
custom_ca_pem = "/etc/ssl/certs/api-example-ca.pem"
accept_invalid_certs = false
[upstreams.local]
type = "unix"
scheme = "http"
path = "/tmp/upstream.sock"Notes:
- TCP upstreams can use
scheme = "http"orscheme = "https". - HTTPS upstreams require
server_namefor SNI and certificate validation. - Unix upstreams must use
scheme = "http". accept_invalid_certs = trueshould only be used for controlled internal or test upstreams.
Special routes are configured with [[special_routes]]. They are useful for AI inference endpoints that may delay headers or stream sparse body chunks.
Common endpoints:
/v1/messages/v1/responses/v1/chat/completions/v1/completions
Example:
[[special_routes]]
paths = ["/v1/messages", "/v1/responses", "/v1/chat/completions", "/v1/completions"]
match = "prefix"
methods = "*"
normalize_path = true
upstream = "main"
rewrite_http_code_5xx = 200
header_timeout = "110s"
upstream_wait_timeout = "15m"
pre_body_keepalive_interval = "30s"
fallback_kind = "json"
[special_routes.json]
keepalive_hex = "0a"
fallback_header = [
"HTTP/1.1 200 OK",
"Content-Type: application/json",
"Transfer-Encoding: chunked",
]
[special_routes.sse]
comment = ""
json_response = "error_event"
json_error_event = "error"
fallback_header = [
"HTTP/1.1 200 OK",
"Content-Type: text/event-stream",
"Cache-Control: no-cache",
"Transfer-Encoding: chunked",
]Important behavior:
- The full downstream request is forwarded upstream before the proxy waits for upstream response headers.
- If upstream headers arrive before
header_timeout, the proxy forwards the upstream body as a downstream chunked response. - If upstream headers do not arrive before
header_timeout, the proxy sends the configured fallback response header downstream, then keeps waiting for upstream untilupstream_wait_timeout. upstream_wait_timeoutalso caps the wait for the first upstream body payload after response headers arrive. The default is15m.- Downstream special-route responses use
Transfer-Encoding: chunkedwhen they can carry a body. Content-Lengthis stripped from rewritten downstream special-route responses.- Keepalive chunks are sent while waiting for the first upstream body payload and between delayed upstream body chunks.
- Query strings are ignored for route matching.
methods can be:
methods = "*"
methods = "GET,POST"
methods = "HEAD"match can be:
match = "exact"
match = "prefix"When normalize_path = true, the request path is normalized before matching. Query strings are ignored for matching.
JSON-style keepalives use raw bytes encoded as hex:
[special_routes.json]
keepalive_hex = "0a"0a is a newline byte. On the wire, special-route responses are chunked, so the downstream bytes include chunk framing around the keepalive payload.
SSE mode is selected when:
- the downstream request
Acceptheader includestext/event-stream, or - the downstream request has
Content-Type: application/jsonand its first top-levelstreamfield istrue, or - the upstream response
Content-Typeistext/event-stream, or fallback_kind = "sse"is configured and the header timeout fallback fires.
Default SSE keepalive is an empty comment:
:
That payload is sent inside HTTP chunked framing. For example, payload bytes :\n\n are sent as:
3\r\n:\n\n\r\n
Configure SSE keepalive as a comment:
[special_routes.sse]
comment = "keepalive"Or as an event/data heartbeat:
[special_routes.sse]
event = "keepalive"
data = ""Do not combine comment with event or data.
For JSON request bodies, stream intent is detected incrementally while the body is forwarded upstream. The detector only considers the first top-level stream field; nested fields, string contents, non-JSON bodies, and later duplicate stream fields do not force SSE mode.
When a stream-intent request receives an upstream Content-Type: application/json response, json_response controls whether that JSON is converted to SSE or forwarded as JSON before any fallback header is sent:
[[special_routes]]
rewrite_http_code_5xx = 200
[special_routes.sse]
json_response = "error_event"
json_error_event = "error"json_response = "error_event" is the default. It rewrites the downstream response as text/event-stream and emits the upstream JSON body as one SSE event. The default event name is error:
event: error
data: {"error":"bad"}
json_response = "raw" forwards the upstream JSON headers and body when no fallback header has been sent yet. If the fallback header has already been sent, the proxy keeps the committed SSE response shape and still emits the upstream JSON body as the configured SSE error event.
rewrite_http_code_5xx optionally rewrites any matched special-route response header whose status is HTTP 500-599. It applies to upstream response headers, fallback headers, fixed local responses, and proxy-generated 5xx responses, and it is not overridden by JSON or SSE response shaping.
CR and LF characters are removed from json_error_event and from the upstream JSON body before emitting the SSE frame. Special-route requests that already request SSE or have JSON request bodies are forwarded upstream without Accept-Encoding, so JSON-to-SSE conversion receives a text JSON body rather than compressed bytes.
A special route can return a local response immediately without opening an upstream connection:
[[special_routes]]
paths = ["/"]
match = "exact"
methods = "HEAD"
fixed_response_header = [
"HTTP/1.1 200 OK",
"Content-Type: text/plain",
]
fixed_response_body = ""The proxy adds Content-Length from fixed_response_body. Do not include Content-Length or Transfer-Encoding in fixed_response_header.
The deploy directory contains:
build-arm64.sh: builds a static ARM64 musl binary atdeploy/app.compose.yml: runs the binary from a scratch container with a read-only filesystem and dropped capabilities.config.toml: deployment-oriented commented config.
Build the static ARM64 binary:
./deploy/build-arm64.sh
file deploy/appThe default target is aarch64-unknown-linux-musl. The build expects an ARM64 musl linker, exposed as aarch64-linux-musl-gcc unless CC_aarch64_unknown_linux_musl is overridden.
Run with Docker Compose:
cd deploy
docker compose up --buildThe Compose file mounts:
./appas/app../config.tomlas/config.toml../runas/tmp/runfor Unix sockets.
By default, the deployed process runs:
/app /config.toml
Enable info logs with RUST_LOG=info:
RUST_LOG=info cargo run --release -- config.tomlFor Docker Compose, run a one-off service with the environment variable passed into the container:
cd deploy
docker compose run --rm --service-ports -e RUST_LOG=info appOr add RUST_LOG=info under the service environment section in deploy/compose.yml for normal docker compose up runs.
Info logs include connection-scoped events with the connection id first, for example:
id=12 client_connection_open ...
id=12 special_fallback_header_sent ...
id=12 special_keepalive_sent ...
id=12 client_connection_closed ...
Use the id= field to correlate client, upstream, fallback, keepalive, and response-modification events for one request path.
Logs are intended for debugging behavior such as:
- whether a request matched a special route.
- whether upstream response headers arrived before
header_timeout. - whether fallback headers were sent.
- whether JSON or SSE keepalives were selected.
- whether original upstream response headers were rewritten for downstream delivery.
Do not put secrets in URLs. The proxy avoids logging query strings for route diagnostics, but request paths and hostnames can still be operationally sensitive.
Run the standard verification suite:
cargo fmt -- --check
cargo clippy --all-targets -- -D warnings
cargo testRun benchmarks:
cargo bench --bench proxy_benchUseful test areas:
tests/http_tests.rs: HTTP framing and parsing behavior.tests/proxy_normal_tests.rs: normal proxy behavior and chunked streaming.tests/proxy_special_tests.rs: special-route fallback, keepalive, JSON, and SSE behavior.tests/security_resilience_tests.rs: request-smuggling and malformed-framing defenses.tests/tls_websocket_tests.rs: TLS upstream and WebSocket upgrade behavior.tests/logging_tests.rs: info-level trace behavior and log redaction expectations.
cloudwait is designed to avoid common HTTP/1.1 desynchronization hazards:
- rejects duplicate
Content-Lengthrequest framing. - rejects
Content-LengthwithTransfer-Encoding. - rejects unsupported or repeated transfer codings.
- rejects request framing nominated through the
Connectionheader. - validates chunk-size lines, chunk-data terminators, and chunk trailers before forwarding unsafe bytes.
- rejects framing headers inside chunk trailers.
- enforces maximum header bytes for request and response parsing.
- uses explicit I/O timeouts and a global active-connection limit.
Operational recommendations:
- Keep
accept_invalid_certs = falseunless testing or using a controlled internal upstream. - Keep
max_header_bytes,request_header_timeout,io_timeout,upstream_wait_timeout, andmax_connectionssized for your environment. - Run
cargo auditas part of release checks. - Treat
RUST_LOG=debuglogs as potentially sensitive in production.
- Downstream protocol support is HTTP/1.1 over TCP or Unix sockets.
- HTTPS support is for upstream TCP connections, not downstream TLS termination.
- Unix upstreams support HTTP only.
- Special-route fallback headers must use chunked transfer encoding and must not include
Content-Length.
Private