matchapi 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,31 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-12
10
+
11
+ A request pays for what the handler reads. On HTTP/1.1 matchapi serves as many requests as a bare RSGI
12
+ application answering the same routes, and more than Emmett, BlackSheep, Starlette, Litestar and FastAPI under
13
+ the same granian (`bench/`).
14
+
15
+ ### Changed
16
+
17
+ - An HTTP/1 request without `content-length` or `transfer-encoding` has no body (RFC 9112, section 6.3), so the
18
+ app no longer awaits one; `req.body` is `b""`. HTTP/2 requests still await the body.
19
+ - `req.route`, `req.query` and `req.headers` are computed on first read and kept. They are read-only properties
20
+ now: assigning to them raises `AttributeError`.
21
+ - `str`, `dict`, `list` and `(status, one of those)` return values go to granian without a `Response`, and
22
+ granian sets `content-length`; `respond()` still describes the same response.
23
+ - JSON responses reuse one encoder instead of building one per `json.dumps` call.
24
+
25
+ ### Added
26
+
27
+ - `bench/` runs Emmett, BlackSheep, Starlette, Litestar and FastAPI, pinned, under the same granian next to
28
+ matchapi, a `POST` with a JSON body, and `--http2`. The previous matchapi from PyPI replaces 0.1.0 under
29
+ uvicorn.
30
+ - The README opens with the measured numbers: a Performance section with the HTTP/1.1 table and a chart,
31
+ `docs/bench.svg`, drawn from `bench/README.md` by `bench/chart.py`; badges for coverage, `mypy --strict`, Ruff,
32
+ the one dependency and the one file.
33
+
9
34
  ## [0.2.1] - 2026-10-12
10
35
 
11
36
  Package metadata only; the code is unchanged.
@@ -51,6 +76,8 @@ the handler contract, the request and the response rules are unchanged.
51
76
  - The `matchapi` command: `matchapi app` serves the module's `handle` with uvicorn; `--host`, `--port`,
52
77
  `--workers`, `--reload`.
53
78
 
54
- [Unreleased]: https://github.com/troyan-dy/matchapi/compare/v0.2.0...HEAD
79
+ [Unreleased]: https://github.com/troyan-dy/matchapi/compare/v0.3.0...HEAD
80
+ [0.3.0]: https://github.com/troyan-dy/matchapi/compare/v0.2.1...v0.3.0
81
+ [0.2.1]: https://github.com/troyan-dy/matchapi/compare/v0.2.0...v0.2.1
55
82
  [0.2.0]: https://github.com/troyan-dy/matchapi/compare/v0.1.0...v0.2.0
56
83
  [0.1.0]: https://github.com/troyan-dy/matchapi/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: matchapi
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: One-function HTTP framework for granian over RSGI: match replaces the router
5
5
  Project-URL: Homepage, https://github.com/troyan-dy/matchapi
6
6
  Project-URL: Repository, https://github.com/troyan-dy/matchapi
