matchapi 0.2.0__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,40 @@ 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
+
34
+ ## [0.2.1] - 2026-10-12
35
+
36
+ Package metadata only; the code is unchanged.
37
+
38
+ ### Changed
39
+
40
+ - The PyPI description, keywords and classifiers name granian and RSGI, so the package is found by the stack it
41
+ runs on. The README opens with the same line.
42
+
9
43
  ## [0.2.0] - 2026-10-12
10
44
 
11
45
  The handler is decorated and granian serves it. The framework loses its command line and its ASGI adapter;
@@ -42,6 +76,8 @@ the handler contract, the request and the response rules are unchanged.
42
76
  - The `matchapi` command: `matchapi app` serves the module's `handle` with uvicorn; `--host`, `--port`,
43
77
  `--workers`, `--reload`.
44
78
 
45
- [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
46
82
  [0.2.0]: https://github.com/troyan-dy/matchapi/compare/v0.1.0...v0.2.0
47
83
  [0.1.0]: https://github.com/troyan-dy/matchapi/releases/tag/v0.1.0
@@ -1,12 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: matchapi
3
+ Version: 0.3.0
4
+ Summary: One-function HTTP framework for granian over RSGI: match replaces the router
5
+ Project-URL: Homepage, https://github.com/troyan-dy/matchapi
6
+ Project-URL: Repository, https://github.com/troyan-dy/matchapi
7
+ Project-URL: Issues, https://github.com/troyan-dy/matchapi/issues
8
+ Project-URL: Changelog, https://github.com/troyan-dy/matchapi/blob/master/CHANGELOG.md
9
+ Author: Dmitriy Troyan
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,async,asyncio,framework,granian,http,json,match,microframework,microservice,pattern-matching,rest,rsgi,rust,single-file,structural-pattern-matching,web,web-framework,webhook
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: AsyncIO
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
26
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
27
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.11
30
+ Requires-Dist: granian>=2.8
31
+ Description-Content-Type: text/markdown
32
+
1
33
  # matchapi
2
34
 
3
35
  [![PyPI](https://img.shields.io/pypi/v/matchapi)](https://pypi.org/project/matchapi/)
4
36
  [![Python](https://img.shields.io/pypi/pyversions/matchapi)](https://pypi.org/project/matchapi/)
5
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)
6
44
  [![License](https://img.shields.io/pypi/l/matchapi)](LICENSE)
7
45
 
8
46
  An HTTP service is one function. `match` replaces the router.
9
47
 
48
+ A one-file Python web framework for [granian](https://github.com/emmett-framework/granian) over
49
+ [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
50
+ interface: no router, no middleware, no ASGI adapter. Python 3.11+.
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
+
10
58
  ```python
11
59
  # app.py
12
60
  import matchapi
@@ -62,6 +110,7 @@ Not Found
62
110
  One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
63
111
  decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
64
112
 
113
+ - [Performance](#performance)
65
114
  - [Why](#why)
66
115
  - [The model](#the-model)
67
116
  - [One function](#one-function)
@@ -77,6 +126,29 @@ decorated `handle` is still the function it was: a test calls it with a request
77
126
  - [Design notes](#design-notes)
78
127
  - [Development](#development)
79
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
+
80
152
  ## Why
81
153
 
82
154
  Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
@@ -138,6 +210,11 @@ There is no scope or stream underneath: the body is read before the handler runs
138
210
  `await`. `Request("POST", "/users", body=b"...")`
139
211
  builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
140
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
+
141
218
  ### The return value is the response
142
219
 
143
220
  | Return | Response |
@@ -287,7 +364,7 @@ cookies or sessions, no templates, no static files, no websockets, no streaming
287
364
  Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
288
365
  interface is RSGI, so there is no middleware layer to plug into.
289
366
 
290
- 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
291
368
  handler stays a function that works without a server.
292
369
 
293
370
  ## Design notes
@@ -295,7 +372,7 @@ handler stays a function that works without a server.
295
372
  The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
296
373
  went instead of the signature magic, why a decorator rather than a command, why the body is read before the
297
374
  call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
298
- 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.
299
376
 
300
377
  ## Development
301
378
 
@@ -1,41 +1,28 @@
1
- Metadata-Version: 2.5
2
- Name: matchapi
3
- Version: 0.2.0
4
- Summary: An HTTP service is one function: match replaces the router
5
- Project-URL: Homepage, https://github.com/troyan-dy/matchapi
6
- Project-URL: Repository, https://github.com/troyan-dy/matchapi
7
- Project-URL: Issues, https://github.com/troyan-dy/matchapi/issues
8
- Project-URL: Changelog, https://github.com/troyan-dy/matchapi/blob/master/CHANGELOG.md
9
- Author: Dmitriy Troyan
10
- License-Expression: MIT
11
- License-File: LICENSE
12
- Keywords: framework,granian,http,match,microservice,pattern-matching,rsgi,web,webhook
13
- Classifier: Development Status :: 3 - Alpha
14
- Classifier: Framework :: AsyncIO
15
- Classifier: Intended Audience :: Developers
16
- Classifier: Operating System :: OS Independent
17
- Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3 :: Only
19
- Classifier: Programming Language :: Python :: 3.11
20
- Classifier: Programming Language :: Python :: 3.12
21
- Classifier: Programming Language :: Python :: 3.13
22
- Classifier: Programming Language :: Python :: 3.14
23
- Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
24
- Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
25
- Classifier: Typing :: Typed
26
- Requires-Python: >=3.11
27
- Requires-Dist: granian>=2.8
28
- Description-Content-Type: text/markdown
29
-
30
1
  # matchapi
31
2
 
32
3
  [![PyPI](https://img.shields.io/pypi/v/matchapi)](https://pypi.org/project/matchapi/)
33
4
  [![Python](https://img.shields.io/pypi/pyversions/matchapi)](https://pypi.org/project/matchapi/)
34
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)
35
12
  [![License](https://img.shields.io/pypi/l/matchapi)](LICENSE)
36
13
 
37
14
  An HTTP service is one function. `match` replaces the router.
38
15
 
16
+ A one-file Python web framework for [granian](https://github.com/emmett-framework/granian) over
17
+ [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
18
+ interface: no router, no middleware, no ASGI adapter. Python 3.11+.
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
+
39
26
  ```python
40
27
  # app.py
41
28
  import matchapi
@@ -91,6 +78,7 @@ Not Found
91
78
  One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
92
79
  decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
93
80
 
81
+ - [Performance](#performance)
94
82
  - [Why](#why)
95
83
  - [The model](#the-model)
96
84
  - [One function](#one-function)
@@ -106,6 +94,29 @@ decorated `handle` is still the function it was: a test calls it with a request
106
94
  - [Design notes](#design-notes)
107
95
  - [Development](#development)
108
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
+
109
120
  ## Why
110
121
 
111
122
  Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
@@ -167,6 +178,11 @@ There is no scope or stream underneath: the body is read before the handler runs
167
178
  `await`. `Request("POST", "/users", body=b"...")`
168
179
  builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
169
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
+
170
186
  ### The return value is the response
171
187
 
172
188
  | Return | Response |
@@ -316,7 +332,7 @@ cookies or sessions, no templates, no static files, no websockets, no streaming
316
332
  Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
317
333
  interface is RSGI, so there is no middleware layer to plug into.
318
334
 
319
- 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
320
336
  handler stays a function that works without a server.
321
337
 
322
338
  ## Design notes
@@ -324,7 +340,7 @@ handler stays a function that works without a server.
324
340
  The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
325
341
  went instead of the signature magic, why a decorator rather than a command, why the body is read before the
326
342
  call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
327
- 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.
328
344
 
329
345
  ## Development
330
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()