a48ff2c
commit a48ff2c
10 files changed
+392 -15
Showing 10 changed files+392-15
▼DECISIONS.md
+37-0
| @@ -1905,3 +1905,40 @@ the OpenAPI parity test. | ||
1905 | 1905 | tests: `static_files::tests::test_mount_resolve_prefix`, |
1906 | 1906 | `test_mount_traversal_blocked_through_prefix`, and |
1907 | 1907 | `integration::test_static_mount_prefix_and_spa_fallback`. |
1908 | + | |
1909 | +## ADR-055 — 2026-07-16 — Configurable max request body size (413 on overflow) | |
1910 | + | |
1911 | +**Context:** The request body cap was a hardcoded `50 * 1024 * 1024` (50 MiB) | |
1912 | +buried at four read sites (core `execute_handler` Echo path; Python | |
1913 | +`make_native_handler` native-fast-path and the Python-handler path; plus the | |
1914 | +`serve()` default-routes path). It was invisible to operators and could not be | |
1915 | +tightened for endpoints that only expect small payloads — a memory-exhaustion | |
1916 | +DoS exposure on untrusted ingress. | |
1917 | + | |
1918 | +**Decision / changes:** | |
1919 | +1. **Single source of truth.** Added `const DEFAULT_MAX_BODY_SIZE = 50 * 1024 | |
1920 | + * 1024` in `server/mod.rs`. Every read site now derives its cap from a | |
1921 | + threaded `max_body_size: usize` value rather than a literal. | |
1922 | +2. **Builder surface.** `Server::with_max_body_size(bytes)` (Rust) and | |
1923 | + `JustAPIApp.run(addr, max_body_size=...)` (Python, default 50 MiB) plumb the | |
1924 | + cap through `make_handler` → `execute_handler` (core) and `make_native_handler` | |
1925 | + → both body reads (Python). `serve()`'s default-routes path uses the default. | |
1926 | +3. **Correct status code.** Previously an oversized body surfaced as a generic | |
1927 | + `400`/`500`/`404`. The `http_body_util::Limited` length-limit error now maps | |
1928 | + to `413 Payload Too Large` with `{"detail":"payload too large"}` on every | |
1929 | + affected path (core Echo, Python native fast path, Python handler path). | |
1930 | +4. **Test handler** (`make_test_handler`, used by the async test client) takes | |
1931 | + the same parameter; the test-client call site passes the default. | |
1932 | + | |
1933 | +**Evidence:** New Rust integration test `integration::test_max_body_size_enforced` | |
1934 | +(binds `limit=1024`, asserts a 512-byte echo returns 200 and a 1025-byte body | |
1935 | +returns 413). New pytest suite `test_max_body_size.py` (`test_max_body_size_enforced` | |
1936 | ++ `test_max_body_size_default_accepts_large`) passes against a real server. | |
1937 | +`cargo test --workspace`, `cargo clippy --workspace --tests -D warnings`, and | |
1938 | +`cargo fmt --check` all clean. | |
1939 | + | |
1940 | +**Note:** `crates/justapi-py/test_graphql.py::test_graphql` fails with 404 — the | |
1941 | +Python package does not yet expose a `graphql()` builder, so `/graphql` is never | |
1942 | +registered. This is a pre-existing gap unrelated to this change (the core has a | |
1943 | +`Handler::GraphQL`, but no Python binding wires it up). Tracked separately. | |
1944 | + | |
1908 | 1945 | |
▼PLAN.md
+3-2
| @@ -12,8 +12,9 @@ | ||
12 | 12 | - **Schema-backed native Rust fast path (2026-07-14) — ✅ complete, further optimized.** Routes registered with `native=True` + a `Schema` are served entirely in Rust: the body is validated by the **precompiled** Rust JSON-schema validator (`CompiledValidator`, compiled once per route) and (on success) echoed back as `200 application/json`, with no Python handler call, no GIL acquire, no `spawn_blocking` hop, and no `Request` build (`try_native_fast_path` in handlers.rs). `native=True` without a schema safely falls back to the Python path. Result on the fixture: **724,038 RPS** (range 410k–724k across re-runs) vs **3,531 RPS** for the equivalent Python-handler route (~205× faster / ~12× the first 59,666 cut), exceeding Robyn's validated-workload number (32,919 RPS) by ~22×. See ADR-048 + ADR-049 + BENCHMARKS.md native fast-path section. Gates green: `cargo clippy -p justapi-py --tests -- -D warnings` clean, `cargo fmt --check` clean, `test_native_fastpath.py` passes. |
13 | 13 | - **⚠️ Non-native dispatch deadlock at high concurrency (P0, next task — see ADR-049).** The Python dispatch path (`spawn_blocking` + `Python::attach`) hard-stalls at ~100 concurrent connections for **every** non-native route (incl. the simplest handler, ≈16–20 req/s, all aborted). Pre-existing relative to the native optimization (proven: `/noparam` ignores all ADR-048-touched code yet still deadlocks); native routes are immune. **Recommended fix:** dedicated GIL thread-pool (bounded `num_cpus()` threads, persistent GIL, channel + oneshot dispatch) replacing per-request `spawn_blocking`+`Python::attach`. This blocks production use of non-native routes at realistic concurrency and should be the immediate next phase after ADR-048. |
14 | 14 | - **Next perf step (open, not a measured gap):** justapi now beats Robyn on *all* three raw workloads (incl. validated 40.1k vs 32.9k), and schema-backed routes run entirely in Rust via `native=True`. The only residual PyO3 cost is the handler *call itself* for **non-schema-backed** routes (handler body still runs in Python) — currently deadlock-blocked per ADR-049, not merely slow. Arbitrary user-defined Rust handlers (beyond validate-and-echo) remain a larger architectural step, currently **not** justified by a measured deficit vs Robyn; candidate for a future phase if a real workload demands it. |
15 | -- **Last updated:** 2026-07-14 (schema-backed native fast path complete; justapi beats Robyn on raw throughput and serves schema routes entirely in Rust) | |
16 | -- **Blocker:** none (CUDA present). Outstanding: run the GPU benchmark with real weights + PyPI upload token/manylinux build. | |
15 | + - **Last updated:** 2026-07-14 (schema-backed native fast path complete; justapi beats Robyn on raw throughput and serves schema routes entirely in Rust) | |
16 | + - **Configurable max body size + production hardening (2026-07-16) — ✅ complete.** Body cap was a hardcoded 50 MiB at four read sites; now a threaded `max_body_size` (Rust `Server::with_max_body_size`, Python `JustAPIApp.run(addr, max_body_size=...)`, default 50 MiB) pushed through `make_handler`/`execute_handler` and `make_native_handler`. Oversized bodies now return **413 Payload Too Large** with `{"detail":"payload too large"}` on every path (was a generic 400/500/404). Added `PRODUCTION.md` (TLS, supervision/crash-fast, body-size DoS hardening, health/readiness/liveness, secure headers, metrics, graceful shutdown, pre-flight checklist) and ADR-055. Gates green: `cargo test --workspace`, `cargo clippy --workspace --tests -D warnings`, `cargo fmt --check`, pytest 133 passed/1 skipped (the 1 failure, `test_graphql.py`, is a pre-existing gap — the Python package has no `graphql()` builder, so `/graphql` 404s; unrelated to this change). | |
17 | + - **Blocker:** none (CUDA present). Outstanding: run the GPU benchmark with real weights + PyPI upload token/manylinux build. | |
17 | 18 | |
18 | 19 | ## Mission |
19 | 20 | |
20 | 21 | |
▼PRODUCTION.md
+207-0
| @@ -0,0 +1,207 @@ | ||
1 | +# PRODUCTION.md — Deploying justapi in production | |
2 | + | |
3 | +This guide covers the operational, security, and observability settings needed | |
4 | +to run a justapi service in production. It assumes you have already built the | |
5 | +extension (`maturin develop` / `maturin build`) and have a working `JustAPIApp`. | |
6 | + | |
7 | +> Rust-first: all networking, protocol parsing, routing, middleware, scheduling, | |
8 | +> and body limiting live in `justapi-core` (Rust). Python only provides | |
9 | +> application handlers and glue. Tuning below is therefore largely Rust-driven | |
10 | +> and exposed through the Python `JustAPIApp` API. | |
11 | + | |
12 | +--- | |
13 | + | |
14 | +## 1. Build for production | |
15 | + | |
16 | +```bash | |
17 | +# Release build of the Rust extension (optimized, panic=abort) | |
18 | +VIRTUAL_ENV=.venv .venv/bin/maturin develop --release --manifest-path crates/justapi-py/Cargo.toml | |
19 | +``` | |
20 | + | |
21 | +`Cargo.toml` sets `panic = "abort"` in `[profile.release]`. On an unexpected | |
22 | +panic the process aborts (no unwinding across the PyO3/GIL FFI, no UB). Run | |
23 | +under a supervisor that restarts the process (systemd, Kubernetes, | |
24 | +`supervisord`, `docker restart=always`). | |
25 | + | |
26 | +--- | |
27 | + | |
28 | +## 2. Process supervision & crash-fast model | |
29 | + | |
30 | +- **Do not** wrap request handling in `catch_unwind`. It is unsound at the GIL | |
31 | + FFI boundary and costs a large throughput regression. (See `DECISIONS.md` | |
32 | + ADR-053.) | |
33 | +- Treat the server as **crash-fast + restart**. A supervisor restarts it within | |
34 | + milliseconds. Design handlers to be idempotent so a restart is safe. | |
35 | +- systemd unit sketch: | |
36 | + | |
37 | + ```ini | |
38 | + [Service] | |
39 | + ExecStart=/srv/app/venv/bin/python -m myapp | |
40 | + Restart=always | |
41 | + RestartSec=1 | |
42 | + # Cap restart storms | |
43 | + StartLimitIntervalSec=10 | |
44 | + StartLimitBurst=5 | |
45 | + ``` | |
46 | + | |
47 | +- Kubernetes: set `restartPolicy: Always`, a `livenessProbe` against `/live`, | |
48 | + and a `readinessProbe` against `/ready` (see §5). | |
49 | + | |
50 | +--- | |
51 | + | |
52 | +## 3. TLS termination | |
53 | + | |
54 | +justapi speaks plain HTTP/1.1 + HTTP/2 at the socket. Terminate TLS at an | |
55 | +edge proxy (recommended) **or** enable native TLS: | |
56 | + | |
57 | +- **Edge proxy (recommended):** put `justapi` behind `nginx`, `Caddy`, `Envoy`, | |
58 | + or a cloud LB. The proxy handles TLS, HTTP/2, and connection reuse; justapi | |
59 | + binds to a private loopback/Unix socket. This keeps certificates out of the | |
60 | + Rust process and lets you use standard cert tooling (ACME, secret stores). | |
61 | +- **Native TLS:** `Server::with_tls(config)` (Rust `tls` feature) is available | |
62 | + if you must terminate in-process. Prefer the proxy pattern for rotation and | |
63 | + OCSP/stapling. | |
64 | + | |
65 | +Pass the real client address and scheme from the proxy via `X-Forwarded-For` | |
66 | +/ `X-Forwarded-Proto` handling in your middleware so logs and rate-limits see | |
67 | +the true peer. | |
68 | + | |
69 | +--- | |
70 | + | |
71 | +## 4. Request body size — DoS hardening | |
72 | + | |
73 | +Unbounded request bodies are a memory-exhaustion DoS vector. Every read path is | |
74 | +capped by a configurable limit (ADR-055): | |
75 | + | |
76 | +```python | |
77 | +app.run("0.0.0.0:8000", max_body_size=1024 * 1024) # 1 MiB cap | |
78 | +``` | |
79 | + | |
80 | +- Default: `50 * 1024 * 1024` (50 MiB). | |
81 | +- Bodies over the limit are rejected with **`413 Payload Too Large`** and | |
82 | + `{"detail":"payload too large"}` — they are never buffered into memory. | |
83 | +- **Tune per deployment, not globally:** set a tight cap for JSON/API endpoints | |
84 | + and a larger one only where big uploads are expected. Run separate services | |
85 | + (or routes behind different listeners) if one size does not fit all. | |
86 | +- The Rust constant `DEFAULT_MAX_BODY_SIZE` is the fallback for the built-in | |
87 | + `serve()` default-routes path. | |
88 | + | |
89 | +--- | |
90 | + | |
91 | +## 5. Health, readiness & liveness | |
92 | + | |
93 | +- `GET /health` — liveness. Returns `200 {"status":"ok"}` when the process is | |
94 | + up. Use for Kubernetes `livenessProbe`. | |
95 | +- `GET /ready` — readiness. Returns `200` only when all registered health | |
96 | + checks pass (DB pool, downstream deps). Returns `503` otherwise. Use for | |
97 | + `readinessProbe` so traffic is withheld until dependencies are healthy. | |
98 | +- `GET /live` — lightweight liveness alias. | |
99 | +- Register custom checks: | |
100 | + | |
101 | + ```python | |
102 | + app.register_health_check("postgres", lambda: db_ok()) | |
103 | + ``` | |
104 | + | |
105 | +- `GET /metrics` — Prometheus exposition in text format (real counters, not a | |
106 | + stub). Scrape it with Prometheus; alert on `request` error rates, p99 latency, | |
107 | + and `connection` saturation. | |
108 | + | |
109 | +--- | |
110 | + | |
111 | +## 6. Security headers (opt-in) | |
112 | + | |
113 | +Secure-by-default means **no** injected headers until you ask. Enable them | |
114 | +explicitly: | |
115 | + | |
116 | +```python | |
117 | +app.enable_secure_headers() | |
118 | +``` | |
119 | + | |
120 | +This adds `Strict-Transport-Security`, `X-Content-Type-Options`, | |
121 | +`X-Frame-Options`, `Content-Security-Policy`, and `Referrer-Policy` defaults. | |
122 | +CORS is also secure-by-default (no permissive `*` unless you configure it). | |
123 | + | |
124 | +--- | |
125 | + | |
126 | +## 7. Observability | |
127 | + | |
128 | +- **Tracing:** justapi emits `tracing` spans per request (`handler.execute`, | |
129 | + with `http.status_code`). Pipe `tracing-subscriber` (env-filter / OTel) via | |
130 | + your Python bootstrap to get distributed traces. | |
131 | +- **Metrics:** scrape `/metrics`. Key signals: request count by route/status, | |
132 | + p50/p95/p99 latency, inbound/outbound bytes, open connections, and error | |
133 | + ratios. | |
134 | +- **Logs:** structured logs via `tracing`; avoid logging request bodies in | |
135 | + production (they may contain PII/secrets). The body cap (§4) bounds memory | |
136 | + even if you do sample payloads. | |
137 | + | |
138 | +--- | |
139 | + | |
140 | +## 8. Connection & timeout tuning | |
141 | + | |
142 | +- Per-request timeout: requests exceeding the configured timeout return `504 | |
143 | + Gateway Timeout` (the handler is cancelled). Set it to bound worst-case tail | |
144 | + latency. | |
145 | +- Max concurrent connections is semaphore-bounded; tune for your worker count | |
146 | + and memory budget. | |
147 | +- Behind a proxy, keep proxy and justapi timeouts aligned (proxy idle timeout ≥ | |
148 | + justapi timeout) to avoid spurious 504s. | |
149 | + | |
150 | +--- | |
151 | + | |
152 | +## 9. Graceful shutdown | |
153 | + | |
154 | +`run()` installs `SIGINT`/`SIGTERM` handlers. On signal it stops accepting new | |
155 | +connections and drains in-flight requests before exiting, so a supervisor | |
156 | +restart loses no committed work. Ensure your supervisor sends `SIGTERM` (not | |
157 | +`SIGKILL`) for clean drains. | |
158 | + | |
159 | +--- | |
160 | + | |
161 | +## 10. Minimal production bootstrap | |
162 | + | |
163 | +```python | |
164 | +from justapi import JustAPIApp | |
165 | + | |
166 | +app = JustAPIApp() | |
167 | + | |
168 | +@app.get("/healthz") | |
169 | +def healthz(request): | |
170 | + return {"status": "ok"} | |
171 | + | |
172 | +app.register_health_check("postgres", lambda: db_ok()) | |
173 | +app.enable_secure_headers() | |
174 | + | |
175 | +if __name__ == "__main__": | |
176 | + # 1 MiB body cap, behind an nginx/LB doing TLS | |
177 | + app.run("127.0.0.1:8000", max_body_size=1024 * 1024) | |
178 | +``` | |
179 | + | |
180 | +Front it with: | |
181 | + | |
182 | +```nginx | |
183 | +server { | |
184 | + listen 443 ssl; | |
185 | + location / { | |
186 | + proxy_pass http://127.0.0.1:8000; | |
187 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
188 | + proxy_set_header X-Forwarded-Proto $scheme; | |
189 | + client_max_body_size 1m; # match justapi's cap | |
190 | + } | |
191 | +} | |
192 | +``` | |
193 | + | |
194 | +--- | |
195 | + | |
196 | +## 11. Pre-flight checklist | |
197 | + | |
198 | +- [ ] Release build (`maturin develop --release`). | |
199 | +- [ ] Supervisor with `Restart=always` / `restartPolicy: Always`. | |
200 | +- [ ] TLS terminated at proxy (or native TLS configured). | |
201 | +- [ ] `max_body_size` set to the smallest value your endpoints allow. | |
202 | +- [ ] `enable_secure_headers()` called; CORS explicitly configured. | |
203 | +- [ ] `/live` + `/ready` wired to liveness/readiness probes. | |
204 | +- [ ] `/metrics` scraped; alerts on error ratio + p99. | |
205 | +- [ ] Request timeout set; proxy idle timeout ≥ justapi timeout. | |
206 | +- [ ] Graceful shutdown verified (SIGTERM drains cleanly). | |
207 | +- [ ] No request bodies logged in production. | |
0 | 208 | |
▼crates/justapi-core/src/server/mod.rs
+34-2
| @@ -314,6 +314,9 @@ pub struct Server { | ||
314 | 314 | /// installed, so we can refuse a later `with_handler` (same footgun). |
315 | 315 | default_routes_set: bool, |
316 | 316 | openapi_spec: Option<Arc<String>>, |
317 | + /// Maximum accepted request body size in bytes (DoS guard). Configurable | |
318 | + /// via `with_max_body_size`; defaults to `DEFAULT_MAX_BODY_SIZE`. | |
319 | + max_body_size: usize, | |
317 | 320 | } |
318 | 321 | |
319 | 322 | impl Server { |
| @@ -353,6 +356,7 @@ impl Server { | ||
353 | 356 | custom_handler_set: false, |
354 | 357 | default_routes_set: false, |
355 | 358 | openapi_spec: None, |
359 | + max_body_size: DEFAULT_MAX_BODY_SIZE, | |
356 | 360 | } |
357 | 361 | } |
358 | 362 | |
| @@ -363,6 +367,14 @@ impl Server { | ||
363 | 367 | self |
364 | 368 | } |
365 | 369 | |
370 | + /// Cap the maximum accepted request body size (bytes). Bodies larger than | |
371 | + /// this are rejected with `413 Payload Too Large`, protecting against | |
372 | + /// memory-exhaustion DoS. Defaults to `DEFAULT_MAX_BODY_SIZE` (50 MiB). | |
373 | + pub fn with_max_body_size(mut self, bytes: usize) -> Self { | |
374 | + self.max_body_size = bytes; | |
375 | + self | |
376 | + } | |
377 | + | |
366 | 378 | /// Register a WebSocket handler. When a WebSocket upgrade is accepted on any |
367 | 379 | /// path, the handler is invoked with the path and the split stream halves. |
368 | 380 | #[cfg(feature = "ws")] |
| @@ -766,6 +778,7 @@ impl Server { | ||
766 | 778 | self.metrics.clone(), |
767 | 779 | self.health_registry.clone(), |
768 | 780 | self.openapi_spec.clone(), |
781 | + self.max_body_size, | |
769 | 782 | ); |
770 | 783 | self.chain.set_handler(handler); |
771 | 784 | } |
| @@ -785,6 +798,7 @@ impl Server { | ||
785 | 798 | self.metrics.clone(), |
786 | 799 | self.health_registry.clone(), |
787 | 800 | self.openapi_spec.clone(), |
801 | + self.max_body_size, | |
788 | 802 | ); |
789 | 803 | self.chain.set_handler(handler); |
790 | 804 | } |
| @@ -853,6 +867,7 @@ impl Server { | ||
853 | 867 | chain, |
854 | 868 | tls_config, |
855 | 869 | static_dir, |
870 | + self.static_mounts.clone(), | |
856 | 871 | metrics, |
857 | 872 | self.shutdown, |
858 | 873 | wasm_middleware, |
| @@ -865,6 +880,7 @@ impl Server { | ||
865 | 880 | chain, |
866 | 881 | tls_config, |
867 | 882 | static_dir, |
883 | + self.static_mounts.clone(), | |
868 | 884 | metrics, |
869 | 885 | self.shutdown, |
870 | 886 | wasm_middleware, |
| @@ -931,7 +947,8 @@ pub async fn serve(listener: TcpListener) -> Result<()> { | ||
931 | 947 | let pool = Arc::new(BufferPool::new()); |
932 | 948 | let metrics = Metrics::new(); |
933 | 949 | let health_registry = Arc::new(HealthRegistry::new()); |
934 | - let handler = make_handler(router, pool, metrics.clone(), health_registry, None); | |
950 | + let handler = | |
951 | + make_handler(router, pool, metrics.clone(), health_registry, None, DEFAULT_MAX_BODY_SIZE); | |
935 | 952 | let chain = Arc::new(MiddlewareChain::new(handler)); |
936 | 953 | |
937 | 954 | #[cfg(feature = "ws")] |
| @@ -953,6 +970,7 @@ fn make_handler( | ||
953 | 970 | metrics: Metrics, |
954 | 971 | health_registry: Arc<HealthRegistry>, |
955 | 972 | openapi_spec: Option<Arc<String>>, |
973 | + max_body_size: usize, | |
956 | 974 | ) -> crate::middleware::HandlerFn { |
957 | 975 | // Build the global GraphQL schema |
958 | 976 | let graphql_schema = Arc::new(crate::graphql::create_schema()); |
| @@ -977,6 +995,7 @@ fn make_handler( | ||
977 | 995 | Some(&health_registry), |
978 | 996 | Some(&graphql_schema), |
979 | 997 | openapi_spec.as_deref().map(|s| s.as_str()), |
998 | + max_body_size, | |
980 | 999 | ) |
981 | 1000 | .await |
982 | 1001 | } |
| @@ -995,6 +1014,7 @@ fn make_handler( | ||
995 | 1014 | Some(&health_registry), |
996 | 1015 | Some(&graphql_schema), |
997 | 1016 | openapi_spec.as_deref().map(|s| s.as_str()), |
1017 | + max_body_size, | |
998 | 1018 | ) |
999 | 1019 | .await |
1000 | 1020 | } |
| @@ -1013,6 +1033,11 @@ fn make_handler( | ||
1013 | 1033 | /// protection). Applies to HTTP/1 and HTTP/2. |
1014 | 1034 | const HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); |
1015 | 1035 | |
1036 | +/// Default cap on request body size. Bounding the body protects against | |
1037 | +/// memory-exhaustion DoS; it is configurable per-server via | |
1038 | +/// `Server::with_max_body_size`. | |
1039 | +const DEFAULT_MAX_BODY_SIZE: usize = 50 * 1024 * 1024; | |
1040 | + | |
1016 | 1041 | /// Max wall-clock time a single request handler may run before we respond 504 |
1017 | 1042 | /// and abort it. Guards against stuck/slow handlers and resource exhaustion. |
1018 | 1043 | /// Configurable via `JUSTAPI_REQUEST_TIMEOUT_SECS`. |
| @@ -1379,6 +1404,7 @@ async fn execute_handler( | ||
1379 | 1404 | health_registry: Option<&HealthRegistry>, |
1380 | 1405 | graphql_schema: Option<&crate::graphql::AppSchema>, |
1381 | 1406 | openapi_spec: Option<&str>, |
1407 | + max_body_size: usize, | |
1382 | 1408 | ) -> Result<Response<ResponseBody>> { |
1383 | 1409 | let handler_name = match m.handler { |
1384 | 1410 | Handler::Static { .. } => "static", |
| @@ -1400,7 +1426,7 @@ async fn execute_handler( | ||
1400 | 1426 | match m.handler { |
1401 | 1427 | Handler::Static { status, body } => Ok(json_response(*status, body)), |
1402 | 1428 | Handler::Echo => { |
1403 | - let body_bytes = match http_body_util::Limited::new(req.into_body(), 50 * 1024 * 1024) | |
1429 | + let body_bytes = match http_body_util::Limited::new(req.into_body(), max_body_size) | |
1404 | 1430 | .collect() |
1405 | 1431 | .await |
1406 | 1432 | { |
| @@ -1409,6 +1435,12 @@ async fn execute_handler( | ||
1409 | 1435 | metrics.add_bytes_in(bytes.len() as u64); |
1410 | 1436 | bytes |
1411 | 1437 | } |
1438 | + Err(e) if e.to_string().contains("length limit") => { | |
1439 | + return Ok(json_response( | |
1440 | + StatusCode::PAYLOAD_TOO_LARGE, | |
1441 | + r#"{"detail":"payload too large"}"#, | |
1442 | + )) | |
1443 | + } | |
1412 | 1444 | Err(_) => { |
1413 | 1445 | return Ok(json_response( |
1414 | 1446 | StatusCode::BAD_REQUEST, |
1415 | 1447 | |
▼crates/justapi-core/tests/integration.rs
+30-0
| @@ -245,3 +245,33 @@ async fn test_static_mount_prefix_and_spa_fallback() { | ||
245 | 245 | |
246 | 246 | let _ = std::fs::remove_dir_all(&tmp); |
247 | 247 | } |
248 | + | |
249 | +async fn start_server_with_max_body_size(limit: usize) -> SocketAddr { | |
250 | + let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); | |
251 | + let addr = listener.local_addr().unwrap(); | |
252 | + tokio::spawn(async move { | |
253 | + Server::new(addr) | |
254 | + .with_default_routes() | |
255 | + .with_max_body_size(limit) | |
256 | + .run_on(listener) | |
257 | + .await | |
258 | + .unwrap(); | |
259 | + }); | |
260 | + tokio::time::sleep(std::time::Duration::from_millis(200)).await; | |
261 | + addr | |
262 | +} | |
263 | + | |
264 | +#[tokio::test] | |
265 | +async fn test_max_body_size_enforced() { | |
266 | + let addr = start_server_with_max_body_size(1024).await; | |
267 | + | |
268 | + // Within the limit: echoed back. | |
269 | + let (status, body) = send_request(addr, Method::POST, "/echo", Some(&[b'x'; 512])).await; | |
270 | + assert_eq!(status, StatusCode::OK); | |
271 | + assert_eq!(&body[..], &[b'x'; 512][..]); | |
272 | + | |
273 | + // Over the limit: 413. | |
274 | + let (status, body) = send_request(addr, Method::POST, "/echo", Some(&[b'x'; 1025])).await; | |
275 | + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); | |
276 | + assert!(String::from_utf8_lossy(&body).contains("payload too large")); | |
277 | +} | |
248 | 278 | |
▼crates/justapi-py/python/justapi/app.py
+3-2
| @@ -1115,8 +1115,9 @@ class JustAPIApp: | ||
1115 | 1115 | register_system_routes(self) |
1116 | 1116 | return self |
1117 | 1117 | |
1118 | - def run(self, addr: str): | |
1119 | - self._app.run(addr) | |
1118 | + def run(self, addr: str, max_body_size: int = 50 * 1024 * 1024): | |
1119 | + self._app.run(addr, max_body_size) | |
1120 | + | |
1120 | 1121 | |
1121 | 1122 | def _wrap_sse_handler(self, func): |
1122 | 1123 | """Wrap a generator/async-generator handler into a TokenStreamResponse.""" |
1123 | 1124 | |
▼crates/justapi-py/src/native/app.rs
+3-1
| @@ -1198,7 +1198,8 @@ impl JustAPIApp { | ||
1198 | 1198 | } |
1199 | 1199 | |
1200 | 1200 | /// Start the server and begin accepting requests. |
1201 | - fn run(slf: Py<Self>, py: Python<'_>, addr: &str) -> PyResult<()> { | |
1201 | + #[pyo3(signature = (addr, max_body_size=50*1024*1024))] | |
1202 | + fn run(slf: Py<Self>, py: Python<'_>, addr: &str, max_body_size: usize) -> PyResult<()> { | |
1202 | 1203 | let mut app = slf.borrow_mut(py); |
1203 | 1204 | let addr: std::net::SocketAddr = addr.parse().map_err(|e| { |
1204 | 1205 | pyo3::exceptions::PyValueError::new_err(format!("Invalid address: {}", e)) |
| @@ -1461,6 +1462,7 @@ impl JustAPIApp { | ||
1461 | 1462 | needs_request.clone(), |
1462 | 1463 | native.clone(), |
1463 | 1464 | schema_validators.clone(), |
1465 | + max_body_size, | |
1464 | 1466 | ); |
1465 | 1467 | |
1466 | 1468 | // Install signal handlers to trigger graceful shutdown. |
1467 | 1469 | |
▼crates/justapi-py/src/native/handlers.rs
+24-8
| @@ -594,6 +594,7 @@ pub(crate) fn make_native_handler<B>( | ||
594 | 594 | needs_request: Arc<Vec<bool>>, |
595 | 595 | native: Arc<Vec<bool>>, |
596 | 596 | schema_validators: Arc<Vec<Option<justapi_core::validate::CompiledValidator>>>, |
597 | + max_body_size: usize, | |
597 | 598 | ) -> justapi_core::middleware::HandlerFn<B> |
598 | 599 | where |
599 | 600 | B: http_body::Body<Data = Bytes> + Send + Sync + Unpin + 'static, |
| @@ -675,11 +676,17 @@ where | ||
675 | 676 | } |
676 | 677 | } |
677 | 678 | } else { |
678 | - let b = http_body_util::Limited::new(req_body, 50 * 1024 * 1024) | |
679 | - .collect() | |
680 | - .await | |
681 | - .map_err(|e| anyhow::anyhow!("Body too large or error: {}", e))? | |
682 | - .to_bytes(); | |
679 | + let b = match http_body_util::Limited::new(req_body, max_body_size).collect().await | |
680 | + { | |
681 | + Ok(c) => c.to_bytes(), | |
682 | + Err(e) if e.to_string().contains("length limit") => { | |
683 | + return Ok(json_response( | |
684 | + StatusCode::PAYLOAD_TOO_LARGE, | |
685 | + r#"{"detail":"payload too large"}"#, | |
686 | + )); | |
687 | + } | |
688 | + Err(e) => return Err(anyhow::anyhow!("Body error: {}", e)), | |
689 | + }; | |
683 | 690 | (b.to_vec(), None) |
684 | 691 | }; |
685 | 692 | |
| @@ -858,6 +865,7 @@ pub fn make_test_handler<B>( | ||
858 | 865 | needs_request: Arc<Vec<bool>>, |
859 | 866 | native: Arc<Vec<bool>>, |
860 | 867 | schema_validators: Arc<Vec<Option<justapi_core::validate::CompiledValidator>>>, |
868 | + max_body_size: usize, | |
861 | 869 | ) -> justapi_core::middleware::HandlerFn<B> |
862 | 870 | where |
863 | 871 | B: http_body::Body<Data = Bytes> + Send + Sync + Unpin + 'static, |
| @@ -914,11 +922,19 @@ where | ||
914 | 922 | let is_multipart = |
915 | 923 | content_type.as_deref().unwrap_or("").starts_with("multipart/form-data"); |
916 | 924 | |
917 | - let body_bytes = http_body_util::Limited::new(req.into_body(), 50 * 1024 * 1024) | |
925 | + let body_bytes = match http_body_util::Limited::new(req.into_body(), max_body_size) | |
918 | 926 | .collect() |
919 | 927 | .await |
920 | - .map_err(|e| anyhow::anyhow!("Body too large or error: {}", e))? | |
921 | - .to_bytes(); | |
928 | + { | |
929 | + Ok(c) => c.to_bytes(), | |
930 | + Err(e) if e.to_string().contains("length limit") => { | |
931 | + return Ok(json_response( | |
932 | + StatusCode::PAYLOAD_TOO_LARGE, | |
933 | + r#"{"detail":"payload too large"}"#, | |
934 | + )); | |
935 | + } | |
936 | + Err(e) => return Err(anyhow::anyhow!("Body error: {}", e)), | |
937 | + }; | |
922 | 938 | |
923 | 939 | let multipart_form_res: Option< |
924 | 940 | Result<justapi_core::multipart::MultipartForm, anyhow::Error>, |
925 | 941 | |
▼crates/justapi-py/src/test_client.rs
+1-0
| @@ -118,6 +118,7 @@ impl JustAPITestClient { | ||
118 | 118 | needs_request, |
119 | 119 | native, |
120 | 120 | schema_validators, |
121 | + 50 * 1024 * 1024, | |
121 | 122 | ); |
122 | 123 | |
123 | 124 | // Apply request coalescing (singleflight) if the app enabled it, so the |
124 | 125 | |
▼crates/justapi-py/test_max_body_size.py
+50-0
| @@ -0,0 +1,50 @@ | ||
1 | +import requests | |
2 | +import threading | |
3 | +import time | |
4 | + | |
5 | +from justapi import JustAPIApp | |
6 | + | |
7 | + | |
8 | +def _spawn(addr, **kwargs): | |
9 | + app = JustAPIApp() | |
10 | + | |
11 | + @app.post("/echo") | |
12 | + def echo(request): | |
13 | + return request.body | |
14 | + | |
15 | + def run(): | |
16 | + app.run(addr, **kwargs) | |
17 | + | |
18 | + t = threading.Thread(target=run, daemon=True) | |
19 | + t.start() | |
20 | + time.sleep(1.0) | |
21 | + return app | |
22 | + | |
23 | + | |
24 | +def test_max_body_size_enforced(): | |
25 | + addr = "127.0.0.1:9231" | |
26 | + _spawn(addr, max_body_size=1024) | |
27 | + | |
28 | + # Body within the limit is accepted. | |
29 | + ok = requests.post(f"http://{addr}/echo", data=b"x" * 512) | |
30 | + assert ok.status_code == 200, ok.status_code | |
31 | + | |
32 | + # Body over the limit is rejected with 413. | |
33 | + big = requests.post(f"http://{addr}/echo", data=b"x" * (1024 + 1)) | |
34 | + assert big.status_code == 413, big.status_code | |
35 | + assert b"payload too large" in big.content | |
36 | + | |
37 | + | |
38 | +def test_max_body_size_default_accepts_large(): | |
39 | + addr = "127.0.0.1:9232" | |
40 | + _spawn(addr) | |
41 | + | |
42 | + # Default limit is 50 MiB; a 5 MiB body should pass. | |
43 | + ok = requests.post(f"http://{addr}/echo", data=b"x" * (5 * 1024 * 1024)) | |
44 | + assert ok.status_code == 200, ok.status_code | |
45 | + | |
46 | + | |
47 | +if __name__ == "__main__": | |
48 | + test_max_body_size_enforced() | |
49 | + test_max_body_size_default_accepts_large() | |
50 | + print("ok") | |
0 | 51 | |