churust-ratelimit 0.3.0

Rate limiting plugin (GCRA, keyed by peer address) for the Churust web framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
<p align="center">
  <img src="https://raw.githubusercontent.com/davthecoder/Churust/main/img/churust_thumb_logo.png" alt="Churust logo — a churro spiral inside a gear" width="400" />
</p>

<h1 align="center">Churust 🌀</h1>

<p align="center">
  <strong>Churro + Rust</strong> — a backend web framework inspired by Kotlin's <a href="https://ktor.io">Ktor</a>.<br />
  Simple, secure, robust, easy to learn.
</p>

<p align="center">
  <a href="https://crates.io/crates/churust"><img src="https://img.shields.io/crates/v/churust.svg" alt="crates.io" /></a>
  <a href="https://docs.rs/churust"><img src="https://docs.rs/churust/badge.svg" alt="docs.rs" /></a>
  <a href="https://github.com/davthecoder/Churust/actions/workflows/ci.yml"><img src="https://github.com/davthecoder/Churust/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
  <img src="https://img.shields.io/badge/rust-1.96%2B-blue.svg" alt="MSRV 1.96" />
  <a href="https://github.com/davthecoder/Churust/blob/main/LICENSE"><img src="https://img.shields.io/crates/l/churust.svg" alt="MIT" /></a>
</p>

Churust gives you Ktor's developer experience in Rust: an application engine, a
routing DSL, an `install(plugin)` system, and a phased interceptor pipeline —
built on a battle-tested async stack (**tokio + hyper + rustls**). Churust owns
the ergonomic layer; it does not reinvent HTTP parsing or TLS.

```rust
use churust::prelude::*;

#[churust::main]
async fn main() -> std::io::Result<()> {
    Churust::server()
        .port(8080)
        .routing(|r| {
            r.get("/", |_call: Call| async { "Hello from Churust 🌀" });
            r.get("/users/{id}", |Path(id): Path<u64>| async move {
                format!("user #{id}")
            });
        })
        .start()
        .await
}
```

## Install

```toml
[dependencies]
churust = "0.3"
```

That is the whole dependency list. Churust re-exports the runtime it is built
on as `churust::tokio`, and `#[churust::main]` uses that re-export, so no
separate `tokio` entry is needed. Add one only if you want a tokio feature
Churust does not enable — Cargo unifies the two.

Plugins and transports are opt-in features on the umbrella crate — depend on
`churust` and enable what you need rather than pulling in the plugin crates
directly:

```toml
churust = { version = "0.3", features = ["full", "ws", "fs", "tls"] }
```

| Feature | Pulls in | Gives you |
| --- | --- | --- |
| `json` | `churust-json` | `Json<T>` extractor/responder, content negotiation |
| `logging` | `churust-logging` | `CallLogging` via `tracing` |
| `cors` | `churust-cors` | preflight + CORS headers |
| `auth` | `churust-auth` | Bearer/Basic/JWT, `Principal<P>` |
| `ratelimit` | `churust-ratelimit` | `RateLimit`, GCRA, keyed on the peer address (IPv6 by prefix) |
| `compression` | `churust-compression` | brotli / gzip / deflate response bodies |
| `templates` | `churust-templates` | `Templates` + `Renderer`, minijinja, HTML-escaped whatever the template is named |
| `full` | the seven above | the whole plugin set |
| `redis` | `churust-redis` | `RedisStore`: server-side sessions, revocable |
| `client` | `churust-client` | an outbound HTTP client (`client-tls` for HTTPS) |
| `openapi` | `churust-openapi` | an OpenAPI 3.1 document from the router |
| `ws` | `churust-core/ws` | WebSocket upgrade + `WebSocket`/`Message` |
| `fs` | `churust-core/fs` | `StaticFiles`, conditional GET, byte ranges |
| `multipart` | `churust-core/multipart` | `multipart/form-data`, buffered or streamed |
| `tls` | `churust-core/tls` | rustls-backed HTTPS |
| `http3` | `churust-core/http3` | HTTP/3 over QUIC (implies `tls`) |

Default features are empty, so a plain `churust = "0.3"` compiles the core
engine and nothing else.

Every Churust crate is released in lockstep on one version number, so
`churust-core`, `churust-json`, and the rest always match the umbrella version.

## Features

- **Hybrid handlers** — write call-style (`|call: Call|`) *or* with typed
  extractors (`Path<T>` — including tuples and structs — `Query<T>`, `Form<T>`,
  `Header<T, N>`, `State<T>`, `Json<T>`, `Payload`, `Either<A, B>`,
  `BearerToken`, `Principal<P>`) — mix freely in one handler. Wrap any of them
  in `Option<T>` when the input is optional; absent is `None`, malformed is
  still an error.
