astor 0.2.1

A fast, minimal HTTP framework for reverse-proxy deployments
Documentation

astor

Crates.io docs.rs License: MIT CI

HTTP for Rust services behind a reverse proxy. Does its job. Goes home.

Two dependencies — [matchit] for routing, tokio for async I/O. No hyper. No http crate. No middleware stack you didn't ask for. astor routes requests, builds typed responses, and stays out of every problem the proxy already solved.


nginx handles this. astor doesn't.

astor is designed for the common deployment: your service lives behind nginx or ingress-nginx. The proxy already solved the hard problems. Re-implementing them in the framework is waste.

  • body sizeclient_max_body_size in nginx ✓
  • header sizelarge_client_header_buffers in nginx ✓
  • rate limitinglimit_req_zone / ingress-nginx annotations ✓
  • slow clientsclient_body_timeout, client_header_timeout in nginx ✓
  • TLS — nginx SSL / k8s ingress TLS ✓
  • HTTP/2 + HTTP/3 to clients — nginx negotiates; astor speaks HTTP/1.1 upstream ✓

What astor covers — the only part that changes between applications:

  • Routing — radix tree via [matchit], O(path-length) lookup, no allocations on the hot path
  • Async I/O — raw tokio, no hyper
  • Graceful shutdown — SIGTERM / Ctrl-C, drains in-flight requests before exit

Quick start

# Cargo.toml
[dependencies]
astor = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
use astor::{Method, Request, Response, Router, Server, Status};

#[tokio::main]
async fn main() {
    let app = Router::new()
        .on(Method::Delete, "/users/{id}", delete_user)
        .on(Method::Get,    "/users/{id}", get_user)
        .on(Method::Post,   "/users",      create_user);

    Server::bind("0.0.0.0:3000").serve(app).await.unwrap();
}

// req.param("id") → Option<&str>. Path params use {name} syntax.
async fn get_user(req: Request) -> Response {
    let id = req.param("id").unwrap_or("unknown");
    Response::json(format!(r#"{{"id":"{id}"}}"#).into_bytes())
}

// req.body() → &[u8]. Parse with serde_json, simd-json, or anything else.
async fn create_user(req: Request) -> Response {
    if req.body().is_empty() {
        return Response::status(Status::BadRequest);
    }
    Response::builder()
        .status(Status::Created)
        .header("location", "/users/99")
        .json(r#"{"id":"99"}"#.to_owned().into_bytes())
}

// Return Status directly from a handler — astor wraps it into a response.
async fn delete_user(_req: Request) -> Status { Status::NoContent }

Status codes are a type, not a number

astor has no free-form response constructor. You cannot pass a raw integer where a status code goes — the compiler stops you. Every status code is a named variant you can tab-complete, grep, and reason about.

use astor::{Response, Status};

// Named. Clear. Greppable. The compiler knows these are correct.
Response::status(Status::NoContent)   // 204 — not "204", not 204, not 20_4
Response::status(Status::NotFound)    // 404
Response::status(Status::Created)     // 201

// The builder enforces the same contract. Explicit at every step.
// Not response(201, bytes) — there are no magic integers here.
Response::builder()
    .status(Status::Created)
    .header("location", "/users/42")
    .json(bytes)

// Return Status directly from a handler — no Response construction needed.
async fn delete_user(_req: Request) -> Status { Status::NoContent }

Every IANA-registered code from 100 to 511 is a named variant — nothing more, nothing less. Full list on docs.rs/astor.


Routing

Paths use {name} parameter syntax. Multiple parameters per path are supported. Each on() call returns self — registrations chain.

use astor::Method;

Router::new()
    .on(Method::Delete,  "/users/{id}",            delete_user)
    .on(Method::Get,     "/orgs/{org}/repos/{repo}", get_repo)
    .on(Method::Get,     "/users/{id}",             get_user)
    .on(Method::Options, "/users",                  options_users)
    .on(Method::Patch,   "/users/{id}",             update_user)
    .on(Method::Post,    "/users",                  create_user);

Retrieve parameters inside the handler with req.param("name"). Unmatched routes return 404 Not Found automatically. Unknown method strings are rejected before they reach a handler.


Deployment

Local development

cargo run --example basic
curl http://localhost:3000/users/42

With nginx

client ──(h2/h1.1)──► nginx ──(HTTP/1.1 keep-alive pool)──► astor

nginx handles TLS, rate limiting, slow clients, and body-size limits. astor does not — by design. The configuration below is what makes that contract work.

Keep-alive pool

nginx reuses TCP connections to astor instead of opening a new one per request. astor loops on each connection until nginx closes it — it never inspects the Connection header.

upstream astor_backend {
    server 127.0.0.1:3000;

    keepalive 64;            # idle connections per worker
    keepalive_requests 1000; # recycle after N requests
    keepalive_timeout  60s;  # close idle connections after this long
}

Rule of thumb for keepalive: (expected RPS / nginx workers) × avg request duration (s). Too low → TCP churn under load. Too high → idle file descriptors accumulate.

Required location block

location / {
    # nginx forwards ALL methods by default — including unknown garbage.
    # List every method your app uses. Everything else gets 405.
    limit_except GET POST PUT PATCH DELETE OPTIONS HEAD CONNECT TRACE {
        return 405;
    }

    proxy_pass         http://astor_backend;

    # Required for keep-alive: HTTP/1.1 + clear the default "close" header.
    proxy_http_version 1.1;
    proxy_set_header   Connection  "";

    # astor reads Content-Length-framed bodies only.
    # Do not set proxy_buffering off — chunked bodies will not be read.
    proxy_buffering    on;

    # Body size, slow-client protection, and rate limiting belong here —
    # nginx enforces them before the request reaches astor.
    client_max_body_size  10m;
    client_body_timeout   30s;
    client_header_timeout 10s;
}

On Kubernetes

ingress-nginx is an nginx instance — the same rules above apply. Set the equivalent annotations on your Ingress resource.

The one astor-specific k8s requirement is terminationGracePeriodSeconds. On SIGTERM, astor stops accepting new connections and drains in-flight requests before exiting. If this value is shorter than your slowest request, k8s sends SIGKILL while requests are still running — that is not graceful shutdown.

spec:
  terminationGracePeriodSeconds: 30  # must be longer than your slowest request
  containers:
    - name: app
      image: your-registry/your-app:latest
      livenessProbe:
        httpGet: { path: /healthz, port: 3000 }  # register this route in your app
      readinessProbe:
        httpGet: { path: /readyz, port: 3000 }   # register this route in your app

Full API reference — types, methods, and examples: docs.rs/astor


Contributing

Contributions are welcome. Read CONTRIBUTING.md before opening a PR. See CHANGELOG.md for release history.


License

MIT