swadhinbiswas

[email protected]
2026-07-16 16:14:47 +0600 committed NaN years ago
a48ff2c
commit a48ff2c
10 files changed
+392 -15
▼DECISIONS.md
+37-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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