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.
Files changed (53) hide show
  1. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/PKG-INFO +13 -10
  2. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/README.md +3 -0
  3. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/pyproject.toml +17 -14
  4. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/pyproject.toml.orig +18 -14
  5. xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/SKILL.md +273 -0
  6. xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/configure.md +44 -0
  7. xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/listeners.md +27 -0
  8. xtr_http_kernel-3.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/rate-limits.md +107 -0
  9. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +8 -11
  10. xtr_http_kernel-3.0.0/src/xtr_http_kernel/recipe/__init__.py +15 -0
  11. xtr_http_kernel-3.0.0/src/xtr_http_kernel/recipe/manifest.toml +24 -0
  12. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/LICENSE +0 -0
  13. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/__init__.py +0 -0
  14. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
  15. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_state.py +0 -0
  16. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/__init__.py +0 -0
  17. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py +0 -0
  18. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +0 -0
  19. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/__init__.py +0 -0
  20. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/_route_contexts.py +0 -0
  21. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/debug_router_command.py +0 -0
  22. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/route_description.py +0 -0
  23. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_command.py +0 -0
  24. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_match_command.py +0 -0
  25. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/__init__.py +0 -0
  26. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/exception_event.py +0 -0
  27. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
  28. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/request_event.py +0 -0
  29. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/response_event.py +0 -0
  30. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/terminate_event.py +0 -0
  31. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
  32. {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
  33. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -0
  34. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
  35. {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
  36. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/request_id_listener.py +0 -0
  37. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/__init__.py +0 -0
  38. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
  39. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_argument_error.py +0 -0
  40. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
  41. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +0 -0
  42. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/too_many_requests_error.py +0 -0
  43. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +0 -0
  44. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/kernel_events.py +0 -0
  45. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_stack.py +0 -0
  46. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_tag.py +0 -0
  47. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/py.typed +0 -0
  48. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/__init__.py +0 -0
  49. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +0 -0
  50. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/rate_limiter/rate_limited.py +0 -0
  51. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py +0 -0
  52. {xtr_http_kernel-2.0.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/setup.py +0 -0
  53. {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: 2.0.0
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]>=2.0,<3
20
- Requires-Dist: xtr-event-dispatcher>=2.0,<3
21
- Requires-Dist: xtr-event-dispatcher-contracts>=2.0,<3
22
- Requires-Dist: xtr-logging-contracts>=2.0,<3
23
- Requires-Dist: xtr-service-contracts>=2.0,<3
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>=2.0,<3 ; extra == 'console'
26
- Requires-Dist: xtr-logging>=2.0,<3 ; extra == 'logging'
27
- Requires-Dist: xtr-clock>=2.0,<3 ; extra == 'rate-limiter'
28
- Requires-Dist: xtr-rate-limiter[di]>=2.0,<3 ; extra == 'rate-limiter'
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 = "2.0.0"
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]>=2.0,<3",
31
- "xtr-event-dispatcher>=2.0,<3",
32
- "xtr-event-dispatcher-contracts>=2.0,<3",
33
- "xtr-logging-contracts>=2.0,<3",
34
- "xtr-service-contracts>=2.0,<3",
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>=2.0,<3"]
44
- console = ["xtr-console>=2.0,<3"]
43
+ logging = ["xtr-logging>=3.0,<4"]
44
+ console = ["xtr-console>=3.0,<4"]
45
45
  rate-limiter = [
46
- "xtr-clock>=2.0,<3",
47
- "xtr-rate-limiter[di]>=2.0,<3",
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>=2.0,<3",
62
- "xtr-console>=2.0,<3",
63
- "xtr-clock>=2.0,<3",
64
- "xtr-rate-limiter[di]>=2.0,<3",
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 = "2.0.0"
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]>=2.0,<3",
30
- "xtr-event-dispatcher>=2.0,<3",
31
- "xtr-event-dispatcher-contracts>=2.0,<3",
32
- "xtr-logging-contracts>=2.0,<3",
33
- "xtr-service-contracts>=2.0,<3",
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>=2.0,<3",
39
+ "xtr-logging>=3.0,<4",
40
40
  ]
41
41
  console = [
42
- "xtr-console>=2.0,<3",
42
+ "xtr-console>=3.0,<4",
43
43
  ]
44
44
  rate-limiter = [
45
- "xtr-clock>=2.0,<3",
46
- "xtr-rate-limiter[di]>=2.0,<3",
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>=2.0,<3",
63
- "xtr-console>=2.0,<3",
64
- "xtr-clock>=2.0,<3",
65
- "xtr-rate-limiter[di]>=2.0,<3",
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.
@@ -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.
@@ -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`.
@@ -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
+ ```
@@ -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
- _LISTENER_TAG,
104
+ LISTENER_TAG,
108
105
  event=RequestEvent,
109
106
  method="on_request",
110
107
  priority=_REQUEST_ID_PRIORITY,
111
108
  )
112
- .add_tag(_LISTENER_TAG, event=ResponseEvent, method="on_response")
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(_LISTENER_TAG, event=ResponseEvent, method="on_response")
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
- _LISTENER_TAG,
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
- _LISTENER_TAG,
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
- _LISTENER_TAG, event=ExceptionEvent, method="on_exception"
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
- _LISTENER_TAG, event=ResponseEvent, method="on_response"
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