- **`install(plugin)`** — Ktor-style plugins composed over a named pipeline
  (`Setup → Monitoring → Plugins → Call → Fallback`) for deterministic order,
  plus `intercept` for middleware scoped to part of the route tree.
- **Route guards** — several routes may share a path when `guard::header`,
  `guard::host` or a custom predicate distinguishes them.
- **Your own error pages**`on_error` renders any `4xx`/`5xx`, including
  routing failures.
- **Request correlation** — a request id on every log line, echoed as
  `x-request-id`, continuing an inbound W3C `traceparent`.
- **Built-in plugins** — JSON content negotiation, request logging, CORS, and
  authentication (Bearer / Basic / JWT).
- **Cookies and sessions** — a cookie primitive with safe defaults
  (`HttpOnly`, `SameSite=Lax`), and HMAC-SHA256 signed cookie sessions.
- **Streaming request bodies**`Payload` reads an upload incrementally, so it
  is not capped by memory.
- **Peer address**`call.peer_addr()` for rate limiting and audit logs.
- **Deployment knobs** — idle keep-alive, listen backlog, connection cap, TLS
  handshake cap and a deadline that bounds queueing for it as well as the
  handshake itself, HTTP/2 stream and header limits, a bounded shutdown grace
  period that actually drains, several bind addresses, and Unix domain sockets
  that refuse to unlink a live one.
- **File uploads**`Multipart` buffers the whole body; `MultipartStream` reads
  fields and their content incrementally, so memory stops scaling with upload
  size. Both behind the `multipart` feature.
- **Typed app state / DI**`.state(T)` then extract `State<T>`.
- **Layered config** — defaults < `churust.toml` < env (`CHURUST_*`) < code DSL.
- **Secure by default** — security headers on every response, including the
  `413`s, `400`s and `408`s a transport writes for itself without reaching a
  handler; body-size limits; request and header-read timeouts, the latter
  covering a connection from the moment it is accepted rather than from the
  moment a protocol is chosen, so a peer that connects and says nothing is still
  bounded (slow-loris); header and path-depth caps; WebSocket frame and message
  caps; panic isolation (a panicking handler returns 500, never crashes the
  server); no version banner; opt-in rustls TLS.
- **HTTP/1.1, HTTP/2 and HTTP/3** — h2 over TLS via ALPN, h2c by prior knowledge
  in plaintext, negotiated per connection; h3 over QUIC on its own listener
  behind the `http3` feature, with `advertise_http3` emitting the `Alt-Svc`
  header clients need in order to try it.
- **Response compression** — brotli, gzip and deflate, negotiated from
  `Accept-Encoding`, streaming bodies included (feature `compression`).
- **Rate limiting** — GCRA, so bursts are smoothed and `Retry-After` is exact
  rather than estimated. IPv6 peers are bucketed by network prefix, because a
  subscriber is handed a whole `/64` and limiting each address in it separately
  limits nobody (feature `ratelimit`).
- **Templating** — minijinja, parsed at startup, HTML-escaped whatever the
  template is called, since the rendered output is served as HTML either way
  (feature `templates`).
- **Server-side sessions** — Redis-backed, so logging out actually revokes, and
  a revocation that could not be carried out is an error rather than a cheerful
  `200` over a session that is still live (feature `redis`).
- **An HTTP client** — pooled, bounded, on the same hyper the server runs on
  (feature `client`).
- **OpenAPI 3.1** — paths and parameters from the router, prose and schemas from
  you, with drift reported in both directions (feature `openapi`).
- **Login and logout** — an identity layer over sessions with both an absolute
  and an idle deadline.
- **Correct HTTP** — automatic `HEAD` and `OPTIONS` (including `OPTIONS *`),
  one `Allow` header that both `405` and `OPTIONS` agree on, conditional GET
  (`ETag`/`Last-Modified`/`304`) and byte ranges (`206`/`416`) for static files.
- **One URL per resource**`PathPolicy` decides what happens to `//a` and
  `/a//b`: refuse (default), `308` to the canonical form, or collapse. Silent
  collapsing makes prefix-based auth checks bypassable and cache identity
  ambiguous.
- **Ambiguity is refused, not guessed** — a request carrying both
  `Transfer-Encoding` and `Content-Length` never keeps its connection,
  whichever order the two headers arrive in, so no leftover byte becomes the
  next request behind a proxy that read the length differently. This is why
  hyper `1.11` is a floor rather than a preference: earlier versions delete the
  `Content-Length` while parsing and leave the connection open, and no check
  above hyper can see what was deleted. A repeated query key on a scalar field
  is an error rather than a silent first-or-last-wins.
