π docs: engineering article (ARTICLE.md) + Hacker News version (HACKERNEWS.md)
swadhinbiswas committed 2 months ago
61b6040
1 parent a225470
commit 61b6040
4 files changed
+550 -1
Showing 4 changed files+550-1
βΌ.gitignore
+0-1
| @@ -128,7 +128,6 @@ heaptrack.* | ||
128 | 128 | /split_server.py |
129 | 129 | /restore.py |
130 | 130 | /generate_commits.sh |
131 | -/demo_app.py | |
132 | 131 | /protection.json |
133 | 132 | |
134 | 133 | # Olist dataset (large, download separately) |
135 | 134 | |
βΌARTICLE.md
+425-0
| @@ -0,0 +1,425 @@ | ||
1 | +# JustAPI: a Python web framework with a Rust core β the story, the numbers, and what I actually learned | |
2 | + | |
3 | +> **Read this first.** I built JustAPI β I'm Swadhin Biswas β with agentic AI as | |
4 | +> a guide: AI helped me design, implement, debug, and document a large part of | |
5 | +> this project. I am not claiming this is the best framework in the world, and | |
6 | +> I am not claiming every number here was measured on your hardware. I am | |
7 | +> describing what I built, why I built it, what I verified, and β just as | |
8 | +> important β what I tried that did not work. The documentation site is large, | |
9 | +> much of it written with AI assistance, and not everything in it is | |
10 | +> independently verified. If you find a claim that seems wrong, it probably | |
11 | +> is. Verify it, and open an issue. | |
12 | +> | |
13 | +> **What this article is:** a technical write-up. **What it is not:** marketing. | |
14 | + | |
15 | +--- | |
16 | + | |
17 | +## 1. What JustAPI is | |
18 | + | |
19 | +JustAPI is a Python web framework where the web server, the router, the TLS | |
20 | +termination, the request validation, the JSON serialization, and the database | |
21 | +access all run in Rust. Your Python code handles the application logic. That is | |
22 | +the entire premise, and it is the one sentence that explains every design | |
23 | +decision in the project. | |
24 | + | |
25 | +```python | |
26 | +from justapi import JustAPIApp | |
27 | + | |
28 | +app = JustAPIApp() | |
29 | + | |
30 | +@app.get("/") | |
31 | +def hello(): | |
32 | + return {"Hello": "World"} | |
33 | + | |
34 | +app.run() # a real server, not a dev server | |
35 | +``` | |
36 | + | |
37 | +That is a complete program. It gives you HTTP/1.1 and HTTP/2, TLS, routing with | |
38 | +path and query parameters, JSON serialization, automatic OpenAPI docs at | |
39 | +`/docs`, and a server that does not need uvicorn or gunicorn. All of it runs in | |
40 | +Rust. The Python process just runs your handler. | |
41 | + | |
42 | +The package is on PyPI (`pip install justapi`), it works on Linux, macOS, and | |
43 | +Windows, and there are wheels for the free-threaded build of CPython 3.14t. | |
44 | + | |
45 | +--- | |
46 | + | |
47 | +## 2. Why I built it (the story) | |
48 | + | |
49 | +The starting point was a simple observation that most Python web frameworks | |
50 | +treat as a given: **the framework runs in Python.** FastAPI runs on Starlette, | |
51 | +Starlette runs on uvicorn, and uvicorn is a Python event loop with a lot of | |
52 | +asyncio machinery between the socket and your handler. Robyn and Granian moved | |
53 | +the *server* to Rust, but the request still crosses into Python for routing, | |
54 | +validation, and serialization. | |
55 | + | |
56 | +I asked a different question: what if almost nothing crossed into Python? | |
57 | +What if the framework β the thing that does the same work on every request β | |
58 | +lived entirely in Rust, and Python only ran the code that is different for each | |
59 | +application? | |
60 | + | |
61 | +That question shaped a project with an unusual constraint, which I wrote down | |
62 | +early and enforced throughout: **if a feature can be implemented in Rust, it | |
63 | +must be implemented in Rust.** Not "can be implemented faster in Rust" β *can | |
64 | +be implemented in Rust*. Python in the framework is reserved for the parts that | |
65 | +have to be Python: the glue between Rust values and your handlers. | |
66 | + | |
67 | +I did not do this alone. I worked with agentic AI throughout β using it as an | |
68 | +engineering guide. I made the architecture decisions; the AI helped me turn | |
69 | +them into working code, find the bugs I could not see, write the tests, and | |
70 | +keep the project honest when I wanted to skip the boring parts. There are | |
71 | +parts of this codebase I understand completely, parts I understand well enough | |
72 | +to maintain, and parts the AI wrote that I reviewed and tested but would be | |
73 | +lying if I said I could reproduce from memory. I am not hiding that; it is | |
74 | +part of how this project exists. | |
75 | + | |
76 | +The project then went through the standard engineering motions, in order: | |
77 | + | |
78 | +1. A minimal server: tokio + hyper, a PyO3 boundary, one route. Measured | |
79 | + 766k req/s on the hello-world fixture. | |
80 | +2. A native router (matchit radix trie, 51ns average lookup on 500 routes). | |
81 | +3. A memory pipeline (per-request arena, buffer pools). | |
82 | +4. Middleware in Rust: CORS, security headers, JWT, rate limiting. | |
83 | +5. Serialization in Rust (serde_json, optional simd-json). | |
84 | +6. TLS with rustls β no OpenSSL dependency. | |
85 | +7. A native API surface, replacing the ASGI shim. | |
86 | +8. Database access via sqlx: SQLite, PostgreSQL, MySQL, DuckDB. | |
87 | +9. WebSockets, SSE, background tasks, a scheduler, a plugin system. | |
88 | +10. Operational tooling: OpenTelemetry, Prometheus metrics, health checks, | |
89 | + circuit breakers. | |
90 | + | |
91 | +None of this was fast in the sense of "one weekend." It was fast in the sense | |
92 | +of "the numbers are in the repo, the tests pass, and the failures are | |
93 | +documented." | |
94 | + | |
95 | +The release history tells part of the story on its own: | |
96 | + | |
97 | +| Version | What changed | | |
98 | +|---|---| | |
99 | +| 2.0.8 | Multi-threaded tokio server, free-threaded CPython support, async handler fix (14Γ async throughput) | | |
100 | +| 2.0.9 | Native async DB awaits, Rust-native SSE, type stubs, tokenless CI publishing, first PyPI release | | |
101 | +| 2.0.10 | The CLI shipped in the wheel (it was a stub before) | | |
102 | + | |
103 | +--- | |
104 | + | |
105 | +## 3. The architecture | |
106 | + | |
107 | +The request path is a pipeline, and almost every stage is Rust: | |
108 | + | |
109 | +``` | |
110 | +Kernel (epoll/io_uring) | |
111 | + β tokio connection manager (multi-threaded) | |
112 | + β TLS (rustls) | |
113 | + β HTTP parse (hyper) | |
114 | + β Router (matchit, ~51ns lookup) | |
115 | + β Middleware chain (auth, CORS, rate-limit, compression) | |
116 | + β Python boundary (zero-copy PyO3, GIL pool) | |
117 | + β Your Python handler | |
118 | + β Rust serializer (serde_json / simd-json) | |
119 | + β Response write β socket | |
120 | +``` | |
121 | + | |
122 | +The crate layout mirrors the pipeline: | |
123 | + | |
124 | +``` | |
125 | +justapi/ | |
126 | +βββ crates/ | |
127 | +β βββ justapi-core/ # networking, routing, middleware, serialization, DB pool | |
128 | +β βββ justapi-py/ # PyO3 bindings + the Python package | |
129 | +β βββ justapi-cli/ # the `justapi-cli` binary (serve, create, check, profile) | |
130 | +β βββ justapi-bench/ # benchmark harness | |
131 | +βββ python/justapi/ # pip-installable package (maturin-built) | |
132 | +βββ BENCHMARKS.md # append-only performance ledger | |
133 | +βββ DECISIONS.md # ADR log (ADR-001 through ADR-093) | |
134 | +βββ PLAN.md # roadmap with an honesty status on every phase | |
135 | +``` | |
136 | + | |
137 | +The dependency graph is intentionally small: | |
138 | + | |
139 | +``` | |
140 | +justapi-cli β justapi-core | |
141 | +justapi-py β justapi-core | |
142 | +justapi-bench β justapi-core | |
143 | +``` | |
144 | + | |
145 | +## 4. The numbers (verified, with the hardware caveat) | |
146 | + | |
147 | +All numbers below are from `BENCHMARKS.md`, measured on a single machine with | |
148 | +100 concurrent connections and 30-second runs. **Your machine will differ.** | |
149 | +What matters is the *ratio* on the same hardware, not the absolute numbers. | |
150 | + | |
151 | +| Framework | Hello-world req/s | JSON echo req/s | p99 hello-world | RSS | | |
152 | +|---|---|---|---|---| | |
153 | +| JustAPI (native fast path) | 766k | 782k | 0.48 ms | 12 MB | | |
154 | +| Granian (ASGI) | 314k | 145k | 0.74 ms | 37 MB | | |
155 | +| Robyn | 39k | β | β | β | | |
156 | +| FastAPI + Uvicorn | 36k | 33k | 24.63 ms | 29 MB | | |
157 | + | |
158 | +The "native fast path" needs two things: a route registered with `native=True` | |
159 | +and a `Schema`. In that configuration, Rust validates the body and writes the | |
160 | +response β Python never runs. Without a schema, you get the Python handler | |
161 | +path, which is GIL-bound at roughly 60β120k req/s on this hardware, same as any | |
162 | +other Python framework. | |
163 | + | |
164 | +Other verified numbers: | |
165 | + | |
166 | +- Route lookup: 51ns average on a 500-route table. | |
167 | +- Native CRUD SELECT: 181k req/s β 125Γ FastAPI on the same SQLite fixture. | |
168 | +- Native async DB awaits: 320 RPS vs 6 RPS for the blocking path on a slow | |
169 | + query (a 2M-row recursive CTE) β 53Γ. | |
170 | +- Multi-worker prefork (`justapi serve --workers 4`): 1.88Γ throughput | |
171 | + (99.7k RPS vs 53k). | |
172 | +- Free-threaded CPython 3.14t, CPU-bound Python handler: 12.4Γ vs | |
173 | + GIL-locked β the GIL ceiling disappears. | |
174 | + | |
175 | +**What I do not claim:** there is no "beats every framework on every | |
176 | +workload" claim here. Light async handlers (no I/O, just `asyncio.sleep`) sit | |
177 | +at 4β12k req/s β the asyncio loop floor β and I measured that Granian's | |
178 | +direct loop dispatch is faster on that specific shape. I do not hide it; it is | |
179 | +in the benchmarks and in the article below. | |
180 | + | |
181 | +## 5. The async story: what I tried, and what the measurements said | |
182 | + | |
183 | +This is the most important section in this article, because it is where I | |
184 | +spent the most effort proving myself wrong. | |
185 | + | |
186 | +The initial async path was bad. An async handler doing `await | |
187 | +asyncio.sleep(0.001)` served about **808 RPS** β a hard ceiling, not | |
188 | +contention. The single GIL worker was blocking on each coroutine's full | |
189 | +duration, serializing every async request. Fixing that (ADR-083) took async | |
190 | +throughput from ~800 to **11,758 RPS** β 14Γ β by making completion | |
191 | +callback-driven instead of blocking. | |
192 | + | |
193 | +Then I got ambitious. The plan was: "make the awaits Rust-native." If a | |
194 | +Python coroutine's `await` could complete on tokio instead of on the asyncio | |
195 | +loop, per-await overhead would drop, and I would beat Granian on async. | |
196 | + | |
197 | +I built four approaches and measured every one: | |
198 | + | |
199 | +| Approach | Per-await overhead | Verdict | | |
200 | +|---|---|---| | |
201 | +| `future_into_py` (Rust future wrapped as an asyncio.Future) | 2Γ slower than `asyncio.sleep` | The wrapper + cross-runtime wakeup costs more than the loop's own timer | | |
202 | +| `into_future` (coroutine β Rust future) | tied at best | Requires a running asyncio loop for task-locals; does not decouple | | |
203 | +| Hand-rolled driver, GIL attach per await | ~1125 Β΅s/await | Thread-state setup dominates | | |
204 | +| Hand-rolled driver, persistent thread | ~3.4 Β΅s/await solo | **86 Γ worse concurrent** (thread per coroutine) | | |
205 | +| asyncio loop (status quo) | ~45 Β΅s/await | The only mechanism that scales | | |
206 | + | |
207 | +The multiplexing driver β one persistent Rust thread stepping many coroutines, | |
208 | +the design that should have worked β measured **16 Β΅s/await vs asyncio's | |
209 | +0.56 Β΅s/await on pure stepping** (ADR-091). The absolute floor, a bare | |
210 | +`await noop()` in asyncio, is **0.039 Β΅s**. | |
211 | + | |
212 | +The conclusion, written into the ADR log: *the Python coroutine `send()` | |
213 | +stepping is the irreducible floor, and asyncio already runs it at the minimum | |
214 | +possible rate. Any Rust wrapper around `send()` adds overhead; it cannot | |
215 | +subtract it.* A multi-loop dispatch experiment (ADR-092) also came back | |
216 | +neutral-to-worse: the GIL worker is the single dispatch point, so extra loops | |
217 | +only added GIL contention. | |
218 | + | |
219 | +I stopped. That is the part of engineering nobody writes articles about: the | |
220 | +work you delete. The async path stays on asyncio, which is the right answer, | |
221 | +and I went back to making the *operations* native instead of the coroutine. | |
222 | + | |
223 | +## 6. Native operations: where the real win is | |
224 | + | |
225 | +Since I cannot make Python coroutines faster, I made the framework operations | |
226 | +they await faster β by taking Python out of them. | |
227 | + | |
228 | +**Native async DB awaits (`query_async`).** A normal async handler doing a | |
229 | +database query would block the asyncio loop thread for the whole query. One | |
230 | +slow Postgres query froze every other async handler on the server. | |
231 | +`await app.db.query_async(...)` runs the SQL on the DB's own multi-threaded | |
232 | +tokio runtime with the GIL released. The loop is never blocked. Measured: 53Γ | |
233 | +faster than the blocking path on slow queries, and it is the only path that is | |
234 | +*safe* for real databases with network latency. | |
235 | + | |
236 | +**Rust-native SSE (`sse_native`).** A stream of events is generated entirely | |
237 | +in Rust β tokio timers, an mpsc channel, the HTTP layer drains it. Zero Python | |
238 | +per event. 100k events stream instantly. | |
239 | + | |
240 | +**`@native_async`.** A marker that routes async handlers to the fastest | |
241 | +dispatch path, and on free-threaded CPython enables true parallel dispatch | |
242 | +across workers. | |
243 | + | |
244 | +The pattern is consistent: Python declares the operation, Rust executes it, | |
245 | +and nothing in the middle is Python. | |
246 | + | |
247 | +## 7. Bugs I found in my own framework (and fixed) | |
248 | + | |
249 | +The honest part of any framework article is the bug list. Some highlights: | |
250 | + | |
251 | +**Write-path collapse.** Concurrent Python-handler writes dropped to ~3 RPS at | |
252 | +11 connections (from ~150 at 10). The request-scoped auto-transaction was | |
253 | +double-acquiring pool connections β 2N for N writes. Removed. (ADR-080) | |
254 | + | |
255 | +**GIL pool not fork-safe.** A forked child inherited the parent's initialized | |
256 | +GIL pool whose worker threads did not exist, so every Python request hung and | |
257 | +returned 504s. The pool now tracks its PID and rebuilds after fork. This was | |
258 | +also the root cause of a long-standing flaky test. (ADR-081) | |
259 | + | |
260 | +**Pydantic models as `body_schema` were broken.** `validate_body` treated a | |
261 | +Pydantic model class as a plain callable and invoked | |
262 | +`BaseModel(body_dict)`, which raised `TypeError`. This broke the single most | |
263 | +important FastAPI migration path. Fixed by validating through | |
264 | +`model_json_schema()` in the Rust engine. It shipped fixed in 2.0.9. | |
265 | + | |
266 | +**`app.run()` required an address.** The README quickstart showed | |
267 | +`app.run()` with no arguments, which crashed with a `TypeError`. Now defaults | |
268 | +to `127.0.0.1:8000`. | |
269 | + | |
270 | +**The CLI in the wheel was a stub.** The PyPI package's `justapi` command | |
271 | +printed a help message and did nothing else. It shipped fixed in 2.0.10 | |
272 | +(serve, create, check, openapi β and it delegates to the Rust CLI when | |
273 | +installed). | |
274 | + | |
275 | +**Unix-only APIs were not cfg-gated.** `UnixListener`-based functions failed | |
276 | +to compile on Windows. Gated with `#[cfg(unix)]`. | |
277 | + | |
278 | +Each of these is in the changelog with the commit that fixed it. None of them | |
279 | +was found by reading the code. They were found by running things, by | |
280 | +benchmarking, and by trying to use the framework the way a user would. | |
281 | + | |
282 | +## 8. What JustAPI ships that no other Python framework ships | |
283 | + | |
284 | +- **HTTP/3 (QUIC)** β feature-gated, serves Python handlers over QUIC through | |
285 | + the full native pipeline. As far as I know, the only Python framework with | |
286 | + a working HTTP/3 transport. | |
287 | +- **Free-threaded CPython wheels** (3.14t) with auto-detected parallel | |
288 | + dispatch. | |
289 | +- **Rust-native SSE** where the stream never touches Python. | |
290 | +- **Multi-worker prefork with auto-scaling** built into the CLI. | |
291 | +- **OIDC trusted publishing** β the release pipeline uses zero tokens; a tag | |
292 | + push builds 9 platform wheels and publishes them. | |
293 | + | |
294 | +## 9. The honest caveats (read this before using it) | |
295 | + | |
296 | +1. **The documentation site is large and much of it is AI-written.** I | |
297 | + generated a lot of the docs with AI assistance. I verified the code and | |
298 | + the benchmarks; I did not independently verify every sentence in every | |
299 | + doc page. If you rely on a doc page, test the behavior yourself. Some doc | |
300 | + pages were already found to describe APIs that did not exist, and I fixed | |
301 | + them as I found them. There are probably more. | |
302 | + | |
303 | +2. **The LLM/inference features are unverified.** Phases 41β52 of the roadmap | |
304 | + (inference engine, KV cache, continuous batching, OpenAI-compatible server) | |
305 | + are implemented but were never run with real GPU weights. The only honest | |
306 | + status is "implemented, not verified." They are not part of the release | |
307 | + story, and I do not make performance claims about them. (ADR-067) | |
308 | + | |
309 | +3. **The absolute numbers are from one machine.** Ratios matter; absolutes | |
310 | + depend on hardware, OS, and load generator. | |
311 | + | |
312 | +4. **The roadmap contains 17 "implemented but unverified" phases.** I mark | |
313 | + them π‘ in PLAN.md. That is not a typo. It means: code exists, tests pass, | |
314 | + and the real-world claim is not proven. | |
315 | + | |
316 | +5. **Async light handlers are loop-bound.** If your handler is | |
317 | + `await asyncio.sleep(0.001); return {}`, JustAPI is not faster than | |
318 | + Granian. I measured it. Use the native operations for DB/SSE/IO work. | |
319 | + | |
320 | +6. **There is no 1M RPS claim here.** The roadmap phase title once said | |
321 | + "1M RPS tuning." I never measured 1M RPS. I changed the status to | |
322 | + reflect that. (The phase overclaims; I say so in PLAN.md.) | |
323 | + | |
324 | +7. **First impressions matter, and mine were rough.** The first published | |
325 | + CLI was a stub. `app.run()` crashed without an argument. The docs had | |
326 | + fabricated API references. All fixed, but if you are evaluating the | |
327 | + project, the version history is honest about the rough edges. | |
328 | + | |
329 | +## 10. How to actually use it | |
330 | + | |
331 | +```bash | |
332 | +pip install justapi==2.0.10 | |
333 | +``` | |
334 | + | |
335 | +```python | |
336 | +# main.py | |
337 | +from justapi import JustAPIApp, native_async | |
338 | +from pydantic import BaseModel | |
339 | + | |
340 | +app = JustAPIApp() | |
341 | +app.set_database("sqlite://app.db") | |
342 | + | |
343 | +class Item(BaseModel): | |
344 | + name: str | |
345 | + | |
346 | +@app.get("/") | |
347 | +def root(): | |
348 | + return {"Hello": "World"} | |
349 | + | |
350 | +@app.get("/items/{item_id}") | |
351 | +async def get_item(item_id: int): | |
352 | + return await app.db.query_async( | |
353 | + "SELECT * FROM items WHERE id = ?", [item_id]) | |
354 | + | |
355 | +@app.post("/items", body_schema=Item) | |
356 | +def create(request): | |
357 | + data = request.json() | |
358 | + return app.db.execute( | |
359 | + "INSERT INTO items (name) VALUES (?)", [data["name"]]) | |
360 | + | |
361 | +# Rust-native SSE: no Python per event | |
362 | +app.sse_native("/events", count=1000, interval_ms=0) | |
363 | + | |
364 | +app.run() # defaults to 127.0.0.1:8000 | |
365 | +``` | |
366 | + | |
367 | +Then: | |
368 | + | |
369 | +```bash | |
370 | +python main.py | |
371 | +justapi check main.py # validate routes without serving | |
372 | +justapi serve 0.0.0.0:8080 # or use the CLI | |
373 | +``` | |
374 | + | |
375 | +For the high-performance Rust CLI: | |
376 | + | |
377 | +```bash | |
378 | +cargo install justapi-cli # provides serve --workers N --reload, create, profile | |
379 | +``` | |
380 | + | |
381 | +## 11. Where to verify everything in this article | |
382 | + | |
383 | +| Claim | Source | | |
384 | +|---|---| | |
385 | +| Benchmarks, methodology, hardware | `BENCHMARKS.md` in the repo | | |
386 | +| Architecture decisions (all 93) | `DECISIONS.md` β ADR-001 through ADR-093 | | |
387 | +| Roadmap status incl. unverified phases | `PLAN.md` (the honesty key is at the top) | | |
388 | +| Async experiments and their failure | ADR-090, ADR-091, ADR-092 | | |
389 | +| Native async DB awaits | ADR-093 | | |
390 | +| Bug fixes | `CHANGELOG.md` (2.0.8 β 2.0.10) | | |
391 | +| Code | https://github.com/swadhinbiswas/JustAPI | | |
392 | +| Docs | https://justapi.pages.dev | | |
393 | + | |
394 | +--- | |
395 | + | |
396 | +## 12. The thing I actually learned | |
397 | + | |
398 | +The takeaway of this project is not "Rust is faster than Python." Everyone | |
399 | +knows that. The takeaway is more specific and more useful: | |
400 | + | |
401 | +**The performance of a Python web framework is decided by how much Python | |
402 | +runs per request β and the parts you cannot remove, you should not try to make | |
403 | +faster by rewriting them in Rust.** | |
404 | + | |
405 | +I tried to make asyncio faster by moving it to Rust. The measurements said | |
406 | +no. I made the operations *around* asyncio native instead, and that worked: | |
407 | +53Γ on slow database queries, 125Γ on native CRUD, zero-Python SSE. The | |
408 | +framework's job is to decide which parts of a request are yours and which are | |
409 | +the framework's β and then to make the framework's parts not run Python at | |
410 | +all. | |
411 | + | |
412 | +That, and the discipline to write down the failures, is what JustAPI is. | |
413 | + | |
414 | +There is a second lesson, specific to how I built this: **agentic AI is a | |
415 | +force multiplier, not a substitute for judgment.** The AI guided me through | |
416 | +Rust lifetimes, PyO3 FFI, and tokio internals I had never touched before. It | |
417 | +also wrote things that were wrong, and I only caught them because I | |
418 | +benchmarked, tested, and read the output. Use the tool; verify the work. | |
419 | + | |
420 | +--- | |
421 | + | |
422 | +*Written by Swadhin Biswas, maintainer of JustAPI, with agentic AI as a | |
423 | +guide. Corrections welcome β open an issue at | |
424 | +https://github.com/swadhinbiswas/JustAPI/issues. If a number here disagrees | |
425 | +with your measurement, your measurement wins.* | |
0 | 426 | |
βΌHACKERNEWS.md
+75-0
| @@ -0,0 +1,75 @@ | ||
1 | +Show HN: JustAPI β a Python web framework where the framework itself is Rust | |
2 | + | |
3 | +I spent months building a Python web framework where almost nothing in the | |
4 | +request path is Python. Networking, TLS, routing, validation, serialization, | |
5 | +database access β all Rust. Your handler is the only Python in the pipeline. | |
6 | + | |
7 | +Why: every Python framework treats "the framework runs in Python" as a given. | |
8 | +FastAPI sits on Starlette on uvicorn, and even Rust-served frameworks like | |
9 | +Granian cross into Python for routing and serialization. I asked what happens | |
10 | +if the framework work never crosses into Python at all. | |
11 | + | |
12 | +The measured numbers (one machine, 100 concurrent connections, 30s runs β | |
13 | +ratios matter more than absolutes): | |
14 | + | |
15 | + hello-world: 766k req/s (native fast path) vs Granian 314k, FastAPI 36k | |
16 | + JSON echo: 782k req/s vs Granian 145k, FastAPI 33k | |
17 | + route lookup: 51ns on a 500-route table | |
18 | + native CRUD SELECT: 181k req/s β 125x FastAPI on the same SQLite fixture | |
19 | + native async DB: 53x faster than the blocking path on slow queries | |
20 | + memory: 12 MB RSS vs Granian 37 MB, FastAPI 29 MB | |
21 | + | |
22 | +The part I'm most proud of is what didn't work. | |
23 | + | |
24 | +I spent real effort trying to make asyncio faster by moving it to Rust β | |
25 | +driving Python coroutines from tokio. I built four approaches and measured | |
26 | +every one. The best result was 16 microseconds per await vs asyncio's 0.56 | |
27 | +microseconds on pure stepping. The absolute floor, a bare "await noop()" in | |
28 | +asyncio, is 0.039 microseconds. Python's coroutine stepping is the irreducible | |
29 | +cost, and asyncio already pays it at the minimum rate. Any Rust wrapper only | |
30 | +adds overhead. So I stopped, documented it in the ADR log (090-092), and | |
31 | +instead made the operations the coroutines await native β DB queries run on | |
32 | +Rust's tokio runtime with the GIL released, SSE streams never touch Python. | |
33 | + | |
34 | +What you get that other Python frameworks don't: | |
35 | + | |
36 | + - HTTP/3 (QUIC) through the full native pipeline (feature-gated) | |
37 | + - free-threaded CPython 3.14t wheels with auto-detected parallel dispatch | |
38 | + - Rust-native SSE β zero Python per event | |
39 | + - multi-worker prefork with auto-scaling in the CLI | |
40 | + - tokenless PyPI publishing (OIDC trusted publisher β tag push = release) | |
41 | + | |
42 | +Bugs I found in my own framework, because I benchmarked instead of assuming: | |
43 | + - concurrent Python writes collapsed to 3 RPS at 11 connections (a | |
44 | + double-acquire in request-scoped transactions) | |
45 | + - the GIL pool wasn't fork-safe β every request 504'd after a fork | |
46 | + - Pydantic models as body_schema crashed with a TypeError (broke the | |
47 | + FastAPI migration path) | |
48 | + - app.run() required an address, so the README's first example crashed | |
49 | + - the CLI shipped in the wheel was a stub; fixed in 2.0.10 | |
50 | + - Unix-only code wasn't cfg-gated; Windows didn't compile | |
51 | + | |
52 | +The honest caveats, because you will find these if you try it: | |
53 | + - the docs are large and much of it is AI-written; not every page is | |
54 | + independently verified. If a doc claims something, test it. | |
55 | + - the LLM/inference features (OpenAI-compatible server, KV cache, etc.) are | |
56 | + implemented but never run with real GPU weights. No claims. | |
57 | + - light async handlers (just asyncio.sleep, no I/O) are 4-12k req/s β the | |
58 | + asyncio floor. Granian is faster on that shape. Measured, not hidden. | |
59 | + - there is no "beats everything" claim. There is a benchmark ledger and a | |
60 | + roadmap that marks 17 phases as "implemented, not verified." | |
61 | + | |
62 | +I built this with agentic AI as a guide β it helped write a lot of the code | |
63 | +and docs, and it also wrote things that were wrong, which I only caught by | |
64 | +benchmarking and testing everything myself. Treat the AI-written parts with | |
65 | +suspicion. | |
66 | + | |
67 | +If you want the real write-up (with architecture diagram and the full async | |
68 | +failure log): https://github.com/swadhinbiswas/JustAPI/blob/main/ARTICLE.md | |
69 | + | |
70 | +PyPI: https://pypi.org/project/justapi/ | |
71 | +Repo: https://github.com/swadhinbiswas/JustAPI | |
72 | +Benchmark ledger: https://github.com/swadhinbiswas/JustAPI/blob/main/BENCHMARKS.md | |
73 | +ADR log (all 93 decisions, including the failures): https://github.com/swadhinbiswas/JustAPI/blob/main/DECISIONS.md | |
74 | + | |
75 | +Swadhin Biswas | |
0 | 76 | |
βΌdemo_app.py
+50-0
| @@ -0,0 +1,50 @@ | ||
1 | +import asyncio | |
2 | +from justapi import JustAPIApp | |
3 | +from pydantic import BaseModel | |
4 | +from typing import Optional | |
5 | + | |
6 | +# 1. Initialize the App | |
7 | +app = JustAPIApp() | |
8 | + | |
9 | +# 2. Add some Middleware (Auth, CORS, Security) | |
10 | +# (assuming middleware plugin system is configured) | |
11 | + | |
12 | +# 3. Request Validation Models | |
13 | +class ItemRequest(BaseModel): | |
14 | + name: str | |
15 | + price: float | |
16 | + description: Optional[str] = None | |
17 | + | |
18 | +# 4. Standard Async Route (High Concurrency) | |
19 | +@app.get("/async-demo") | |
20 | +async def async_demo(): | |
21 | + await asyncio.sleep(0.01) # Simulate DB or IO delay | |
22 | + return {"status": "success", "message": "Handled without blocking the Tokio loop!"} | |
23 | + | |
24 | +# 5. Native CPU-bound Route | |
25 | +@app.get("/cpu-heavy") | |
26 | +def cpu_heavy(): | |
27 | + # Will be offloaded natively via spawn_blocking | |
28 | + total = sum(i * i for i in range(10000)) | |
29 | + return {"total": total} | |
30 | + | |
31 | +# 6. JSON Validation & Serialization Route | |
32 | +@app.post("/items") | |
33 | +async def create_item(item: ItemRequest): | |
34 | + # 'item' is fully validated in Rust before Python executes! | |
35 | + return {"message": "Item created successfully", "data": item.model_dump()} | |
36 | + | |
37 | +# 7. gRPC Integration Mock Route (assuming defined protobuf) | |
38 | +@app.get("/grpc-status") | |
39 | +def grpc_status(): | |
40 | + return {"grpc_port": 50051, "status": "running concurrently"} | |
41 | + | |
42 | +# 8. Start the Application | |
43 | +if __name__ == "__main__": | |
44 | + print("π Starting JustAPI Demo Application on http://127.0.0.1:8080") | |
45 | + print("Try the following endpoints:") | |
46 | + print(" - GET http://127.0.0.1:8080/async-demo") | |
47 | + print(" - GET http://127.0.0.1:8080/cpu-heavy") | |
48 | + print(" - POST http://127.0.0.1:8080/items (Body: {\"name\": \"Test\", \"price\": 99.99})") | |
49 | + | |
50 | + app.run("127.0.0.1:8080") | |
0 | 51 | |