@@ -35,6 +35,12 @@ Description-Content-Type: text/markdown
35
35
  [![PyPI](https://img.shields.io/pypi/v/matchapi)](https://pypi.org/project/matchapi/)
36
36
  [![Python](https://img.shields.io/pypi/pyversions/matchapi)](https://pypi.org/project/matchapi/)
37
37
  [![CI](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml/badge.svg)](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml)
38
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/troyan-dy/matchapi/blob/master/README.md#development)
39
+ [![mypy](https://img.shields.io/badge/mypy-strict-blue)](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict)
40
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
+ [![HTTP/1.1](https://img.shields.io/badge/HTTP%2F1.1-at%20the%20RSGI%20ceiling-2a78d6)](https://github.com/troyan-dy/matchapi/blob/master/README.md#performance)
42
+ [![Dependencies](https://img.shields.io/badge/dependencies-granian-orange)](https://github.com/emmett-framework/granian)
43
+ [![One file](https://img.shields.io/badge/one%20file-~300%20lines-informational)](https://github.com/troyan-dy/matchapi/blob/master/src/matchapi/__init__.py)
38
44
  [![License](https://img.shields.io/pypi/l/matchapi)](LICENSE)
39
45
 
40
46
  An HTTP service is one function. `match` replaces the router.
@@ -43,6 +49,12 @@ A one-file Python web framework for [granian](https://github.com/emmett-framewor
43
49
  [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
44
50
  interface: no router, no middleware, no ASGI adapter. Python 3.11+.
45
51
 
52
+ - **Fast.** On HTTP/1.1 within a few percent of a bare RSGI application written by hand, and 1.16 to 3.1 times
53
+ the requests per second of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
54
+ [Performance](#performance).
55
+ - **Small.** One file of about 300 lines that reads in half an hour, pure Python, one dependency: granian.
56
+ - **Tested.** 100% line and branch coverage, `mypy --strict`, a real granian in the test suite.
57
+
46
58
  ```python
47
59
  # app.py
48
60
  import matchapi
@@ -98,6 +110,7 @@ Not Found
98
110
  One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
99
111
  decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
100
112
 
113
+ - [Performance](#performance)
101
114
  - [Why](#why)
102
115
  - [The model](#the-model)
103
116
  - [One function](#one-function)
@@ -113,6 +126,29 @@ decorated `handle` is still the function it was: a test calls it with a request
113
126
  - [Design notes](#design-notes)
114
127
  - [Development](#development)
115
128
 
129
+ ## Performance
130
+
131
+ ![Requests per second over HTTP/1.1, one granian worker: matchapi 43,055 on GET /users/42 and 23,439 on POST /users, level with a bare RSGI application; BlackSheep, Starlette, Emmett, Litestar and FastAPI below](https://raw.githubusercontent.com/troyan-dy/matchapi/master/docs/bench.svg)
132
+
133
+ The README's service, written the way each framework writes it, under the same granian, one worker, `oha` with
134
+ 64 connections; the multiplier is how many times matchapi's requests per second:
135
+
136
+ | Requests/s, HTTP/1.1, higher is better | `GET /ping` | `GET /users/42` | `POST /users` |
137
+ |---|---:|---:|---:|
138
+ | bare RSGI, no framework (the ceiling) | 45,264 | 42,993 | 24,432 |
139
+ | **matchapi** | **45,049** | **43,055** | **23,439** |
140
+ | Emmett 2.8 (RSGI) | 38,733 (1.16×) | 30,135 (1.43×) | 17,574 (1.33×) |
141
+ | BlackSheep 2.6 | 39,000 (1.16×) | 33,794 (1.27×) | 13,878 (1.69×) |
142
+ | Starlette 1.7 | 32,612 (1.38×) | 30,206 (1.43×) | 13,071 (1.79×) |
143
+ | Litestar 2.24 | 26,005 (1.73×) | 23,974 (1.80×) | 11,878 (1.97×) |
144
+ | FastAPI 0.143 | 17,819 (2.53×) | 13,940 (3.09×) | 8,501 (2.76×) |
145
+
146
+ matchapi parses nothing the handler does not read: `route`, `query` and `headers` are computed on first use, an
147
+ HTTP/1 request without a body is not waited on, and the commonest return values go to granian without an
148
+ intermediate object. Over HTTP/2 every request still awaits its body, and there Emmett and BlackSheep are ahead on
149
+ `GET`. [bench/](bench/) has both protocols, the method and the command that reproduces the tables; one laptop,
150
+ Python 3.14, granian 2.8.4.
151
+
116
152
  ## Why
117
153
 
118
154
  Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
@@ -174,6 +210,11 @@ There is no scope or stream underneath: the body is read before the handler runs
174
210
  `await`. `Request("POST", "/users", body=b"...")`
175
211
  builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
176
212
 
213
+ Nothing is parsed that the handler does not read: `route`, `query` and `headers` are computed on first use, and
214
+ an HTTP/1 request without `content-length` or `transfer-encoding` has no body to wait for. On HTTP/1.1
215
+ [bench/](bench/) cannot tell matchapi from a bare RSGI application answering the same routes by hand, and puts
216
+ it ahead of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
217
+
177
218
  ### The return value is the response
178
219
 
179
220
  | Return | Response |
@@ -323,7 +364,7 @@ cookies or sessions, no templates, no static files, no websockets, no streaming
323
364
  Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
324
365
  interface is RSGI, so there is no middleware layer to plug into.
325
366
 
326
- The line is deliberate. The framework fits in one file of about 200 lines that reads in half an hour, and the
367
+ The line is deliberate. The framework fits in one file of about 300 lines that reads in half an hour, and the
327
368
  handler stays a function that works without a server.
328
369
 
329
370
  ## Design notes
@@ -331,7 +372,7 @@ handler stays a function that works without a server.
331
372
  The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
332
373
  went instead of the signature magic, why a decorator rather than a command, why the body is read before the
333
374
  call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
334
- glossary. [bench/](bench/) measures the framework's own share of a request.
375
+ glossary. [bench/](bench/) measures matchapi against a bare RSGI application and against other frameworks.
335
376
 
336
377
  ## Development
337
378
 
@@ -3,6 +3,12 @@
3
3
  [![PyPI](https://img.shields.io/pypi/v/matchapi)](https://pypi.org/project/matchapi/)
4
4
  [![Python](https://img.shields.io/pypi/pyversions/matchapi)](https://pypi.org/project/matchapi/)
5
5
  [![CI](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml/badge.svg)](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml)
6
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/troyan-dy/matchapi/blob/master/README.md#development)
7
+ [![mypy](https://img.shields.io/badge/mypy-strict-blue)](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict)
8
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![HTTP/1.1](https://img.shields.io/badge/HTTP%2F1.1-at%20the%20RSGI%20ceiling-2a78d6)](https://github.com/troyan-dy/matchapi/blob/master/README.md#performance)
10
+ [![Dependencies](https://img.shields.io/badge/dependencies-granian-orange)](https://github.com/emmett-framework/granian)
11
+ [![One file](https://img.shields.io/badge/one%20file-~300%20lines-informational)](https://github.com/troyan-dy/matchapi/blob/master/src/matchapi/__init__.py)
6
12
  [![License](https://img.shields.io/pypi/l/matchapi)](LICENSE)
7
13
 
8
14
  An HTTP service is one function. `match` replaces the router.
@@ -11,6 +17,12 @@ A one-file Python web framework for [granian](https://github.com/emmett-framewor
11
17
  [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
12
18
  interface: no router, no middleware, no ASGI adapter. Python 3.11+.
13
19
 
20
+ - **Fast.** On HTTP/1.1 within a few percent of a bare RSGI application written by hand, and 1.16 to 3.1 times
21
+ the requests per second of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
22
+ [Performance](#performance).
23
+ - **Small.** One file of about 300 lines that reads in half an hour, pure Python, one dependency: granian.
24
+ - **Tested.** 100% line and branch coverage, `mypy --strict`, a real granian in the test suite.
25
+
14
26
  ```python
15
27
  # app.py
16
28
  import matchapi
@@ -66,6 +78,7 @@ Not Found
66
78
  One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
67
79
  decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
68
80
 
81
+ - [Performance](#performance)
69
82
  - [Why](#why)
70
83
  - [The model](#the-model)
71
84
  - [One function](#one-function)
@@ -81,6 +94,29 @@ decorated `handle` is still the function it was: a test calls it with a request
81
94
  - [Design notes](#design-notes)
82
95
  - [Development](#development)
83
96
 
97
+ ## Performance
98
+
99
+ ![Requests per second over HTTP/1.1, one granian worker: matchapi 43,055 on GET /users/42 and 23,439 on POST /users, level with a bare RSGI application; BlackSheep, Starlette, Emmett, Litestar and FastAPI below](https://raw.githubusercontent.com/troyan-dy/matchapi/master/docs/bench.svg)
100
+
101
+ The README's service, written the way each framework writes it, under the same granian, one worker, `oha` with
102
+ 64 connections; the multiplier is how many times matchapi's requests per second:
103
+
104
+ | Requests/s, HTTP/1.1, higher is better | `GET /ping` | `GET /users/42` | `POST /users` |
105
+ |---|---:|---:|---:|
106
+ | bare RSGI, no framework (the ceiling) | 45,264 | 42,993 | 24,432 |
107
+ | **matchapi** | **45,049** | **43,055** | **23,439** |
108
+ | Emmett 2.8 (RSGI) | 38,733 (1.16×) | 30,135 (1.43×) | 17,574 (1.33×) |
109
+ | BlackSheep 2.6 | 39,000 (1.16×) | 33,794 (1.27×) | 13,878 (1.69×) |
110
+ | Starlette 1.7 | 32,612 (1.38×) | 30,206 (1.43×) | 13,071 (1.79×) |
111
+ | Litestar 2.24 | 26,005 (1.73×) | 23,974 (1.80×) | 11,878 (1.97×) |
112
+ | FastAPI 0.143 | 17,819 (2.53×) | 13,940 (3.09×) | 8,501 (2.76×) |
113
+
114
+ matchapi parses nothing the handler does not read: `route`, `query` and `headers` are computed on first use, an
115
+ HTTP/1 request without a body is not waited on, and the commonest return values go to granian without an
116
+ intermediate object. Over HTTP/2 every request still awaits its body, and there Emmett and BlackSheep are ahead on
117
+ `GET`. [bench/](bench/) has both protocols, the method and the command that reproduces the tables; one laptop,
118
+ Python 3.14, granian 2.8.4.
119
+
84
120
  ## Why
85
121
 
86
122
  Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
@@ -142,6 +178,11 @@ There is no scope or stream underneath: the body is read before the handler runs
142
178
  `await`. `Request("POST", "/users", body=b"...")`
143
179
  builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
144
180
 
181
+ Nothing is parsed that the handler does not read: `route`, `query` and `headers` are computed on first use, and
182
+ an HTTP/1 request without `content-length` or `transfer-encoding` has no body to wait for. On HTTP/1.1
183
+ [bench/](bench/) cannot tell matchapi from a bare RSGI application answering the same routes by hand, and puts
184
+ it ahead of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
185
+
145
186
  ### The return value is the response
146
187
 
147
188
  | Return | Response |
@@ -291,7 +332,7 @@ cookies or sessions, no templates, no static files, no websockets, no streaming
291
332
  Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
292
333
  interface is RSGI, so there is no middleware layer to plug into.
293
334
 
294
- The line is deliberate. The framework fits in one file of about 200 lines that reads in half an hour, and the
335
+ The line is deliberate. The framework fits in one file of about 300 lines that reads in half an hour, and the
295
336
  handler stays a function that works without a server.
296
337
 
297
338
  ## Design notes
@@ -299,7 +340,7 @@ handler stays a function that works without a server.
299
340
  The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
300
341
  went instead of the signature magic, why a decorator rather than a command, why the body is read before the
301
342
  call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
302
- glossary. [bench/](bench/) measures the framework's own share of a request.
343
+ glossary. [bench/](bench/) measures matchapi against a bare RSGI application and against other frameworks.
303
344
 
304
345
  ## Development
305
346
 
@@ -0,0 +1,114 @@
1
+ # bench
2
+
3
+ matchapi against the ceiling and against other frameworks. Every server answers the README's routes under the
4
+ same granian, `oha` hits each one, and the tables are the result:
5
+
6
+ - **bare RSGI**: [bare.py](bare.py), an RSGI application with no framework that reads the body only for the
7
+ route that has one; the ceiling, what granian does alone.
8
+ - **bare RSGI reading every body**: [bare_body.py](bare_body.py), the same one awaiting the body of every
9
+ request, as matchapi must under HTTP/2 ([design.md](../docs/design.md), D3).
10
+ - **matchapi**: [service.py](service.py), the README's service, from this checkout.
11
+ - **matchapi 0.2.1**: the same file against the previous release from PyPI.
12
+ - **Emmett, BlackSheep, Starlette, Litestar, FastAPI**: [with_emmett.py](with_emmett.py),
13
+ [with_blacksheep.py](with_blacksheep.py), [with_starlette.py](with_starlette.py),
14
+ [with_litestar.py](with_litestar.py), [with_fastapi.py](with_fastapi.py), the same routes written the way each
15
+ framework's documentation writes them. Emmett speaks RSGI like matchapi; the others are ASGI applications
16
+ under `granian --interface asgi`.
17
+
18
+ Everything outside this checkout is pinned and installed into a throwaway environment by `uv`, with the granian
19
+ of this checkout. One worker, granian's defaults otherwise, so every framework gets the same event loop.
20
+
21
+ ```console
22
+ $ uv run bench/run.py # HTTP/1.1, 10 s per server and request, 64 connections
23
+ $ uv run bench/run.py --http2 # the same over HTTP/2 (h2c)
24
+ $ uv run bench/run.py -z 3s -c 16 # a quick look
25
+ $ uv run bench/chart.py # the README's chart, docs/bench.svg, from the HTTP/1.1 table below
26
+ ```
27
+
28
+ Needs [oha](https://github.com/hatoo/oha) and `uv` on the PATH. The bench is not part of CI: the numbers below
29
+ are one run on one laptop, and only the ratios between the rows mean anything. A run stops if any response has
30
+ a status other than the one the route answers with.
31
+
32
+ ## Results
33
+
34
+ ### HTTP/1.1
35
+
36
+ Python 3.14.6, Darwin arm64, granian 2.8.4, 2026-10-12; oha, HTTP/1.1, 64 connections, 10s per cell, one worker.
37
+
38
+ | Server | Request | Requests/s | p50 ms | p99 ms | Of the ceiling |
39
+ |---|---|---:|---:|---:|---:|
40
+ | bare RSGI | `GET /ping` | 45,264 | 1.39 | 2.13 | 100% |
41
+ | bare RSGI | `GET /users/42` | 42,993 | 1.48 | 2.10 | 100% |
42
+ | bare RSGI | `POST /users` | 24,432 | 2.59 | 3.48 | 100% |
43
+ | bare RSGI reading every body | `GET /ping` | 27,284 | 2.32 | 3.39 | 60% |
44
+ | bare RSGI reading every body | `GET /users/42` | 24,598 | 2.57 | 3.47 | 57% |
45
+ | bare RSGI reading every body | `POST /users` | 23,979 | 2.57 | 4.64 | 98% |
46
+ | matchapi | `GET /ping` | 45,049 | 1.40 | 2.12 | 100% |
47
+ | matchapi | `GET /users/42` | 43,055 | 1.46 | 2.37 | 100% |
48
+ | matchapi | `POST /users` | 23,439 | 2.67 | 4.18 | 96% |
49
+ | matchapi 0.2.1 | `GET /ping` | 24,006 | 2.62 | 3.95 | 53% |
50
+ | matchapi 0.2.1 | `GET /users/42` | 23,293 | 2.72 | 3.68 | 54% |
51
+ | matchapi 0.2.1 | `POST /users` | 20,808 | 3.03 | 4.81 | 85% |
52
+ | Emmett 2.8 (RSGI) | `GET /ping` | 38,733 | 1.64 | 2.41 | 86% |
53
+ | Emmett 2.8 (RSGI) | `GET /users/42` | 30,135 | 2.06 | 3.96 | 70% |
54
+ | Emmett 2.8 (RSGI) | `POST /users` | 17,574 | 3.55 | 5.19 | 72% |
55
+ | BlackSheep 2.6 (ASGI) | `GET /ping` | 39,000 | 1.63 | 2.39 | 86% |
56
+ | BlackSheep 2.6 (ASGI) | `GET /users/42` | 33,794 | 1.88 | 2.79 | 79% |
57
+ | BlackSheep 2.6 (ASGI) | `POST /users` | 13,878 | 4.43 | 6.88 | 57% |
58
+ | Starlette 1.7 (ASGI) | `GET /ping` | 32,612 | 1.94 | 3.06 | 72% |
59
+ | Starlette 1.7 (ASGI) | `GET /users/42` | 30,206 | 2.10 | 3.06 | 70% |
60
+ | Starlette 1.7 (ASGI) | `POST /users` | 13,071 | 4.69 | 10.83 | 53% |
61
+ | Litestar 2.24 (ASGI) | `GET /ping` | 26,005 | 2.44 | 3.86 | 57% |
62
+ | Litestar 2.24 (ASGI) | `GET /users/42` | 23,974 | 2.65 | 3.88 | 56% |
63
+ | Litestar 2.24 (ASGI) | `POST /users` | 11,878 | 5.31 | 7.10 | 49% |
64
+ | FastAPI 0.143 (ASGI) | `GET /ping` | 17,819 | 3.55 | 4.65 | 39% |
65
+ | FastAPI 0.143 (ASGI) | `GET /users/42` | 13,940 | 4.51 | 5.71 | 32% |
66
+ | FastAPI 0.143 (ASGI) | `POST /users` | 8,501 | 7.47 | 9.92 | 35% |
67
+
68
+ ### HTTP/2
69
+
70
+ Python 3.14.6, Darwin arm64, granian 2.8.4, 2026-10-12; oha, HTTP/2, 64 connections, 10s per cell, one worker.
71
+
72
+ | Server | Request | Requests/s | p50 ms | p99 ms | Of the ceiling |
73
+ |---|---|---:|---:|---:|---:|
74
+ | bare RSGI | `GET /ping` | 41,217 | 1.53 | 2.25 | 100% |
75
+ | bare RSGI | `GET /users/42` | 38,342 | 1.65 | 2.40 | 100% |
76
+ | bare RSGI | `POST /users` | 22,500 | 2.75 | 5.26 | 100% |
77
+ | bare RSGI reading every body | `GET /ping` | 26,675 | 2.36 | 3.36 | 65% |
78
+ | bare RSGI reading every body | `GET /users/42` | 25,586 | 2.47 | 3.44 | 67% |
79
+ | bare RSGI reading every body | `POST /users` | 23,221 | 2.73 | 3.56 | 103% |
80
+ | matchapi | `GET /ping` | 25,344 | 2.48 | 3.21 | 61% |
81
+ | matchapi | `GET /users/42` | 25,044 | 2.53 | 3.42 | 65% |
82
+ | matchapi | `POST /users` | 23,107 | 2.73 | 3.93 | 103% |
83
+ | matchapi 0.2.1 | `GET /ping` | 24,296 | 2.61 | 3.55 | 59% |
84
+ | matchapi 0.2.1 | `GET /users/42` | 23,012 | 2.75 | 3.71 | 60% |
85
+ | matchapi 0.2.1 | `POST /users` | 19,922 | 3.19 | 4.01 | 89% |
86
+ | Emmett 2.8 (RSGI) | `GET /ping` | 34,240 | 1.86 | 2.45 | 83% |
87
+ | Emmett 2.8 (RSGI) | `GET /users/42` | 26,988 | 2.37 | 2.97 | 70% |
88
+ | Emmett 2.8 (RSGI) | `POST /users` | 15,489 | 4.06 | 5.63 | 69% |
89
+ | BlackSheep 2.6 (ASGI) | `GET /ping` | 34,698 | 1.84 | 2.45 | 84% |
90
+ | BlackSheep 2.6 (ASGI) | `GET /users/42` | 30,883 | 2.07 | 2.71 | 81% |
91
+ | BlackSheep 2.6 (ASGI) | `POST /users` | 13,574 | 4.69 | 5.57 | 60% |
92
+ | Starlette 1.7 (ASGI) | `GET /ping` | 29,971 | 2.13 | 2.78 | 73% |
93
+ | Starlette 1.7 (ASGI) | `GET /users/42` | 26,462 | 2.36 | 4.66 | 69% |
94
+ | Starlette 1.7 (ASGI) | `POST /users` | 12,686 | 5.01 | 6.85 | 56% |
95
+ | Litestar 2.24 (ASGI) | `GET /ping` | 23,102 | 2.77 | 3.48 | 56% |
96
+ | Litestar 2.24 (ASGI) | `GET /users/42` | 20,711 | 3.05 | 3.90 | 54% |
97
+ | Litestar 2.24 (ASGI) | `POST /users` | 10,941 | 5.82 | 6.79 | 49% |
98
+ | FastAPI 0.143 (ASGI) | `GET /ping` | 16,120 | 3.97 | 4.68 | 39% |
99
+ | FastAPI 0.143 (ASGI) | `GET /users/42` | 12,359 | 5.16 | 5.84 | 32% |
100
+ | FastAPI 0.143 (ASGI) | `POST /users` | 7,930 | 7.96 | 13.59 | 35% |
101
+
102
+ ### What the rows say
103
+
104
+ - On HTTP/1.1 matchapi is the ceiling: the `GET` rows are within noise of the bare application, and the `POST`
105
+ row, which must await and parse a body, is within a few percent of it. Nothing in the framework is left to
106
+ optimise there ([design.md](../docs/design.md), D8 and D9).
107
+ - On HTTP/1.1 matchapi serves more requests than every framework measured: `GET /users/42` 1.27 times
108
+ BlackSheep and 1.43 times Emmett, `POST /users` 1.33 times Emmett, and 2.5 to 3.1 times FastAPI.
109
+ - Against 0.2.1, the release before it, `GET` is 1.9 times faster on HTTP/1.1: 0.2.1 awaited the body of every
110
+ request, and that await alone costs about forty percent of the ceiling (the "reading every body" rows).
111
+ - On HTTP/2 every request still awaits the body (D3), so the `GET` rows sit where "bare RSGI reading every
112
+ body" sits, about two thirds of the ceiling, behind Emmett, BlackSheep and Starlette, which read the body
113
+ only when a handler asks for it. `POST` on HTTP/2 is still ahead of every framework. The open question in
114
+ design.md describes the ways out; both change the contract.
@@ -0,0 +1,27 @@
1
+ """A bare RSGI application with the README's routes: the ceiling, what granian does with no framework at all.
2
+
3
+ It reads the request body only for the route that has one, as a hand-written application would.
4
+ """
5
+
6
+ import json
7
+
8
+ TEXT = [("content-type", "text/plain; charset=utf-8")]
9
+ JSON = [("content-type", "application/json")]
10
+
11
+
12
+ async def answer(scope, protocol, body):
13
+ if scope.method == "GET" and scope.path == "/ping":
14
+ protocol.response_str(200, TEXT, "pong")
15
+ elif scope.method == "GET" and scope.path.startswith("/users/"):
16
+ user = {"id": int(scope.path[7:]), "name": "ann"}
17
+ protocol.response_str(200, JSON, json.dumps(user, separators=(",", ":")))
18
+ elif scope.method == "POST" and scope.path == "/users":
19
+ user = {"id": 7, "name": json.loads(body)["name"]}
20
+ protocol.response_str(201, JSON, json.dumps(user, separators=(",", ":")))
21
+ else:
22
+ protocol.response_str(404, TEXT, "Not Found")
23
+
24
+
25
+ async def app(scope, protocol):
26
+ body = await protocol() if scope.method == "POST" else b""
27
+ await answer(scope, protocol, body)
@@ -0,0 +1,7 @@
1
+ """The bare RSGI application that reads the body of every request, as matchapi must under HTTP/2 (design.md, D3)."""
2
+
3
+ from bare import answer
4
+
5
+
6
+ async def app(scope, protocol):
7
+ await answer(scope, protocol, await protocol())
@@ -0,0 +1,117 @@
1
+ """The README's chart: the HTTP/1.1 table of bench/README.md as docs/bench.svg.
2
+
3
+ $ uv run bench/chart.py
4
+
5
+ matchapi in the accent, the other frameworks in gray, the bare RSGI application as the line every bar is
6
+ measured against; light and dark from the viewer's color scheme.
7
+ """
8
+
9
+ import re
10
+ from html import escape
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ FRAMEWORKS = ["matchapi", "Emmett", "BlackSheep", "Starlette", "Litestar", "FastAPI"]
15
+ PANELS = ["GET /users/42", "POST /users"]
16
+
17
+ WIDTH, PANEL, GAP, LABEL, ROW, BAR, TOP = 880, 420, 40, 96, 30, 18, 78
18
+ SCALE = 50_000 # one x-scale for both panels
19
+ FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
20
+ SOURCE = "bench/README.md · Python 3.14, granian 2.8.4, oha with 64 connections · higher is better"
21
+
22
+
23
+ def table() -> dict[tuple[str, str], float]:
24
+ """`(server, request) -> requests/s` from the HTTP/1.1 section."""
25
+ text = (ROOT / "bench" / "README.md").read_text().split("### HTTP/1.1", 1)[1].split("### HTTP/2", 1)[0]
26
+ rows = re.findall(r"^\| ([^|]+) \| `([^`]+)` \| ([\d,]+) \|", text, re.MULTILINE)
27
+ return {(server.strip(), request): float(rps.replace(",", "")) for server, request, rps in rows}
28
+
29
+
30
+ def name(server: str) -> str:
31
+ """`Emmett 2.8 (RSGI)` is `Emmett`; `matchapi 0.2.1`, the previous release, is not on the chart."""
32
+ if server in ("bare RSGI", "matchapi"):
33
+ return server
34
+ return "" if server.startswith("matchapi ") else server.split(" ")[0]
35
+
36
+
37
+ def bar(x: float, y: float, width: float) -> str:
38
+ """Square at the baseline, 4px round at the data end."""
39
+ r = min(4.0, width / 2)
40
+ return (
41
+ f"M{x:.1f},{y:.1f}h{width - r:.1f}a{r},{r} 0 0 1 {r},{r}v{BAR - 2 * r:.1f}"
42
+ f"a{r},{r} 0 0 1 {-r},{r}h{-(width - r):.1f}z"
43
+ )
44
+
45
+
46
+ def panel(data: dict[tuple[str, str], float], request: str, left: float) -> list[str]:
47
+ plot, inner = left + LABEL, PANEL - LABEL - 64
48
+ servers = {name(server): rps for (server, req), rps in data.items() if req == request}
49
+ ceiling = servers["bare RSGI"]
50
+ rows = sorted((rps, framework) for framework, rps in servers.items() if framework in FRAMEWORKS)[::-1]
51
+ height = len(rows) * ROW
52
+ out = [f'<text class="head" x="{left}" y="{TOP - 30}">{escape(request)}</text>']
53
+ for tick in range(0, SCALE + 1, 10_000):
54
+ x = plot + inner * tick / SCALE
55
+ out.append(f'<line class="grid" x1="{x:.1f}" x2="{x:.1f}" y1="{TOP - 6}" y2="{TOP + height}"/>')
56
+ out.append(f'<text class="tick" x="{x:.1f}" y="{TOP + height + 16}">{tick // 1000}k</text>')
57
+ for i, (rps, framework) in enumerate(rows):
58
+ y = TOP + i * ROW + (ROW - BAR) / 2
59
+ width = inner * rps / SCALE
60
+ kind = "accent" if framework == "matchapi" else "other"
61
+ out.append(f'<text class="name {kind}" x="{plot - 10}" y="{y + BAR / 2 + 4.5}">{framework}</text>')
62
+ out.append(
63
+ f'<path class="bar {kind}" d="{bar(plot, y, width)}"><title>{framework}: {rps:,.0f} req/s</title></path>'
64
+ )
65
+ out.append(f'<text class="value {kind}" x="{plot + width + 6:.1f}" y="{y + BAR / 2 + 4.5}">{rps:,.0f}</text>')
66
+ x = plot + inner * ceiling / SCALE
67
+ out.append(f'<line class="ceiling" x1="{x:.1f}" x2="{x:.1f}" y1="{TOP - 12}" y2="{TOP + height}"/>')
68
+ out.append(f'<text class="mark" x="{x:.1f}" y="{TOP - 16}">bare RSGI {ceiling:,.0f}</text>')
69
+ out.append(f'<line class="axis" x1="{plot}" x2="{plot}" y1="{TOP - 6}" y2="{TOP + height}"/>')
70
+ return out
71
+
72
+
73
+ def main() -> None:
74
+ data = table()
75
+ height = TOP + len(FRAMEWORKS) * ROW + 62
76
+ body = [line for i, request in enumerate(PANELS) for line in panel(data, request, i * (PANEL + GAP))]
77
+ get, post = data["matchapi", "GET /users/42"], data["matchapi", "POST /users"]
78
+ svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{height}"
79
+ viewBox="-10 0 {WIDTH} {height}" role="img" aria-labelledby="t d" font-family="{FONT}">
80
+ <title id="t">Requests per second over HTTP/1.1, one granian worker</title>
81
+ <desc id="d">matchapi serves {get:,.0f} GET and {post:,.0f} POST requests per second, level with a bare RSGI
82
+ application and ahead of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.</desc>
83
+ <style>
84
+ svg {{ --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781; --grid: #e1e0d9;
85
+ --axis: #c3c2b7; --accent: #2a78d6; --other: #b5b3ab; }}
86
+ @media (prefers-color-scheme: dark) {{
87
+ svg {{ --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781; --grid: #2c2c2a;
88
+ --axis: #383835; --accent: #3987e5; --other: #5e5d58; }}
89
+ }}
90
+ .bg {{ fill: var(--surface); }}
91
+ .title {{ fill: var(--ink); font-size: 15px; font-weight: 600; }}
92
+ .sub {{ fill: var(--muted); font-size: 12px; }}
93
+ .head {{ fill: var(--ink); font-size: 13px; font-weight: 600; font-family: ui-monospace, Menlo, monospace; }}
94
+ .grid {{ stroke: var(--grid); stroke-width: 1; }}
95
+ .axis {{ stroke: var(--axis); stroke-width: 1; }}
96
+ .tick {{ fill: var(--muted); font-size: 11px; text-anchor: middle; }}
97
+ .name {{ fill: var(--ink-2); font-size: 12.5px; text-anchor: end; }}
98
+ .name.accent, .value.accent {{ fill: var(--ink); font-weight: 600; }}
99
+ .value {{ fill: var(--ink-2); font-size: 12px; }}
100
+ .bar.accent {{ fill: var(--accent); }}
101
+ .bar.other {{ fill: var(--other); }}
102
+ .ceiling {{ stroke: var(--ink-2); stroke-width: 1.5; }}
103
+ .mark {{ fill: var(--ink-2); font-size: 11px; text-anchor: end; }}
104
+ </style>
105
+ <rect class="bg" x="-10" width="{WIDTH}" height="{height}" rx="8"/>
106
+ <text class="title" x="0" y="22">Requests per second, HTTP/1.1, one granian worker</text>
107
+ <g>
108
+ {chr(10).join(body)}
109
+ </g>
110
+ <text class="sub" x="0" y="{height - 12}">{SOURCE}</text>
111
+ </svg>
112
+ """
113
+ (ROOT / "docs" / "bench.svg").write_text(svg)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()