xtr-http-kernel 2.0.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-2.0.0 → xtr_http_kernel-3.0.0}/PKG-INFO +13 -10
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/README.md +3 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/pyproject.toml +17 -14
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/pyproject.toml.orig +18 -14
- 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-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +8 -11
- 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-2.0.0 → xtr_http_kernel-3.0.0}/LICENSE +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_state.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/_route_contexts.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/debug_router_command.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/route_description.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_command.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_match_command.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/exception_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/request_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/response_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/terminate_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/request_id_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_argument_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/too_many_requests_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/kernel_events.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_stack.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_tag.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/py.typed +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/rate_limited.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/setup.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtr-http-kernel
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel.
|
|
5
5
|
Keywords: http,kernel,request,lifecycle,events,middleware,fastapi
|
|
6
6
|
Author: Xterr
|
|
@@ -16,16 +16,16 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Dist: fastapi>=0.121.3
|
|
18
18
|
Requires-Dist: starlette>=0.40
|
|
19
|
-
Requires-Dist: xtr-dependency-injection[fastapi]>=
|
|
20
|
-
Requires-Dist: xtr-event-dispatcher>=
|
|
21
|
-
Requires-Dist: xtr-event-dispatcher-contracts>=
|
|
22
|
-
Requires-Dist: xtr-logging-contracts>=
|
|
23
|
-
Requires-Dist: xtr-service-contracts>=
|
|
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
24
|
Requires-Dist: typing-extensions>=4.4
|
|
25
|
-
Requires-Dist: xtr-console>=
|
|
26
|
-
Requires-Dist: xtr-logging>=
|
|
27
|
-
Requires-Dist: xtr-clock>=
|
|
28
|
-
Requires-Dist: xtr-rate-limiter[di]>=
|
|
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
29
|
Requires-Python: >=3.11
|
|
30
30
|
Provides-Extra: console
|
|
31
31
|
Provides-Extra: logging
|
|
@@ -319,6 +319,9 @@ removing it undoes.
|
|
|
319
319
|
- **Install** — `uv add "xtr-http-kernel[logging,console]"`; `logging` brings the listeners
|
|
320
320
|
that write to a log, `console` the commands that report on the router, `rate-limiter` the
|
|
321
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.
|
|
322
325
|
- **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
323
326
|
from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
|
|
324
327
|
- **Brings along** — the [event dispatcher](../xtr-event-dispatcher) bundle always, because the
|
|
@@ -285,6 +285,9 @@ removing it undoes.
|
|
|
285
285
|
- **Install** — `uv add "xtr-http-kernel[logging,console]"`; `logging` brings the listeners
|
|
286
286
|
that write to a log, `console` the commands that report on the router, `rate-limiter` the
|
|
287
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.
|
|
288
291
|
- **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
289
292
|
from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
|
|
290
293
|
- **Brings along** — the [event dispatcher](../xtr-event-dispatcher) bundle always, because the
|
|
@@ -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,16 +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
45
|
rate-limiter = [
|
|
46
|
-
"xtr-clock>=
|
|
47
|
-
"xtr-rate-limiter[di]>=
|
|
46
|
+
"xtr-clock>=3.0,<4",
|
|
47
|
+
"xtr-rate-limiter[di]>=3.0,<4",
|
|
48
48
|
]
|
|
49
49
|
|
|
50
50
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
51
51
|
http_kernel = "xtr_http_kernel.bundle:HttpKernelBundle"
|
|
52
52
|
|
|
53
|
+
[project.entry-points.xtr_recipes]
|
|
54
|
+
http_kernel = "xtr_http_kernel.recipe"
|
|
55
|
+
|
|
53
56
|
[dependency-groups]
|
|
54
57
|
dev = [
|
|
55
58
|
"basedpyright>=1.21",
|
|
@@ -58,10 +61,10 @@ dev = [
|
|
|
58
61
|
"pytest-cov>=5",
|
|
59
62
|
"anyio>=4.0",
|
|
60
63
|
"ty>=0.0.83",
|
|
61
|
-
"xtr-logging>=
|
|
62
|
-
"xtr-console>=
|
|
63
|
-
"xtr-clock>=
|
|
64
|
-
"xtr-rate-limiter[di]>=
|
|
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",
|
|
65
68
|
"httpx>=0.27",
|
|
66
69
|
]
|
|
67
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,24 +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
43
|
]
|
|
44
44
|
rate-limiter = [
|
|
45
|
-
"xtr-clock>=
|
|
46
|
-
"xtr-rate-limiter[di]>=
|
|
45
|
+
"xtr-clock>=3.0,<4",
|
|
46
|
+
"xtr-rate-limiter[di]>=3.0,<4",
|
|
47
47
|
]
|
|
48
48
|
|
|
49
49
|
# Advertises the bundle so debug:bundles can name it when installed but not listed.
|
|
@@ -51,6 +51,10 @@ rate-limiter = [
|
|
|
51
51
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
52
52
|
http_kernel = "xtr_http_kernel.bundle:HttpKernelBundle"
|
|
53
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
|
+
|
|
54
58
|
[dependency-groups]
|
|
55
59
|
dev = [
|
|
56
60
|
"basedpyright>=1.21",
|
|
@@ -59,10 +63,10 @@ dev = [
|
|
|
59
63
|
"pytest-cov>=5",
|
|
60
64
|
"anyio>=4.0",
|
|
61
65
|
"ty>=0.0.83",
|
|
62
|
-
"xtr-logging>=
|
|
63
|
-
"xtr-console>=
|
|
64
|
-
"xtr-clock>=
|
|
65
|
-
"xtr-rate-limiter[di]>=
|
|
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",
|
|
66
70
|
# Requests are driven straight at the application through an ASGI
|
|
67
71
|
# transport, so the suite never binds a socket.
|
|
68
72
|
"httpx>=0.27",
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-http-kernel
|
|
3
|
+
description: The HTTP entry point for a FastAPI application built on xtr-dependency-injection — setup(app, kernel), a request lifecycle of five events, container services injected into routes, and rate limits on routes. Use when wiring a FastAPI app to a kernel, writing a route that needs a container service, adding a request id or a response header for every request, logging uncaught exceptions with the request that caused it, short-circuiting a request before routing, turning an exception into a response, opening a per-request unit of work, contributing middleware from a bundle, listing routes with debug:router, rate limiting a route or router, or testing a served application with an httpx ASGI client. For plain FastAPI matters (routing, Depends, request bodies, response models, OpenAPI) use the fastapi skill instead.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-http-kernel
|
|
7
|
+
|
|
8
|
+
FastAPI still routes, resolves arguments and serializes. This package adds the part around the
|
|
9
|
+
endpoint: one `setup(app, kernel)` call that gives every application life its own kernel, five
|
|
10
|
+
lifecycle events listeners can join, and container services injected straight into route
|
|
11
|
+
signatures. Everything the framework already does well is left alone.
|
|
12
|
+
|
|
13
|
+
## Quick reference
|
|
14
|
+
|
|
15
|
+
- One call: `setup(app, kernel)` from `xtr_http_kernel`, after the routes and before the first
|
|
16
|
+
request.
|
|
17
|
+
- Pull a container service into a route with `Injected[T]`, `Annotated[T, Target("name")]` or
|
|
18
|
+
`Annotated[T, Autowire(param=...)]`. Keep `Depends` for everything that is the framework's.
|
|
19
|
+
- Join the lifecycle with `@as_event_listener()` from `xtr_event_dispatcher` on a function
|
|
20
|
+
taking one of `RequestEvent`, `ResponseEvent`, `ExceptionEvent`, `FinishRequestEvent`,
|
|
21
|
+
`TerminateEvent`.
|
|
22
|
+
- Every response carries `X-Request-Id`; `request.state.request_id` holds the same value.
|
|
23
|
+
- Test with `httpx.ASGITransport` inside `app.router.lifespan_context(app)`, never
|
|
24
|
+
`TestClient`. Swap services with `override_services` from `xtr_http_kernel.testing`.
|
|
25
|
+
- Activate `HttpKernelBundle` from `xtr_http_kernel.bundle`; configure with `HttpKernelConfig`.
|
|
26
|
+
|
|
27
|
+
## Set up the application
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# app/web.py
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from fastapi import FastAPI
|
|
34
|
+
from xtr_dependency_injection import Kernel
|
|
35
|
+
from xtr_http_kernel import setup
|
|
36
|
+
from xtr_http_kernel.bundle import HttpKernelBundle
|
|
37
|
+
|
|
38
|
+
app = FastAPI()
|
|
39
|
+
# ... routes and the application's own middleware here ...
|
|
40
|
+
|
|
41
|
+
kernel = Kernel("app", bundles={HttpKernelBundle: {"all": True}})
|
|
42
|
+
setup(app, kernel)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`setup` builds nothing. It wraps two seams:
|
|
46
|
+
|
|
47
|
+
- **The lifespan.** Every application life builds the kernel afresh, applies test overrides,
|
|
48
|
+
boots it, attaches the container so the route markers resolve, runs the app's own lifespan
|
|
49
|
+
inside, then detaches and shuts down.
|
|
50
|
+
- **One middleware.** Fetches the kernel's `MiddlewareStack` once per life and runs every
|
|
51
|
+
request through it, inside the request scope scoped services live in.
|
|
52
|
+
|
|
53
|
+
Serve it with any ASGI server: `uvicorn app.web:app`.
|
|
54
|
+
|
|
55
|
+
## Inject services into a route
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from typing import Annotated
|
|
59
|
+
|
|
60
|
+
from xtr_dependency_injection import Autowire, Injected, Target
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.get("/books/{isbn}")
|
|
64
|
+
async def read_book(
|
|
65
|
+
isbn: str, # a path parameter, the framework's
|
|
66
|
+
catalogue: Injected[Catalogue], # from the container
|
|
67
|
+
mailer: Annotated[Mailer, Target("smtp")], # a qualified service
|
|
68
|
+
env: Annotated[str, Autowire(param="kernel.environment")], # a kernel parameter
|
|
69
|
+
) -> Book:
|
|
70
|
+
return await catalogue.get(isbn)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The markers never reach the OpenAPI schema, so the documented parameters stay the HTTP ones. A
|
|
74
|
+
`lifetime="scoped"` service is built once per request and released after the response has been
|
|
75
|
+
sent, which is when a per-request unit of work commits or rolls back; put cleanup that must
|
|
76
|
+
survive an error in a `finally`.
|
|
77
|
+
|
|
78
|
+
## Join the lifecycle
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from fastapi import Response
|
|
82
|
+
from xtr_event_dispatcher import as_event_listener
|
|
83
|
+
|
|
84
|
+
from xtr_http_kernel import ExceptionEvent, RequestEvent, ResponseEvent, TerminateEvent
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@as_event_listener(priority=100)
|
|
88
|
+
def keep_it_out_of_the_index(event: ResponseEvent) -> None:
|
|
89
|
+
event.headers["x-robots-tag"] = "noindex" # headers are the outgoing ones, mutated in place
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@as_event_listener()
|
|
93
|
+
def serve_the_maintenance_page(event: RequestEvent) -> None:
|
|
94
|
+
if under_maintenance():
|
|
95
|
+
event.set_response(Response("back soon", status_code=503)) # the app never runs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@as_event_listener()
|
|
99
|
+
def answer_the_failure(event: ExceptionEvent) -> None:
|
|
100
|
+
if isinstance(event.exception, OutOfStock):
|
|
101
|
+
event.set_response(Response("sorry", status_code=409))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@as_event_listener()
|
|
105
|
+
def count_the_status(event: TerminateEvent) -> None:
|
|
106
|
+
metrics.increment(event.status_code) # the caller already has their answer
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Every event carries `request`. The kernel scans the application package, so a listener in a
|
|
110
|
+
scanned module needs no further registration.
|
|
111
|
+
|
|
112
|
+
| Event | When | A listener may |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `RequestEvent` | it arrived, nothing has looked at it | read `request`, or `set_response(...)` to answer instead of the application |
|
|
115
|
+
| `ResponseEvent` | a response is about to start | assign `status_code`, mutate `headers`; there is no body here |
|
|
116
|
+
| `ExceptionEvent` | handling raised, nothing was sent | read `exception` (a `BaseException`), or `set_response(...)` to answer with it |
|
|
117
|
+
| `FinishRequestEvent` | handling finished, on every path | put away what the request set up |
|
|
118
|
+
| `TerminateEvent` | everything was sent | read `status_code`, which is `500` when nothing left |
|
|
119
|
+
|
|
120
|
+
- `set_response` stops the event: the listeners after it do not run.
|
|
121
|
+
- `ExceptionEvent` only fires while nothing has been sent, and only an `Exception` is turned
|
|
122
|
+
into a response; a cancellation or an interrupt is announced and then carries on out.
|
|
123
|
+
- `ResponseEvent` owns exactly the head, because the body is streamed. A listener wanting a
|
|
124
|
+
body of its own answers at the request or the exception.
|
|
125
|
+
- `KernelEvents.REQUEST`, `.RESPONSE`, `.EXCEPTION`, `.FINISH_REQUEST` and `.TERMINATE` name the
|
|
126
|
+
same events, for a listener registered by hand:
|
|
127
|
+
`dispatcher.add_listener(KernelEvents.RESPONSE, stamp, priority=100)`.
|
|
128
|
+
|
|
129
|
+
## What ships already
|
|
130
|
+
|
|
131
|
+
The bundle registers `RequestIdListener` and `DisallowRobotsIndexingListener` always,
|
|
132
|
+
`LogUnitListener` and `ErrorLoggingListener` when the logging bundle is active, and
|
|
133
|
+
`RateLimitHeadersListener` when the rate limiter bundle is. So a request id, a per-request
|
|
134
|
+
logging unit, an uncaught exception written to the log, a noindex header and the
|
|
135
|
+
`X-RateLimit-*` headers are all already done: configure them rather than writing your own. See
|
|
136
|
+
[references/listeners.md](references/listeners.md) for what each one does.
|
|
137
|
+
|
|
138
|
+
## Rate limits
|
|
139
|
+
|
|
140
|
+
With the `rate-limiter` extra, `RateLimited` holds a route, a router or the whole application to
|
|
141
|
+
a limiter configured in xtr-rate-limiter's bundle:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from xtr_http_kernel.rate_limiter import RateLimited
|
|
145
|
+
|
|
146
|
+
app = FastAPI(dependencies=[RateLimited("global")]) # every route
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.get("/books")
|
|
150
|
+
@RateLimited("api", expose_headers=True) # below the route decorator
|
|
151
|
+
async def list_books() -> list[Book]: ...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A refusal is `429 Too Many Requests` with `Retry-After`, raised as `TooManyRequestsError`.
|
|
155
|
+
See [references/rate-limits.md](references/rate-limits.md) for keys, tokens, methods, headers,
|
|
156
|
+
routers and limiting by hand.
|
|
157
|
+
|
|
158
|
+
## Contribute middleware from a bundle
|
|
159
|
+
|
|
160
|
+
A bundle tags a service that builds a middleware (a callable taking the downstream ASGI app and
|
|
161
|
+
returning it wrapped) with `MIDDLEWARE_TAG` from `xtr_http_kernel` and an integer `priority`:
|
|
162
|
+
`services.set(compression_middleware).add_tag(MIDDLEWARE_TAG, priority=10)`. Highest sits
|
|
163
|
+
outermost, a missing one counts as `0`, ties keep registration order, and a non-integer fails
|
|
164
|
+
the build with `InvalidMiddlewarePriorityError`.
|
|
165
|
+
|
|
166
|
+
## Testing
|
|
167
|
+
|
|
168
|
+
`TestClient` is unusable here: it leans on a deprecated framework path. Drive the application
|
|
169
|
+
through an ASGI transport, inside its own lifespan so the kernel builds and boots:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import httpx
|
|
173
|
+
import pytest
|
|
174
|
+
|
|
175
|
+
from app.web import app
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.anyio
|
|
179
|
+
async def test_a_book_carries_a_request_id() -> None:
|
|
180
|
+
async with app.router.lifespan_context(app):
|
|
181
|
+
transport = httpx.ASGITransport(app, raise_app_exceptions=False)
|
|
182
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
183
|
+
response = await client.get("/books/0262510871")
|
|
184
|
+
|
|
185
|
+
assert response.status_code == 200
|
|
186
|
+
assert response.headers["x-request-id"]
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Swap a service by parking it on the application **before** entering the lifespan:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from xtr_http_kernel.testing import override_services
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.mark.anyio
|
|
196
|
+
async def test_it_uses_the_fake_catalogue() -> None:
|
|
197
|
+
with override_services(app, {Catalogue: FakeCatalogue()}):
|
|
198
|
+
async with app.router.lifespan_context(app):
|
|
199
|
+
... # every kernel built here resolves the fake
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
A key is a type, or a `(type, qualifier)` pair. The overrides live on the application, so two
|
|
203
|
+
tests serving one application must not run at the same time.
|
|
204
|
+
|
|
205
|
+
## Use in an application
|
|
206
|
+
|
|
207
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
208
|
+
`HttpKernelBundle`. That is the steps below a recipe can do; the `setup(app, kernel)` step it prints
|
|
209
|
+
for you to make.
|
|
210
|
+
|
|
211
|
+
1. **Install** `uv add "xtr-http-kernel[logging,console]"`. The extras: `logging` adds the
|
|
212
|
+
listeners that write to a log, `console` the router commands, `rate-limiter` the rate limits.
|
|
213
|
+
None is needed to serve requests.
|
|
214
|
+
2. **Activate** `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
215
|
+
from `xtr_http_kernel.bundle`, then call `setup(app, kernel)` where the application is built.
|
|
216
|
+
3. **Brings along** the event dispatcher bundle always; the logging, console and rate limiter
|
|
217
|
+
bundles whenever those packages are installed. Installing the extra is what activates them,
|
|
218
|
+
not listing.
|
|
219
|
+
4. **Configure** nothing by default: a `uuid4` id under `X-Request-Id`, no robots header, the
|
|
220
|
+
`request` log channel.
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
# <app>/config/http_kernel.py
|
|
224
|
+
from xtr_dependency_injection import configure
|
|
225
|
+
|
|
226
|
+
from xtr_http_kernel.bundle import HttpKernelConfig
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@configure
|
|
230
|
+
def http_kernel() -> HttpKernelConfig:
|
|
231
|
+
return HttpKernelConfig(disallow_search_indexing=True, app="app.web:app")
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Six fields, all optional: `request_id_header`, `trust_request_id`,
|
|
235
|
+
`disallow_search_indexing`, `log_channel`, `middleware_priority`, `app`. See
|
|
236
|
+
[references/configure.md](references/configure.md) for each one's default and meaning.
|
|
237
|
+
5. **Environment** nothing.
|
|
238
|
+
6. **Run** any ASGI server against the module `setup` was called in: `uvicorn app.web:app`.
|
|
239
|
+
7. **Check** `debug:bundles` shows `http_kernel` as `listed` and `active`, `event_dispatcher` as
|
|
240
|
+
`required`. With a console bundle, `debug:router` lists the routes in the order routing tries
|
|
241
|
+
them, and `router:match PATH [--method GET]` names the route a path reaches.
|
|
242
|
+
8. **Remove** drop the `setup(app, kernel)` call, drop the `BUNDLES` entry, delete
|
|
243
|
+
`<app>/config/http_kernel.py`, then `uv remove xtr-http-kernel`.
|
|
244
|
+
|
|
245
|
+
## Errors
|
|
246
|
+
|
|
247
|
+
All derive from `HttpKernelError`, in `xtr_http_kernel.exception`:
|
|
248
|
+
|
|
249
|
+
| Error | Raised when |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| `InvalidArgumentError` | an `HttpKernelConfig` field holds a value the lifecycle would misread; also a `ValueError` |
|
|
252
|
+
| `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
|
|
253
|
+
| `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` |
|
|
254
|
+
| `TooManyRequestsError` | a limiter refused the request; a 429 the framework answers, with `Retry-After` |
|
|
255
|
+
| `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
|
|
256
|
+
|
|
257
|
+
A route marker resolving with no kernel attached, or outside a request scope, raises
|
|
258
|
+
`FastapiIntegrationError` from xtr-dependency-injection.
|
|
259
|
+
|
|
260
|
+
## Do not
|
|
261
|
+
|
|
262
|
+
- Do not use `Depends` to reach a container service. Use `Injected[T]` or `Target("name")`, so
|
|
263
|
+
the service stays out of the OpenAPI schema.
|
|
264
|
+
- Do not call `attach`, `detach`, `request_scope` or `provider` from
|
|
265
|
+
`xtr_dependency_injection.integration.fastapi`. They are what `setup` is built on; an
|
|
266
|
+
application calls `setup` and nothing lower.
|
|
267
|
+
- Do not call `setup` after the application has started. The framework refuses new middleware
|
|
268
|
+
then, and the lifecycle would be missing.
|
|
269
|
+
- Do not use `TestClient`, and do not drive the app without entering its lifespan: no kernel is
|
|
270
|
+
attached, so every marker fails.
|
|
271
|
+
- Do not expect a body on `ResponseEvent`. Answer at `RequestEvent` or `ExceptionEvent` instead.
|
|
272
|
+
- Do not look for a controller, controller-arguments or view event. Routing, argument resolution
|
|
273
|
+
and serialization are the framework's, deliberately.
|
xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/configure.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# HttpKernelConfig
|
|
2
|
+
|
|
3
|
+
A frozen dataclass buildable with no arguments; every field has a default. Import it from
|
|
4
|
+
`xtr_http_kernel.bundle` and return it from a `@configure` function in
|
|
5
|
+
`<app>/config/http_kernel.py`.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from xtr_dependency_injection import configure
|
|
9
|
+
|
|
10
|
+
from xtr_http_kernel.bundle import HttpKernelConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@configure
|
|
14
|
+
def http_kernel() -> HttpKernelConfig:
|
|
15
|
+
return HttpKernelConfig(disallow_search_indexing=True, app="app.web:app")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| Field | Default | What it sets |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `request_id_header` | `"X-Request-Id"` | the header the id is read from and echoed on; must be a non-empty HTTP token |
|
|
21
|
+
| `trust_request_id` | `True` | keep a well-formed incoming id; `False` mints a fresh one every request |
|
|
22
|
+
| `disallow_search_indexing` | `False` | mark every response `X-Robots-Tag: noindex` |
|
|
23
|
+
| `log_channel` | `"request"` | the logging channel the error listener writes to |
|
|
24
|
+
| `middleware_priority` | `0` | where the lifecycle middleware sits among the contributed factories, highest outermost |
|
|
25
|
+
| `app` | `None` | the `"package.module:app"` string the router commands load the application from |
|
|
26
|
+
|
|
27
|
+
## What the constructor refuses
|
|
28
|
+
|
|
29
|
+
Each raises `InvalidArgumentError`, which is also a `ValueError`:
|
|
30
|
+
|
|
31
|
+
- a `request_id_header` that is not an HTTP token (`"bad header"` with a space, or empty);
|
|
32
|
+
- an empty `log_channel`;
|
|
33
|
+
- an `app` that is not exactly one module and one attribute around a single `:`.
|
|
34
|
+
|
|
35
|
+
## log_channel
|
|
36
|
+
|
|
37
|
+
The bundle declares the default `request` channel on the logging config for you. An application
|
|
38
|
+
renaming it must declare the new channel in its **own** logging configuration: this bundle's
|
|
39
|
+
config resolves after logging's, so it cannot declare a name it does not yet know.
|
|
40
|
+
|
|
41
|
+
## app
|
|
42
|
+
|
|
43
|
+
Only the `debug:router` and `router:match` commands read it, and only when their `--app` option
|
|
44
|
+
is left out. A served application never needs it.
|
xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/listeners.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The listeners the bundle registers
|
|
2
|
+
|
|
3
|
+
Which ones join depends on what is installed and active. None of them needs to be listed: the
|
|
4
|
+
bundle registers them.
|
|
5
|
+
|
|
6
|
+
| Listener | Events | Active when | Does |
|
|
7
|
+
| --- | --- | --- | --- |
|
|
8
|
+
| `RequestIdListener` | `RequestEvent`, `ResponseEvent` | always | keeps a trusted incoming id or mints a `uuid4().hex`, puts it on `request.state.request_id`, binds it to the log context when logging is around, and echoes it on the response under `request_id_header` |
|
|
9
|
+
| `DisallowRobotsIndexingListener` | `ResponseEvent` | always | stamps `X-Robots-Tag: noindex` on every response, when `disallow_search_indexing` turns it on |
|
|
10
|
+
| `LogUnitListener` | `RequestEvent`, `TerminateEvent` | logging bundle active | opens a logging unit of work per request and closes it once all was sent |
|
|
11
|
+
| `ErrorLoggingListener` | `ExceptionEvent` | logging bundle active | writes every uncaught exception to `log_channel`, at `error` below a 500 status and `critical` otherwise, leaving the response to whoever answers it |
|
|
12
|
+
| `RateLimitHeadersListener` | `ResponseEvent` | rate limiter bundle active | writes the `X-RateLimit-*` headers of the limit that speaks for the response, and makes the response private |
|
|
13
|
+
|
|
14
|
+
The two logging listeners open and close the unit of work outside everything else, so every
|
|
15
|
+
record made while handling carries the request's id. The request id settles right after the unit
|
|
16
|
+
opens, for the same reason.
|
|
17
|
+
|
|
18
|
+
What this means for application code:
|
|
19
|
+
|
|
20
|
+
- Read the current id from `request.state.request_id`, or from the response's
|
|
21
|
+
`request_id_header`. Do not mint your own.
|
|
22
|
+
- An uncaught exception is already logged with the request that caused it. An
|
|
23
|
+
`ExceptionEvent` listener of your own should turn the failure into a response, not log it
|
|
24
|
+
again.
|
|
25
|
+
- To keep every page out of a search index, set `disallow_search_indexing=True` rather than
|
|
26
|
+
writing a `ResponseEvent` listener.
|
|
27
|
+
- To stop trusting an inbound id (a public edge), set `trust_request_id=False`.
|
xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/rate-limits.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Rate limits
|
|
2
|
+
|
|
3
|
+
Needs the `rate-limiter` extra (`uv add "xtr-http-kernel[rate-limiter]"`) and limiters
|
|
4
|
+
configured in [xtr-rate-limiter](https://github.com/xterr/python-xtr-rate-limiter)'s bundle.
|
|
5
|
+
`RateLimited` names one of those limiters; everything else is where you put the declaration.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from xtr_http_kernel.rate_limiter import RateLimited
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Where a declaration goes
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
15
|
+
|
|
16
|
+
app = FastAPI(dependencies=[RateLimited("global")]) # every route
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.get("/books")
|
|
20
|
+
@RateLimited("api", expose_headers=True) # BELOW the route decorator
|
|
21
|
+
async def list_books() -> list[Book]: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.post("/login", dependencies=[RateLimited("login", key=by_username, methods="post")])
|
|
25
|
+
async def login() -> None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
reports = RateLimited("reports")(APIRouter(prefix="/reports")) # BEFORE its routes
|
|
29
|
+
app.include_router(admin, dependencies=[RateLimited("admin")]) # or when included
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- A decorator must sit **below** the route decorator: the route reads the endpoint when it is
|
|
33
|
+
declared.
|
|
34
|
+
- A router must be limited **before** routes are added, since each route copies its router's
|
|
35
|
+
dependencies. A router that already has routes raises `InvalidRateLimitError`; give the limit
|
|
36
|
+
to `include_router(..., dependencies=[...])` instead.
|
|
37
|
+
- The limit runs after routing and before the endpoint, in the framework's dependency order:
|
|
38
|
+
the application's, the routers', then the route's.
|
|
39
|
+
- Two declarations naming one limiter both count. The published schema is untouched: the limit
|
|
40
|
+
never appears as a parameter.
|
|
41
|
+
|
|
42
|
+
## Arguments
|
|
43
|
+
|
|
44
|
+
| Argument | Default | Meaning |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `limiter` | required | The name the limiter is configured under |
|
|
47
|
+
| `key` | `None` | What the request is counted under |
|
|
48
|
+
| `tokens` | `1` | Tokens one request consumes; below one raises `InvalidRateLimitError` |
|
|
49
|
+
| `methods` | `()` | The methods limited, every one when empty. One string or an iterable, case-insensitive; `GET` also limits `HEAD` |
|
|
50
|
+
| `expose_headers` | `False` | Report this limit in `X-RateLimit-*` headers |
|
|
51
|
+
|
|
52
|
+
### `key`
|
|
53
|
+
|
|
54
|
+
A string, or a function of the request returning a string, awaited when it returns an awaitable
|
|
55
|
+
(`RateLimitKey = str | Callable[[Request], str | Awaitable[str]]`). Anything but a string raises
|
|
56
|
+
`InvalidRateLimitError`.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
def by_username(request: Request) -> str:
|
|
60
|
+
return request.headers.get("x-username", "anonymous")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
With no `key`, a request counts under the client's address, the method and the route's **path
|
|
64
|
+
template**, so `/books/1` and `/books/2` share one count and varying a path parameter never
|
|
65
|
+
earns a fresh limit. Behind a proxy the address is the proxy's unless the server trusts its
|
|
66
|
+
forwarded headers (uvicorn's `--forwarded-allow-ips`).
|
|
67
|
+
|
|
68
|
+
## A refusal
|
|
69
|
+
|
|
70
|
+
- Answered `429 Too Many Requests` with `Retry-After`, raised as `TooManyRequestsError`. It is
|
|
71
|
+
the framework's own HTTP exception, so an exception handler registered for it reshapes the
|
|
72
|
+
body.
|
|
73
|
+
- A `RateLimitExceededEvent` naming the limiter and the key is dispatched first, when an event
|
|
74
|
+
dispatcher is around.
|
|
75
|
+
- Limits consulted before the refusal keep their spend.
|
|
76
|
+
- A limiter the application never configured raises `UnknownRateLimiterError`, naming the ones
|
|
77
|
+
it did.
|
|
78
|
+
|
|
79
|
+
## Headers
|
|
80
|
+
|
|
81
|
+
With `expose_headers=True` the response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
|
|
82
|
+
`X-RateLimit-Reset`, in calls rather than tokens, for the limit closest to refusing among those
|
|
83
|
+
exposing theirs. A refusing limit always speaks, and a limiter that keeps its state to itself
|
|
84
|
+
leaves the response without them. The response is made private, so a shared cache never serves
|
|
85
|
+
one caller's count to another.
|
|
86
|
+
|
|
87
|
+
## Limiting by hand
|
|
88
|
+
|
|
89
|
+
For a limit that only applies in some outcome, such as throttling logins only on failure,
|
|
90
|
+
inject the limiter by name like any other service:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from typing import Annotated
|
|
94
|
+
|
|
95
|
+
from xtr_dependency_injection import Target
|
|
96
|
+
from xtr_rate_limiter import RateLimiterFactoryInterface
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.post("/login")
|
|
100
|
+
async def login(
|
|
101
|
+
limiter: Annotated[RateLimiterFactoryInterface, Target("login")],
|
|
102
|
+
) -> None:
|
|
103
|
+
limit = limiter.create(username)
|
|
104
|
+
if not (await limit.consume(0)).is_accepted():
|
|
105
|
+
raise TooManyAttempts
|
|
106
|
+
...
|
|
107
|
+
```
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py
RENAMED
|
@@ -22,7 +22,7 @@ from xtr_dependency_injection import (
|
|
|
22
22
|
bundle_active,
|
|
23
23
|
required_bundle,
|
|
24
24
|
)
|
|
25
|
-
from xtr_event_dispatcher.bundle import EventDispatcherBundle
|
|
25
|
+
from xtr_event_dispatcher.bundle import LISTENER_TAG, EventDispatcherBundle
|
|
26
26
|
|
|
27
27
|
# Read at runtime: the container fills factory parameters from annotations.
|
|
28
28
|
from xtr_logging_contracts import LoggerInterface
|
|
@@ -50,9 +50,6 @@ if TYPE_CHECKING:
|
|
|
50
50
|
|
|
51
51
|
__all__ = ["HttpKernelBundle"]
|
|
52
52
|
|
|
53
|
-
_LISTENER_TAG: Final = "event_dispatcher.listener"
|
|
54
|
-
"""The tag the event dispatcher bundle reads listeners from."""
|
|
55
|
-
|
|
56
53
|
# The unit of work opens before anything else contributes and closes after
|
|
57
54
|
# everything else has heard the terminate; the request id settles right after
|
|
58
55
|
# the unit opens, so every record made while handling carries it.
|
|
@@ -104,17 +101,17 @@ class HttpKernelBundle(Bundle[HttpKernelConfig]):
|
|
|
104
101
|
.set_argument("header", config.request_id_header)
|
|
105
102
|
.set_argument("trust_incoming", config.trust_request_id)
|
|
106
103
|
.add_tag(
|
|
107
|
-
|
|
104
|
+
LISTENER_TAG,
|
|
108
105
|
event=RequestEvent,
|
|
109
106
|
method="on_request",
|
|
110
107
|
priority=_REQUEST_ID_PRIORITY,
|
|
111
108
|
)
|
|
112
|
-
.add_tag(
|
|
109
|
+
.add_tag(LISTENER_TAG, event=ResponseEvent, method="on_response")
|
|
113
110
|
)
|
|
114
111
|
_ = (
|
|
115
112
|
services.set(DisallowRobotsIndexingListener)
|
|
116
113
|
.set_argument("enabled", config.disallow_search_indexing)
|
|
117
|
-
.add_tag(
|
|
114
|
+
.add_tag(LISTENER_TAG, event=ResponseEvent, method="on_response")
|
|
118
115
|
)
|
|
119
116
|
if bundle_active(builder, "logging"):
|
|
120
117
|
# Needs the optional logging extra, which being here proves installed.
|
|
@@ -125,20 +122,20 @@ class HttpKernelBundle(Bundle[HttpKernelConfig]):
|
|
|
125
122
|
_ = (
|
|
126
123
|
services.set(LogUnitListener)
|
|
127
124
|
.add_tag(
|
|
128
|
-
|
|
125
|
+
LISTENER_TAG,
|
|
129
126
|
event=RequestEvent,
|
|
130
127
|
method="on_request",
|
|
131
128
|
priority=_UNIT_OPEN_PRIORITY,
|
|
132
129
|
)
|
|
133
130
|
.add_tag(
|
|
134
|
-
|
|
131
|
+
LISTENER_TAG,
|
|
135
132
|
event=TerminateEvent,
|
|
136
133
|
method="on_terminate",
|
|
137
134
|
priority=_UNIT_CLOSE_PRIORITY,
|
|
138
135
|
)
|
|
139
136
|
)
|
|
140
137
|
_ = services.set(_error_logging_listener(config.log_channel)).add_tag(
|
|
141
|
-
|
|
138
|
+
LISTENER_TAG, event=ExceptionEvent, method="on_exception"
|
|
142
139
|
)
|
|
143
140
|
if bundle_active(builder, "rate_limiter"):
|
|
144
141
|
# Reads what the rate-limit dependency leaves on a request; the extra is installed.
|
|
@@ -147,7 +144,7 @@ class HttpKernelBundle(Bundle[HttpKernelConfig]):
|
|
|
147
144
|
)
|
|
148
145
|
|
|
149
146
|
_ = services.set(RateLimitHeadersListener).add_tag(
|
|
150
|
-
|
|
147
|
+
LISTENER_TAG, event=ResponseEvent, method="on_response"
|
|
151
148
|
)
|
|
152
149
|
if bundle_active(builder, "console"):
|
|
153
150
|
services.load("xtr_http_kernel.command")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""This directory holds the package's recipe: its manifest.
|
|
2
|
+
|
|
3
|
+
The recipe is what *Use in an application* in the README lists, written as
|
|
4
|
+
data: the bundle to list, and the step only a person can take — where the
|
|
5
|
+
application is built is application code, so a recipe says what to call rather
|
|
6
|
+
than calling it. An application applies it with ``xtr-recipes recipes:sync``,
|
|
7
|
+
which finds this module through the ``xtr_recipes`` entry point.
|
|
8
|
+
|
|
9
|
+
Nothing is importable from here. The manifest is read as a file, so a recipe
|
|
10
|
+
is the same from a wheel as from a working tree.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# The recipe for xtr-http-kernel: what *Use in an application* in the README
|
|
2
|
+
# takes, as data. Keep the two in step — the README is the source.
|
|
3
|
+
|
|
4
|
+
[bundles]
|
|
5
|
+
# "<module>:<class>" = the environments it is listed with, exactly as in BUNDLES.
|
|
6
|
+
"xtr_http_kernel.bundle:HttpKernelBundle" = { all = true }
|
|
7
|
+
|
|
8
|
+
# No [files]: nothing is required. With no configuration a request gets a uuid4
|
|
9
|
+
# id under X-Request-Id, no robots header, and the "request" log channel; an
|
|
10
|
+
# application changing any of that writes config/http_kernel.py itself.
|
|
11
|
+
|
|
12
|
+
# No [env], and nothing to ignore.
|
|
13
|
+
|
|
14
|
+
[gitignore]
|
|
15
|
+
lines = []
|
|
16
|
+
|
|
17
|
+
[notes]
|
|
18
|
+
steps = [
|
|
19
|
+
"Call setup(app, kernel) where the application is built: the lifecycle reaches a request through it.",
|
|
20
|
+
]
|
|
21
|
+
check = [
|
|
22
|
+
"<script> debug:bundles — http_kernel is listed and active, event_dispatcher required",
|
|
23
|
+
"<script> debug:router — the application's routes",
|
|
24
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/_route_contexts.py
RENAMED
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/debug_router_command.py
RENAMED
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/route_description.py
RENAMED
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_command.py
RENAMED
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_match_command.py
RENAMED
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/exception_event.py
RENAMED
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/finish_request_event.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/terminate_event.py
RENAMED
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/rate_limited.py
RENAMED
|
File without changes
|
{xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|