- **Borrow the ecosystem** — the `tower` feature runs any `tower::Service` as
  middleware, so `tower-http`'s layers work without Churust reimplementing
  them.
- **Errors that carry** — implement `IntoError` on your own error type and `?`
  works in handlers, without leaking its `Display` text to clients.
- **Hard to misuse** — a missing static root or a duplicate route fails at
  startup rather than silently at runtime; `secure_compare` for secrets.
- **Fast, in-process tests**`TestClient` drives the full pipeline without
  binding a socket.

## Workspace layout

| Crate | Docs | What it is |
|-------|------|------------|
| `churust` | [docs.rs]https://docs.rs/churust | Umbrella crate + `prelude`. Depend on this. Plugins behind features. |
| `churust-core` | [docs.rs]https://docs.rs/churust-core | Engine, routing, pipeline, `Call`, extractors, config, state, TLS, WebSockets, static files, test harness. |
| `churust-macros` | [docs.rs]https://docs.rs/churust-macros | `#[churust::main]`. |
| `churust-json` | [docs.rs]https://docs.rs/churust-json | `Json<T>` extractor/responder + `ContentNegotiation` plugin (feature `json`). |
| `churust-logging` | [docs.rs]https://docs.rs/churust-logging | `CallLogging` plugin via `tracing` (feature `logging`). |
| `churust-cors` | [docs.rs]https://docs.rs/churust-cors | `Cors` plugin — preflight + headers (feature `cors`). |
| `churust-auth` | [docs.rs]https://docs.rs/churust-auth | `Auth` (Bearer/Basic/JWT) + `Principal<P>` (feature `auth`). |
| `churust-ratelimit` | [docs.rs]https://docs.rs/churust-ratelimit | `RateLimit` plugin, GCRA (feature `ratelimit`). |
| `churust-compression` | [docs.rs]https://docs.rs/churust-compression | `Compression` plugin — brotli/gzip/deflate (feature `compression`). |
| `churust-templates` | [docs.rs]https://docs.rs/churust-templates | `Templates` + `Renderer` over minijinja (feature `templates`). |
| `churust-redis` | [docs.rs]https://docs.rs/churust-redis | `RedisStore`, a revocable server-side `SessionStore` (feature `redis`). |
| `churust-client` | [docs.rs]https://docs.rs/churust-client | Outbound HTTP client (feature `client`, HTTPS via `client-tls`). |
| `churust-openapi` | [docs.rs]https://docs.rs/churust-openapi | OpenAPI 3.1 document generation (feature `openapi`). |
| `churust-lab` | [docs.rs]https://docs.rs/churust-lab | Incubator. **Never reaches 1.0**; expect breaking changes on most releases. |

Runnable examples:

| Example | Run it | Shows |
|---------|--------|-------|
| [`examples/hello`]https://github.com/davthecoder/Churust/tree/main/examples/hello | `cargo run -p hello` | Minimal server, path params. |
| [`examples/api`]https://github.com/davthecoder/Churust/tree/main/examples/api | `cargo run -p api` | JSON CRUD with all four plugins + auth-gated routes. |
| [`examples/chat`]https://github.com/davthecoder/Churust/tree/main/examples/chat | `cargo run -p chat` | WebSocket echo endpoint and a broadcast room. |
| [`examples/static`]https://github.com/davthecoder/Churust/tree/main/examples/static | `cargo run -p static-example` | `StaticFiles` plus a streamed response body. |

## A fuller example

```rust
use churust::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;

#[derive(Clone, Serialize, Deserialize)]
struct Note { id: u64, text: String }
#[derive(Deserialize)]
struct NewNote { text: String }
#[derive(Clone)]
struct Admin { name: String }
struct Store { notes: Mutex<Vec<Note>> }

#[churust::main]
async fn main() -> std::io::Result<()> {
    Churust::server()
        .state(Store { notes: Mutex::new(Vec::new()) })
        .install(CallLogging::new())
        .install(ContentNegotiation::new())  // renders errors as JSON
        .install(Cors::new().allow_origin("http://localhost:3000"))
        .install(Auth::bearer(|token: String| async move {
            (token == "admin-token").then(|| Admin { name: "admin".into() })
        }))
        .routing(|r| {
            r.get("/notes", |s: State<Store>| async move {
                Json(s.notes.lock().unwrap().clone())
            });
            // Asking for `Principal<Admin>` enforces auth (401 otherwise).
            r.post("/notes", |Principal(_a): Principal<Admin>,
                              s: State<Store>,
                              Json(input): Json<NewNote>| async move {
                let mut notes = s.notes.lock().unwrap();
                let note = Note { id: notes.len() as u64 + 1, text: input.text };
                notes.push(note.clone());
                (StatusCode::CREATED, Json(note))
            });
        })
        .start()
        .await
}
```

