xtr-http-kernel 1.5.0__tar.gz → 3.0.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.
- xtr_http_kernel-1.5.0/README.md → xtr_http_kernel-3.0.0/PKG-INFO +127 -6
- xtr_http_kernel-1.5.0/PKG-INFO → xtr_http_kernel-3.0.0/README.md +93 -37
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/pyproject.toml +19 -10
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/pyproject.toml.orig +20 -10
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/SKILL.md +273 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/configure.md +44 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/listeners.md +27 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/rate-limits.md +107 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +18 -11
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py +6 -4
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +1 -1
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +52 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/__init__.py +12 -1
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/invalid_argument_error.py +25 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +26 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/too_many_requests_error.py +50 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +34 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/rate_limiter/__init__.py +11 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +40 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/rate_limiter/rate_limited.py +283 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/recipe/__init__.py +15 -0
- xtr_http_kernel-3.0.0/src/xtr_http_kernel/recipe/manifest.toml +24 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py +7 -1
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/LICENSE +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/__init__.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_state.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/__init__.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/__init__.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/_route_contexts.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/debug_router_command.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/route_description.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_command.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_match_command.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/__init__.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/exception_event.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/request_event.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/response_event.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/terminate_event.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/request_id_listener.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/kernel_events.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_stack.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_tag.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/py.typed +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/setup.py +0 -0
- {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/testing.py +0 -0
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-http-kernel
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel.
|
|
5
|
+
Keywords: http,kernel,request,lifecycle,events,middleware,fastapi
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: fastapi>=0.121.3
|
|
18
|
+
Requires-Dist: starlette>=0.40
|
|
19
|
+
Requires-Dist: xtr-dependency-injection[fastapi]>=3.0,<4
|
|
20
|
+
Requires-Dist: xtr-event-dispatcher>=3.0,<4
|
|
21
|
+
Requires-Dist: xtr-event-dispatcher-contracts>=3.0,<4
|
|
22
|
+
Requires-Dist: xtr-logging-contracts>=3.0,<4
|
|
23
|
+
Requires-Dist: xtr-service-contracts>=3.0,<4
|
|
24
|
+
Requires-Dist: typing-extensions>=4.4
|
|
25
|
+
Requires-Dist: xtr-console>=3.0,<4 ; extra == 'console'
|
|
26
|
+
Requires-Dist: xtr-logging>=3.0,<4 ; extra == 'logging'
|
|
27
|
+
Requires-Dist: xtr-clock>=3.0,<4 ; extra == 'rate-limiter'
|
|
28
|
+
Requires-Dist: xtr-rate-limiter[di]>=3.0,<4 ; extra == 'rate-limiter'
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Provides-Extra: console
|
|
31
|
+
Provides-Extra: logging
|
|
32
|
+
Provides-Extra: rate-limiter
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
1
35
|
<div align="center">
|
|
2
36
|
|
|
3
37
|
# xtr-http-kernel
|
|
@@ -38,6 +72,7 @@ Listeners are services, so they come from the container with everything else the
|
|
|
38
72
|
uv add xtr-http-kernel # the lifecycle, its bundle and setup
|
|
39
73
|
uv add "xtr-http-kernel[logging]" # + the listeners that log
|
|
40
74
|
uv add "xtr-http-kernel[console]" # + the commands that report on the router
|
|
75
|
+
uv add "xtr-http-kernel[rate-limiter]" # + rate limits on routes, routers and the app
|
|
41
76
|
```
|
|
42
77
|
|
|
43
78
|
Requires Python 3.11+. The web framework and the container layer come with the package
|
|
@@ -124,7 +159,7 @@ contribute. Each is dispatched at most once, however the request went:
|
|
|
124
159
|
|---|---|---|---|
|
|
125
160
|
| `RequestEvent` | `REQUEST` | it arrived, nothing has looked at it | read it, or `set_response(...)` to answer instead of the application |
|
|
126
161
|
| `ResponseEvent` | `RESPONSE` | a response is about to start | assign `status_code`, change `headers` in place |
|
|
127
|
-
| `ExceptionEvent` | `EXCEPTION` | handling raised, nothing was sent | read `exception`, or `set_response(...)` to answer with it |
|
|
162
|
+
| `ExceptionEvent` | `EXCEPTION` | handling raised, nothing was sent | read `exception`, or `set_response(...)` to answer with it — an `Exception` only: a cancellation, interrupt or exit is announced and goes on |
|
|
128
163
|
| `FinishRequestEvent` | `FINISH_REQUEST` | handling finished — **on every path** | put away what the request set up |
|
|
129
164
|
| `TerminateEvent` | `TERMINATE` | everything was sent | work worth doing once the caller has their answer |
|
|
130
165
|
|
|
@@ -194,11 +229,87 @@ The bundle registers these; which ones depend on what is installed and active:
|
|
|
194
229
|
| `DisallowRobotsIndexingListener` | `ResponseEvent` | always | stamps `X-Robots-Tag: noindex` on every response, when the config turns it on |
|
|
195
230
|
| `LogUnitListener` | `RequestEvent`, `TerminateEvent` | logging bundle active | opens a [logging unit of work](../xtr-logging#units-of-work) per request and closes it once all was sent |
|
|
196
231
|
| `ErrorLoggingListener` | `ExceptionEvent` | logging bundle active | writes every uncaught exception to the request channel — `error` below a 500 status, `critical` otherwise — leaving the response to whoever answers it |
|
|
232
|
+
| `RateLimitHeadersListener` | `ResponseEvent` | rate limiter bundle active | writes the `X-RateLimit-*` headers of the [rate limit](#rate-limits) that speaks for the response, and makes it private |
|
|
197
233
|
|
|
198
234
|
The two logging listeners join only when the logging bundle is active, and open and close the
|
|
199
235
|
unit of work outside everything else so every record made while handling carries the request's
|
|
200
236
|
id. The request id settles right after the unit opens, for the same reason.
|
|
201
237
|
|
|
238
|
+
## Rate limits
|
|
239
|
+
|
|
240
|
+
With the `rate-limiter` extra, a route, a router or the whole application can be held to a
|
|
241
|
+
limiter configured in [xtr-rate-limiter](../xtr-rate-limiter)'s bundle. `RateLimited` is one
|
|
242
|
+
declaration, written as a decorator or wherever the framework takes a dependency:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
246
|
+
|
|
247
|
+
from xtr_http_kernel.rate_limiter import RateLimited
|
|
248
|
+
|
|
249
|
+
app = FastAPI(dependencies=[RateLimited("global")]) # every route
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.get("/books")
|
|
253
|
+
@RateLimited("api", expose_headers=True) # below the route decorator
|
|
254
|
+
async def list_books() -> list[Book]: ...
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def by_username(request: Request) -> str:
|
|
258
|
+
return request.headers.get("x-username", "anonymous")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.post("/login", dependencies=[RateLimited("login", key=by_username, methods="post")])
|
|
262
|
+
async def login() -> None: ...
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
reports = RateLimited("reports")(APIRouter(prefix="/reports")) # before its routes
|
|
266
|
+
app.include_router(admin, dependencies=[RateLimited("admin")]) # or when included
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- **Where it runs.** After routing and before the endpoint: routing is the framework's, so the
|
|
270
|
+
limit is a dependency rather than a lifecycle listener, and runs in the framework's order —
|
|
271
|
+
the application's, the routers', then the route's. As a decorator it must sit **below** the
|
|
272
|
+
route decorator, which reads the endpoint when the route is declared; a router must be
|
|
273
|
+
limited **before** routes are added, since each route copies its router's dependencies.
|
|
274
|
+
- **What a request is counted under.** `key` is a string, or a function of the request —
|
|
275
|
+
awaited when it returns an awaitable. By default: the client's address, the method and the
|
|
276
|
+
route's path template — `/books/{isbn}`, so asking for another book never earns a fresh
|
|
277
|
+
limit. Behind a proxy the address is the proxy's unless the server trusts its forwarded
|
|
278
|
+
headers (uvicorn's `--forwarded-allow-ips`).
|
|
279
|
+
- **`tokens`** a request consumes, and **`methods`** limited — every one when empty; `GET` also
|
|
280
|
+
limits `HEAD`.
|
|
281
|
+
- **A refusal** is answered `429 Too Many Requests` with `Retry-After`, raised as
|
|
282
|
+
`TooManyRequestsError` — the framework's own HTTP exception, so an exception handler
|
|
283
|
+
registered for it reshapes the body. A `RateLimitExceededEvent` naming the limiter and the key
|
|
284
|
+
is dispatched first. Limits consulted before a refusal keep their spend.
|
|
285
|
+
- **Headers.** With `expose_headers=True` the response carries `X-RateLimit-Limit`,
|
|
286
|
+
`X-RateLimit-Remaining` and `X-RateLimit-Reset`, in calls rather than tokens, for the limit
|
|
287
|
+
closest to refusing among those exposing theirs; a refusing limit always speaks, and one that
|
|
288
|
+
keeps its state to itself leaves the response without them. The response is made private, so
|
|
289
|
+
a shared cache never serves one caller's count to another.
|
|
290
|
+
- **The published schema** is untouched: the limit never appears as a parameter.
|
|
291
|
+
|
|
292
|
+
A limiter the application did not configure fails the request with `UnknownRateLimiterError`,
|
|
293
|
+
naming the ones it did. To limit by hand — throttling logins only on failure, say — inject the
|
|
294
|
+
limiter by name like any service:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from typing import Annotated
|
|
298
|
+
|
|
299
|
+
from xtr_dependency_injection import Target
|
|
300
|
+
from xtr_rate_limiter import RateLimiterFactoryInterface
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.post("/login")
|
|
304
|
+
async def login(
|
|
305
|
+
limiter: Annotated[RateLimiterFactoryInterface, Target("login")],
|
|
306
|
+
) -> None:
|
|
307
|
+
limit = limiter.create(username)
|
|
308
|
+
if not (await limit.consume(0)).is_accepted():
|
|
309
|
+
raise TooManyAttempts
|
|
310
|
+
...
|
|
311
|
+
```
|
|
312
|
+
|
|
202
313
|
## Use in an application
|
|
203
314
|
|
|
204
315
|
Everything adding this package to an application on
|
|
@@ -206,13 +317,16 @@ Everything adding this package to an application on
|
|
|
206
317
|
removing it undoes.
|
|
207
318
|
|
|
208
319
|
- **Install** — `uv add "xtr-http-kernel[logging,console]"`; `logging` brings the listeners
|
|
209
|
-
that write to a log, `console` the commands that report on the router
|
|
210
|
-
serve requests.
|
|
320
|
+
that write to a log, `console` the commands that report on the router, `rate-limiter` the
|
|
321
|
+
[rate limits](#rate-limits). None is needed to serve requests.
|
|
322
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
323
|
+
`HttpKernelBundle`. There is no config file, environment or ignore line to write; it prints the
|
|
324
|
+
`setup(app, kernel)` step, which a recipe cannot make for you.
|
|
211
325
|
- **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
212
326
|
from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
|
|
213
327
|
- **Brings along** — the [event dispatcher](../xtr-event-dispatcher) bundle always, because the
|
|
214
|
-
lifecycle dispatches through it; the [logging](../xtr-logging)
|
|
215
|
-
bundles whenever those packages are *installed* — they are required peers, pulled in and made
|
|
328
|
+
lifecycle dispatches through it; the [logging](../xtr-logging), [console](../xtr-console) and
|
|
329
|
+
[rate limiter](../xtr-rate-limiter) bundles whenever those packages are *installed* — they are required peers, pulled in and made
|
|
216
330
|
active without being listed, and left out silently when the package is not installed. Listing
|
|
217
331
|
is not what activates them; installing the extra is.
|
|
218
332
|
- **Configure** — nothing is required: the zero-config path gives a `uuid4` request id under
|
|
@@ -243,7 +357,7 @@ default:
|
|
|
243
357
|
|
|
244
358
|
The constructor refuses values the lifecycle would silently misread: a `request_id_header` that
|
|
245
359
|
is not an HTTP token, an empty `log_channel`, or an `app` that is not exactly one module and one
|
|
246
|
-
attribute around a single `:`, each raise `ValueError`.
|
|
360
|
+
attribute around a single `:`, each raise `InvalidArgumentError` — also a `ValueError`.
|
|
247
361
|
|
|
248
362
|
The bundle declares the default `log_channel` on the logging config for you. An application
|
|
249
363
|
renaming it must declare the new channel in its own logging configuration — this bundle's
|
|
@@ -352,6 +466,8 @@ async def test_it_uses_the_fake_catalogue() -> None:
|
|
|
352
466
|
```
|
|
353
467
|
|
|
354
468
|
A key is a type, or a `(type, qualifier)` pair for a qualified service.
|
|
469
|
+
The overrides live on the application itself, so two tests serving one application must not
|
|
470
|
+
run at the same time: the block that exits last would restore what the other replaced.
|
|
355
471
|
|
|
356
472
|
## Errors
|
|
357
473
|
|
|
@@ -360,7 +476,11 @@ typed attributes rather than only a message.
|
|
|
360
476
|
|
|
361
477
|
| Error | Raised when |
|
|
362
478
|
|---|---|
|
|
479
|
+
| `InvalidArgumentError` | an `HttpKernelConfig` field holds a value the lifecycle would misread; also a `ValueError` |
|
|
363
480
|
| `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
|
|
481
|
+
| `InvalidRateLimitError` | a `RateLimited` takes fewer than one token, limits a router that already has routes, or its key function returns no string; also a `ValueError` |
|
|
482
|
+
| `TooManyRequestsError` | a limiter refused the request — a 429 the framework answers, with `Retry-After` |
|
|
483
|
+
| `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
|
|
364
484
|
|
|
365
485
|
## Layout
|
|
366
486
|
|
|
@@ -374,6 +494,7 @@ xtr_http_kernel/
|
|
|
374
494
|
├── request_lifecycle_middleware.py the middleware that dispatches the events
|
|
375
495
|
├── middleware_stack.py the ordered chain a bundle contributes to
|
|
376
496
|
├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
|
|
497
|
+
├── rate_limiter/ RateLimited, with the rate-limiter extra
|
|
377
498
|
├── command/ debug:router and router:match
|
|
378
499
|
├── exception/ HttpKernelError, the root of everything this library raises
|
|
379
500
|
└── bundle/ HttpKernelBundle and HttpKernelConfig
|
|
@@ -1,34 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: xtr-http-kernel
|
|
3
|
-
Version: 1.5.0
|
|
4
|
-
Summary: Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel.
|
|
5
|
-
Keywords: http,kernel,request,lifecycle,events,middleware,fastapi
|
|
6
|
-
Author: Xterr
|
|
7
|
-
Author-email: Xterr <me@xterr.dev>
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
-
Classifier: Typing :: Typed
|
|
17
|
-
Requires-Dist: fastapi>=0.121.3
|
|
18
|
-
Requires-Dist: starlette>=0.40
|
|
19
|
-
Requires-Dist: xtr-dependency-injection[fastapi]>=1.0,<2
|
|
20
|
-
Requires-Dist: xtr-event-dispatcher>=1.0,<2
|
|
21
|
-
Requires-Dist: xtr-event-dispatcher-contracts>=1.0,<2
|
|
22
|
-
Requires-Dist: xtr-logging-contracts>=1.0,<2
|
|
23
|
-
Requires-Dist: xtr-service-contracts>=1.0,<2
|
|
24
|
-
Requires-Dist: typing-extensions>=4.4
|
|
25
|
-
Requires-Dist: xtr-console>=1.0,<2 ; extra == 'console'
|
|
26
|
-
Requires-Dist: xtr-logging>=1.0,<2 ; extra == 'logging'
|
|
27
|
-
Requires-Python: >=3.11
|
|
28
|
-
Provides-Extra: console
|
|
29
|
-
Provides-Extra: logging
|
|
30
|
-
Description-Content-Type: text/markdown
|
|
31
|
-
|
|
32
1
|
<div align="center">
|
|
33
2
|
|
|
34
3
|
# xtr-http-kernel
|
|
@@ -69,6 +38,7 @@ Listeners are services, so they come from the container with everything else the
|
|
|
69
38
|
uv add xtr-http-kernel # the lifecycle, its bundle and setup
|
|
70
39
|
uv add "xtr-http-kernel[logging]" # + the listeners that log
|
|
71
40
|
uv add "xtr-http-kernel[console]" # + the commands that report on the router
|
|
41
|
+
uv add "xtr-http-kernel[rate-limiter]" # + rate limits on routes, routers and the app
|
|
72
42
|
```
|
|
73
43
|
|
|
74
44
|
Requires Python 3.11+. The web framework and the container layer come with the package
|
|
@@ -155,7 +125,7 @@ contribute. Each is dispatched at most once, however the request went:
|
|
|
155
125
|
|---|---|---|---|
|
|
156
126
|
| `RequestEvent` | `REQUEST` | it arrived, nothing has looked at it | read it, or `set_response(...)` to answer instead of the application |
|
|
157
127
|
| `ResponseEvent` | `RESPONSE` | a response is about to start | assign `status_code`, change `headers` in place |
|
|
158
|
-
| `ExceptionEvent` | `EXCEPTION` | handling raised, nothing was sent | read `exception`, or `set_response(...)` to answer with it |
|
|
128
|
+
| `ExceptionEvent` | `EXCEPTION` | handling raised, nothing was sent | read `exception`, or `set_response(...)` to answer with it — an `Exception` only: a cancellation, interrupt or exit is announced and goes on |
|
|
159
129
|
| `FinishRequestEvent` | `FINISH_REQUEST` | handling finished — **on every path** | put away what the request set up |
|
|
160
130
|
| `TerminateEvent` | `TERMINATE` | everything was sent | work worth doing once the caller has their answer |
|
|
161
131
|
|
|
@@ -225,11 +195,87 @@ The bundle registers these; which ones depend on what is installed and active:
|
|
|
225
195
|
| `DisallowRobotsIndexingListener` | `ResponseEvent` | always | stamps `X-Robots-Tag: noindex` on every response, when the config turns it on |
|
|
226
196
|
| `LogUnitListener` | `RequestEvent`, `TerminateEvent` | logging bundle active | opens a [logging unit of work](../xtr-logging#units-of-work) per request and closes it once all was sent |
|
|
227
197
|
| `ErrorLoggingListener` | `ExceptionEvent` | logging bundle active | writes every uncaught exception to the request channel — `error` below a 500 status, `critical` otherwise — leaving the response to whoever answers it |
|
|
198
|
+
| `RateLimitHeadersListener` | `ResponseEvent` | rate limiter bundle active | writes the `X-RateLimit-*` headers of the [rate limit](#rate-limits) that speaks for the response, and makes it private |
|
|
228
199
|
|
|
229
200
|
The two logging listeners join only when the logging bundle is active, and open and close the
|
|
230
201
|
unit of work outside everything else so every record made while handling carries the request's
|
|
231
202
|
id. The request id settles right after the unit opens, for the same reason.
|
|
232
203
|
|
|
204
|
+
## Rate limits
|
|
205
|
+
|
|
206
|
+
With the `rate-limiter` extra, a route, a router or the whole application can be held to a
|
|
207
|
+
limiter configured in [xtr-rate-limiter](../xtr-rate-limiter)'s bundle. `RateLimited` is one
|
|
208
|
+
declaration, written as a decorator or wherever the framework takes a dependency:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
212
|
+
|
|
213
|
+
from xtr_http_kernel.rate_limiter import RateLimited
|
|
214
|
+
|
|
215
|
+
app = FastAPI(dependencies=[RateLimited("global")]) # every route
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.get("/books")
|
|
219
|
+
@RateLimited("api", expose_headers=True) # below the route decorator
|
|
220
|
+
async def list_books() -> list[Book]: ...
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def by_username(request: Request) -> str:
|
|
224
|
+
return request.headers.get("x-username", "anonymous")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.post("/login", dependencies=[RateLimited("login", key=by_username, methods="post")])
|
|
228
|
+
async def login() -> None: ...
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
reports = RateLimited("reports")(APIRouter(prefix="/reports")) # before its routes
|
|
232
|
+
app.include_router(admin, dependencies=[RateLimited("admin")]) # or when included
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
- **Where it runs.** After routing and before the endpoint: routing is the framework's, so the
|
|
236
|
+
limit is a dependency rather than a lifecycle listener, and runs in the framework's order —
|
|
237
|
+
the application's, the routers', then the route's. As a decorator it must sit **below** the
|
|
238
|
+
route decorator, which reads the endpoint when the route is declared; a router must be
|
|
239
|
+
limited **before** routes are added, since each route copies its router's dependencies.
|
|
240
|
+
- **What a request is counted under.** `key` is a string, or a function of the request —
|
|
241
|
+
awaited when it returns an awaitable. By default: the client's address, the method and the
|
|
242
|
+
route's path template — `/books/{isbn}`, so asking for another book never earns a fresh
|
|
243
|
+
limit. Behind a proxy the address is the proxy's unless the server trusts its forwarded
|
|
244
|
+
headers (uvicorn's `--forwarded-allow-ips`).
|
|
245
|
+
- **`tokens`** a request consumes, and **`methods`** limited — every one when empty; `GET` also
|
|
246
|
+
limits `HEAD`.
|
|
247
|
+
- **A refusal** is answered `429 Too Many Requests` with `Retry-After`, raised as
|
|
248
|
+
`TooManyRequestsError` — the framework's own HTTP exception, so an exception handler
|
|
249
|
+
registered for it reshapes the body. A `RateLimitExceededEvent` naming the limiter and the key
|
|
250
|
+
is dispatched first. Limits consulted before a refusal keep their spend.
|
|
251
|
+
- **Headers.** With `expose_headers=True` the response carries `X-RateLimit-Limit`,
|
|
252
|
+
`X-RateLimit-Remaining` and `X-RateLimit-Reset`, in calls rather than tokens, for the limit
|
|
253
|
+
closest to refusing among those exposing theirs; a refusing limit always speaks, and one that
|
|
254
|
+
keeps its state to itself leaves the response without them. The response is made private, so
|
|
255
|
+
a shared cache never serves one caller's count to another.
|
|
256
|
+
- **The published schema** is untouched: the limit never appears as a parameter.
|
|
257
|
+
|
|
258
|
+
A limiter the application did not configure fails the request with `UnknownRateLimiterError`,
|
|
259
|
+
naming the ones it did. To limit by hand — throttling logins only on failure, say — inject the
|
|
260
|
+
limiter by name like any service:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from typing import Annotated
|
|
264
|
+
|
|
265
|
+
from xtr_dependency_injection import Target
|
|
266
|
+
from xtr_rate_limiter import RateLimiterFactoryInterface
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.post("/login")
|
|
270
|
+
async def login(
|
|
271
|
+
limiter: Annotated[RateLimiterFactoryInterface, Target("login")],
|
|
272
|
+
) -> None:
|
|
273
|
+
limit = limiter.create(username)
|
|
274
|
+
if not (await limit.consume(0)).is_accepted():
|
|
275
|
+
raise TooManyAttempts
|
|
276
|
+
...
|
|
277
|
+
```
|
|
278
|
+
|
|
233
279
|
## Use in an application
|
|
234
280
|
|
|
235
281
|
Everything adding this package to an application on
|
|
@@ -237,13 +283,16 @@ Everything adding this package to an application on
|
|
|
237
283
|
removing it undoes.
|
|
238
284
|
|
|
239
285
|
- **Install** — `uv add "xtr-http-kernel[logging,console]"`; `logging` brings the listeners
|
|
240
|
-
that write to a log, `console` the commands that report on the router
|
|
241
|
-
serve requests.
|
|
286
|
+
that write to a log, `console` the commands that report on the router, `rate-limiter` the
|
|
287
|
+
[rate limits](#rate-limits). None is needed to serve requests.
|
|
288
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
289
|
+
`HttpKernelBundle`. There is no config file, environment or ignore line to write; it prints the
|
|
290
|
+
`setup(app, kernel)` step, which a recipe cannot make for you.
|
|
242
291
|
- **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
243
292
|
from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
|
|
244
293
|
- **Brings along** — the [event dispatcher](../xtr-event-dispatcher) bundle always, because the
|
|
245
|
-
lifecycle dispatches through it; the [logging](../xtr-logging)
|
|
246
|
-
bundles whenever those packages are *installed* — they are required peers, pulled in and made
|
|
294
|
+
lifecycle dispatches through it; the [logging](../xtr-logging), [console](../xtr-console) and
|
|
295
|
+
[rate limiter](../xtr-rate-limiter) bundles whenever those packages are *installed* — they are required peers, pulled in and made
|
|
247
296
|
active without being listed, and left out silently when the package is not installed. Listing
|
|
248
297
|
is not what activates them; installing the extra is.
|
|
249
298
|
- **Configure** — nothing is required: the zero-config path gives a `uuid4` request id under
|
|
@@ -274,7 +323,7 @@ default:
|
|
|
274
323
|
|
|
275
324
|
The constructor refuses values the lifecycle would silently misread: a `request_id_header` that
|
|
276
325
|
is not an HTTP token, an empty `log_channel`, or an `app` that is not exactly one module and one
|
|
277
|
-
attribute around a single `:`, each raise `ValueError`.
|
|
326
|
+
attribute around a single `:`, each raise `InvalidArgumentError` — also a `ValueError`.
|
|
278
327
|
|
|
279
328
|
The bundle declares the default `log_channel` on the logging config for you. An application
|
|
280
329
|
renaming it must declare the new channel in its own logging configuration — this bundle's
|
|
@@ -383,6 +432,8 @@ async def test_it_uses_the_fake_catalogue() -> None:
|
|
|
383
432
|
```
|
|
384
433
|
|
|
385
434
|
A key is a type, or a `(type, qualifier)` pair for a qualified service.
|
|
435
|
+
The overrides live on the application itself, so two tests serving one application must not
|
|
436
|
+
run at the same time: the block that exits last would restore what the other replaced.
|
|
386
437
|
|
|
387
438
|
## Errors
|
|
388
439
|
|
|
@@ -391,7 +442,11 @@ typed attributes rather than only a message.
|
|
|
391
442
|
|
|
392
443
|
| Error | Raised when |
|
|
393
444
|
|---|---|
|
|
445
|
+
| `InvalidArgumentError` | an `HttpKernelConfig` field holds a value the lifecycle would misread; also a `ValueError` |
|
|
394
446
|
| `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
|
|
447
|
+
| `InvalidRateLimitError` | a `RateLimited` takes fewer than one token, limits a router that already has routes, or its key function returns no string; also a `ValueError` |
|
|
448
|
+
| `TooManyRequestsError` | a limiter refused the request — a 429 the framework answers, with `Retry-After` |
|
|
449
|
+
| `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
|
|
395
450
|
|
|
396
451
|
## Layout
|
|
397
452
|
|
|
@@ -405,6 +460,7 @@ xtr_http_kernel/
|
|
|
405
460
|
├── request_lifecycle_middleware.py the middleware that dispatches the events
|
|
406
461
|
├── middleware_stack.py the ordered chain a bundle contributes to
|
|
407
462
|
├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
|
|
463
|
+
├── rate_limiter/ RateLimited, with the rate-limiter extra
|
|
408
464
|
├── command/ debug:router and router:match
|
|
409
465
|
├── exception/ HttpKernelError, the root of everything this library raises
|
|
410
466
|
└── bundle/ HttpKernelBundle and HttpKernelConfig
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-http-kernel"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -27,11 +27,11 @@ classifiers = [
|
|
|
27
27
|
dependencies = [
|
|
28
28
|
"fastapi>=0.121.3",
|
|
29
29
|
"starlette>=0.40",
|
|
30
|
-
"xtr-dependency-injection[fastapi]>=
|
|
31
|
-
"xtr-event-dispatcher>=
|
|
32
|
-
"xtr-event-dispatcher-contracts>=
|
|
33
|
-
"xtr-logging-contracts>=
|
|
34
|
-
"xtr-service-contracts>=
|
|
30
|
+
"xtr-dependency-injection[fastapi]>=3.0,<4",
|
|
31
|
+
"xtr-event-dispatcher>=3.0,<4",
|
|
32
|
+
"xtr-event-dispatcher-contracts>=3.0,<4",
|
|
33
|
+
"xtr-logging-contracts>=3.0,<4",
|
|
34
|
+
"xtr-service-contracts>=3.0,<4",
|
|
35
35
|
"typing-extensions>=4.4",
|
|
36
36
|
]
|
|
37
37
|
|
|
@@ -40,12 +40,19 @@ name = "Xterr"
|
|
|
40
40
|
email = "me@xterr.dev"
|
|
41
41
|
|
|
42
42
|
[project.optional-dependencies]
|
|
43
|
-
logging = ["xtr-logging>=
|
|
44
|
-
console = ["xtr-console>=
|
|
43
|
+
logging = ["xtr-logging>=3.0,<4"]
|
|
44
|
+
console = ["xtr-console>=3.0,<4"]
|
|
45
|
+
rate-limiter = [
|
|
46
|
+
"xtr-clock>=3.0,<4",
|
|
47
|
+
"xtr-rate-limiter[di]>=3.0,<4",
|
|
48
|
+
]
|
|
45
49
|
|
|
46
50
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
47
51
|
http_kernel = "xtr_http_kernel.bundle:HttpKernelBundle"
|
|
48
52
|
|
|
53
|
+
[project.entry-points.xtr_recipes]
|
|
54
|
+
http_kernel = "xtr_http_kernel.recipe"
|
|
55
|
+
|
|
49
56
|
[dependency-groups]
|
|
50
57
|
dev = [
|
|
51
58
|
"basedpyright>=1.21",
|
|
@@ -54,8 +61,10 @@ dev = [
|
|
|
54
61
|
"pytest-cov>=5",
|
|
55
62
|
"anyio>=4.0",
|
|
56
63
|
"ty>=0.0.83",
|
|
57
|
-
"xtr-logging>=
|
|
58
|
-
"xtr-console>=
|
|
64
|
+
"xtr-logging>=3.0,<4",
|
|
65
|
+
"xtr-console>=3.0,<4",
|
|
66
|
+
"xtr-clock>=3.0,<4",
|
|
67
|
+
"xtr-rate-limiter[di]>=3.0,<4",
|
|
59
68
|
"httpx>=0.27",
|
|
60
69
|
]
|
|
61
70
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-http-kernel"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -26,20 +26,24 @@ dependencies = [
|
|
|
26
26
|
"fastapi>=0.121.3",
|
|
27
27
|
# Imported directly — requests, responses, middleware — not only through fastapi.
|
|
28
28
|
"starlette>=0.40",
|
|
29
|
-
"xtr-dependency-injection[fastapi]>=
|
|
30
|
-
"xtr-event-dispatcher>=
|
|
31
|
-
"xtr-event-dispatcher-contracts>=
|
|
32
|
-
"xtr-logging-contracts>=
|
|
33
|
-
"xtr-service-contracts>=
|
|
29
|
+
"xtr-dependency-injection[fastapi]>=3.0,<4",
|
|
30
|
+
"xtr-event-dispatcher>=3.0,<4",
|
|
31
|
+
"xtr-event-dispatcher-contracts>=3.0,<4",
|
|
32
|
+
"xtr-logging-contracts>=3.0,<4",
|
|
33
|
+
"xtr-service-contracts>=3.0,<4",
|
|
34
34
|
"typing-extensions>=4.4",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.optional-dependencies]
|
|
38
38
|
logging = [
|
|
39
|
-
"xtr-logging>=
|
|
39
|
+
"xtr-logging>=3.0,<4",
|
|
40
40
|
]
|
|
41
41
|
console = [
|
|
42
|
-
"xtr-console>=
|
|
42
|
+
"xtr-console>=3.0,<4",
|
|
43
|
+
]
|
|
44
|
+
rate-limiter = [
|
|
45
|
+
"xtr-clock>=3.0,<4",
|
|
46
|
+
"xtr-rate-limiter[di]>=3.0,<4",
|
|
43
47
|
]
|
|
44
48
|
|
|
45
49
|
# Advertises the bundle so debug:bundles can name it when installed but not listed.
|
|
@@ -47,6 +51,10 @@ console = [
|
|
|
47
51
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
48
52
|
http_kernel = "xtr_http_kernel.bundle:HttpKernelBundle"
|
|
49
53
|
|
|
54
|
+
# Advertises the recipe — what *Use in an application* takes — for recipes:sync.
|
|
55
|
+
[project.entry-points."xtr_recipes"]
|
|
56
|
+
http_kernel = "xtr_http_kernel.recipe"
|
|
57
|
+
|
|
50
58
|
[dependency-groups]
|
|
51
59
|
dev = [
|
|
52
60
|
"basedpyright>=1.21",
|
|
@@ -55,8 +63,10 @@ dev = [
|
|
|
55
63
|
"pytest-cov>=5",
|
|
56
64
|
"anyio>=4.0",
|
|
57
65
|
"ty>=0.0.83",
|
|
58
|
-
"xtr-logging>=
|
|
59
|
-
"xtr-console>=
|
|
66
|
+
"xtr-logging>=3.0,<4",
|
|
67
|
+
"xtr-console>=3.0,<4",
|
|
68
|
+
"xtr-clock>=3.0,<4",
|
|
69
|
+
"xtr-rate-limiter[di]>=3.0,<4",
|
|
60
70
|
# Requests are driven straight at the application through an ASGI
|
|
61
71
|
# transport, so the suite never binds a socket.
|
|
62
72
|
"httpx>=0.27",
|