astor
Minimal HTTP framework for Rust. Lives behind nginx. Does its job. Goes home.
Your nginx handles TLS. Your nginx handles rate limiting. Your nginx handles slow clients, body sizes, and half the other things frameworks love to re-implement.
So what exactly is your framework supposed to duplicate?
Nothing. astor doesn't touch any of that. The proxy does proxy things. The framework does framework things. This is not a controversial opinion.
The deal
astor sits behind nginx or ingress-nginx. The proxy covers the hard, boring, already-solved stuff. astor covers your routes.
What the proxy already owns — and why we sleep soundly knowing it:
| nginx / ingress handles this | what astor thinks about it |
|---|---|
| Body-size limits | client_max_body_size in nginx. Done. |
| HTTP/2 + HTTP/3 to clients | nginx negotiates protocol. astor speaks plain HTTP/1.1. |
| Rate limiting | limit_req or ingress-nginx annotations. Not our concern. |
| Slow-client & DDoS protection | nginx timeouts and buffers. We trust nginx. |
| TLS termination | nginx SSL / k8s ingress TLS. Obviously. |
What's left for astor — which is, coincidentally, the only part that changes between applications:
| What | How |
|---|---|
| Async I/O | tokio |
| Graceful shutdown | SIGTERM + Ctrl-C — drains in-flight requests before exit |
| Health probes | /healthz and /readyz built in |
| Radix-tree routing | [matchit] — O(path-length) lookup |
| Structured logging | [tracing] crate |
Quick start
# Cargo.toml
[]
= "0.1"
= { = "1", = ["rt-multi-thread", "macros"] }
use ;
async
async
Routing
router
.delete
.get
.get
.patch
.post
.route; // arbitrary method
Path parameters via req.param("name"):
async
Responses
Status codes
All status codes go through Status. Every IANA-registered code is a named variant — no magic integers:
use Status;
Ok // 200
Created // 201
NoContent // 204
BadRequest // 400
Unauthorized // 401
NotFound // 404
UnprocessableContent // 422
TooManyRequests // 429
InternalServerError // 500
ServiceUnavailable // 503
Shortcuts — 200 OK, no custom headers needed
use ;
// JSON — bytes from your serialiser, directly. No intermediate allocation.
// serde_json: Response::json(serde_json::to_vec(&val).unwrap())
// hand-built: Response::json(format!(r#"{{"id":{id}}}"#).into_bytes())
json
// Plain text
text
// No body
status
status
Builder — custom status or extra headers
Ends with a typed body call. You always know exactly what you're sending.
use ;
// 201 Created with Location header, JSON body
builder
.status
.header
.json
// 301 redirect — no body
builder
.status
.header
.no_body
// Any content-type via the ContentType enum
builder
.status
.bytes
ContentType enum
| Variant | Content-Type header |
|---|---|
ContentType::Csv |
text/csv |
ContentType::EventStream |
text/event-stream |
ContentType::FormData |
application/x-www-form-urlencoded |
ContentType::Html |
text/html; charset=utf-8 |
ContentType::Json |
application/json |
ContentType::MsgPack |
application/msgpack |
ContentType::OctetStream |
application/octet-stream |
ContentType::Pdf |
application/pdf |
ContentType::Text |
text/plain; charset=utf-8 |
ContentType::Xml |
application/xml |
Reading request bodies
req.body() returns &[u8]. Parse it however you want — astor never touches the bytes:
async
Custom return types with IntoResponse
Implement IntoResponse on your own types and return them directly from handlers. No Response construction scattered across every call site:
use ;
use Serialize;
;
// Handler return type is inferred — no Response construction at the call site.
async
Built-in IntoResponse impls: Response, String, &'static str, Status.
// Return Status directly — astor wraps it. No boilerplate.
async
Health checks
Kubernetes needs to know if your pod is alive and ready. Two endpoints. Always 200 if the process can respond. That's it.
use ;
let app = new
.get // is the process alive?
.get; // ready to serve traffic?
Custom readiness to gate on dependency health:
async
Deployment
Local development
RUST_LOG=info
With nginx
See nginx/nginx.conf for a production-ready configuration.
How keep-alive works — and why astor doesn't manage it:
client ──(h2/h1.1)──► nginx ──(HTTP/1.1 keep-alive pool)──► astor
nginx maintains a pool of idle TCP connections to astor. Requests reuse those connections — no handshake per request. astor loops on each connection until nginx closes it. Connection lifetime is nginx's business. astor doesn't inspect the Connection header, and it never will.
Required proxy settings:
proxy_http_version 1.1;
proxy_set_header Connection ""; # clears nginx's default "close", enabling keep-alive
client_max_body_size 10m; # enforced by nginx, not astor
# REQUIRED — do not set to off.
# astor only reads Content-Length-framed bodies. proxy_buffering on (the default)
# guarantees nginx buffers the full request body before forwarding it.
proxy_buffering on;
Tuning the upstream connection pool (in the upstream block):
upstream astor_backend {
server 127.0.0.1:3000;
keepalive 64; # idle connections per worker — raise if you see TCP churn
keepalive_requests 1000; # recycle connection after N requests (default 1000)
keepalive_timeout 60s; # close idle connection after this long (default 60s)
}
Rule of thumb for keepalive: (expected RPS / nginx workers) × avg request duration (s).
Too low → pool exhausts under load, new TCP connections are opened.
Too high → idle file descriptors accumulate across workers.
On Kubernetes
See the manifests in k8s/:
| File | Purpose |
|---|---|
deployment.yaml |
Pod spec with probes and terminationGracePeriodSeconds |
ingress.yaml |
ingress-nginx with TLS, body-size, and keepalive annotations |
service.yaml |
ClusterIP service on port 3000 |
Required: set terminationGracePeriodSeconds longer than your slowest request. Otherwise k8s SIGKILLs the pod before astor finishes draining. That is not graceful shutdown.
spec:
terminationGracePeriodSeconds: 30 # adjust to your workload
containers:
- name: app
image: your-registry/your-app:latest
livenessProbe:
httpGet:
readinessProbe:
httpGet:
A note on ordering
Everything in this codebase that can be alphabetically ordered, is. Enum variants. Function parameters. Struct fields. Imports. Table rows. Everything.
This is not a stylistic preference. It is a rule. When things are ordered, you stop thinking about where they are and start thinking about what they do. You search for Html in the ContentType enum and your eye goes straight to the Hs. You add a new variant and you know exactly where it lives. No debates, no "should this go before or after that" — alphabetical order is always the right answer and it is never wrong.
If you open a PR and something that could be alphabetically ordered is not, it will be sent back.
Contributing
Contributions are welcome. Read CONTRIBUTING.md before opening a PR. See CHANGELOG.md for release history.
License
MIT