```
$ curl localhost:8080/notes
[]
$ curl -X POST localhost:8080/notes -d '{"text":"hi"}'
{"error":"authentication required","status":401}
$ curl -X POST localhost:8080/notes -H 'authorization: Bearer admin-token' \
       -H 'content-type: application/json' -d '{"text":"hi"}'
{"id":1,"text":"hi"}
```

## WebSockets (feature `ws`)

Opt in with `features = ["ws"]`. A handler takes the `WebSocketUpgrade`
extractor and calls `on_upgrade` to switch the request into a bidirectional
socket (a plain GET to the route is rejected with `426 Upgrade Required`):

```rust
use churust::prelude::*;
use churust::ws::{Message, WebSocketUpgrade};

r.get("/echo", |ws: WebSocketUpgrade| async move {
    ws.on_upgrade(|mut sock| async move {
        while let Some(Ok(msg)) = sock.recv().await {
            if matches!(msg, Message::Close) || sock.send(msg).await.is_err() {
                break;
            }
        }
    })
});
```

```toml
churust = { version = "0.3", features = ["ws"] }
```

See [`examples/chat`](https://github.com/davthecoder/Churust/tree/main/examples/chat) for an echo endpoint plus a broadcast room.

## Static files & streaming (feature `fs`)

Response bodies are a `Body` — either buffered bytes or a lazy stream — so large
or dynamic payloads never have to be fully materialized in memory. `Body` is
always available; `StaticFiles` is behind the opt-in `fs` feature.

```rust
use churust::prelude::*;        // brings StaticFiles into scope under `fs`
use churust::Body;

r.get(
    "/{path...}",
    StaticFiles::dir("./public").index("index.html").handler(),
);
r.get("/numbers", |_c: Call| async {
    let chunks = futures_util::stream::iter(
        (1..=5).map(|i| Ok::<_, std::io::Error>(bytes::Bytes::from(format!("{i}\n")))),
    );
    Response::stream("text/plain", Body::from_stream(chunks))
});
```

`StaticFiles` detects the `Content-Type` from the file extension, serves an
optional `index` file for directories, rejects path traversal (`..`, absolute
paths, symlink escapes) with `404`, and streams the file in chunks. See
[`examples/static`](https://github.com/davthecoder/Churust/tree/main/examples/static).

```toml
churust = { version = "0.3", features = ["fs"] }
```

## Core concepts

### Handlers & extractors
A handler is an `async` closure/fn returning anything that implements
`IntoResponse`. Arguments are **extractors**:

- `FromCallParts` (borrow the request head) — any position: `Path<T>`,
  `Query<T>`, `Header<T, N>`, `State<T>`, `BearerToken`, `Principal<P>`.
- `FromCall` (consume the body) — last argument only: `Json<T>`, `Form<T>`,
  `Bytes`, `String`, `Payload`, `Multipart`, `Either<A, B>`, `Call`.

The split is enforced by the compiler, not by convention: the body is a
one-shot stream, so two body-consuming arguments do not compile. Every
`FromCallParts` is also usable last.

`Option<T>` works for any extractor implementing `OptionalFromCallParts`
(`Query`, `Path`, `Header`, `BearerToken`). **Absent yields `None`; malformed is
still an error** — so a typo'd query string does not quietly become a default.

`Call` itself is the call-style base case (`|call: Call| ...`).

### Pipeline & plugins
A `Plugin` registers `Middleware` into a `Phase`. Middleware own the `Call`,
may mutate it, call `next.run(call)`, and post-process the `Response` (the onion
model). Phase order is fixed and deterministic regardless of install order.

### Configuration
```toml
# churust.toml
[server]
host = "0.0.0.0"
port = 8080
max_body_bytes = 1048576
request_timeout_ms = 30000
keep_alive_ms = 75000          # idle connections; 0 answers and closes
max_connections = 25000        # connections served at once; 0 is unlimited
max_tls_handshakes = 256       # much smaller: a handshake is asymmetric work
tls_handshake_timeout_ms = 10000
shutdown_timeout_ms = 30000    # bounded drain, so exit is never held hostage
path_policy = "strict"         # strict | redirect | collapse

[tls]            # requires the `tls` feature
cert = "cert.pem"
key  = "key.pem"
```
`Churust::from_config()` loads `churust.toml` + `CHURUST_*` env vars; chained
DSL setters override. Example: `CHURUST_SERVER_PORT=9090`.

### Testing
```rust
use churust::TestClient;

let app = build_app();
let client = TestClient::new(app);
let res = client.get("/users/1").send().await;
assert_eq!(res.status(), StatusCode::OK);
```

## Development

Requires Rust **1.96+** (the MSRV, and what CI pins).

```bash
git clone https://github.com/davthecoder/Churust.git
cd Churust
cargo test --workspace
```

Warnings are errors in CI. Before opening a PR, run the full gate — the exact
command list is in [CONTRIBUTING.md](CONTRIBUTING.md#before-you-open-a-pr), and
it covers `fmt`, the clippy feature matrix, the test feature matrix, the
examples, and a docs build.

## Status

Published on crates.io — the badge above carries the current version. Pre-1.0,
so the API is settling rather than settled: expect breaking changes in minor
releases until 1.0.

Everything documented above works today:

- **Core** — routing, hybrid extractors, the four plugins, named phases, layered
  config, typed state, timeouts, TLS, `#[churust::main]`
- **WebSockets** — opt-in `ws` feature
- **Streaming bodies** — the always-on `Body` type
- **Static files**`StaticFiles`, opt-in `fs` feature

Also working, each behind its own feature: response compression, rate limiting,
templating, HTTP/3 over QUIC, Redis-backed sessions, a login/logout layer, an
outbound HTTP client, OpenAPI generation, and a streaming multipart parser.
Several of those were listed as deliberate non-goals until 2026-07-25; the
[production-readiness audit](https://github.com/davthecoder/Churust/blob/main/docs/design/2026-07-25-production-readiness-audit.md)
records what the original objection was and what the implementation does about
it, because "we changed our mind" is more useful to a reader than a silently
edited list.

**Still not supported, on purpose.** Actor integration.
`multipart/byteranges` for multi-range requests, which RFC 9110 permits omitting.
WebSockets over HTTP/3, which upgrade through Extended CONNECT (RFC 9220) rather
than the HTTP/1.1 handshake the `ws` feature implements. Revocation from a
client-side session store, which has no server-side record to withdraw — that is
what `churust-redis` is for. And `max_body_bytes` still bounds every request:
streaming changed what an upload costs in memory, not what it is allowed to
weigh.

Those are tracked and ordered in
[`docs/design/2026-07-25-roadmap-to-parity.md`](https://github.com/davthecoder/Churust/blob/main/docs/design/2026-07-25-roadmap-to-parity.md),
which also says which are deliberate scope choices and which are simply not
built yet. Want one sooner? Make the case in
[Discussions](https://github.com/davthecoder/Churust/discussions/categories/ideas).

Design specs live in
[`docs/design/`](https://github.com/davthecoder/Churust/tree/main/docs/design)
and implementation plans in
[`docs/plans/`](https://github.com/davthecoder/Churust/tree/main/docs/plans).
Those documents label work as v1 / v2.0 / v2.1 — those are internal build
milestones, not published versions.

## Contributing

Contributions are welcome — read [CONTRIBUTING.md](CONTRIBUTING.md) first. The
short version: Churust keeps a narrow scope, tests come first, and anything
optional is feature-gated so default builds never change.

| | |
| --- | --- |
| Ask a question | [Discussions → Q&A]https://github.com/davthecoder/Churust/discussions/categories/q-a |
| Propose a feature | [Discussions → Ideas]https://github.com/davthecoder/Churust/discussions/categories/ideas |
| Report a bug | [Issues]https://github.com/davthecoder/Churust/issues/new/choose |
| Report a vulnerability | [SECURITY.md]SECURITY.md — privately, never a public issue |
| Get help | [SUPPORT.md]SUPPORT.md |
| Cut a release (maintainers) | [RELEASING.md]RELEASING.md |

Everyone taking part is held to the [Code of Conduct](CODE_OF_CONDUCT.md).

## Sponsor

Churust is built and maintained in spare time. If it saves you some, you can
support the work through [GitHub Sponsors](https://github.com/sponsors/davthecodercom).

Sponsorship funds maintenance — issue triage, security response, keeping up with
tokio/hyper/rustls releases — not feature bounties. Features are decided on
merit in [Discussions](https://github.com/davthecoder/Churust/discussions),
and that stays true regardless of who is sponsoring.

## License

MIT — see [LICENSE](LICENSE).