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.
Files changed (53) hide show
  1. xtr_http_kernel-1.5.0/README.md → xtr_http_kernel-3.0.0/PKG-INFO +127 -6
  2. xtr_http_kernel-1.5.0/PKG-INFO → xtr_http_kernel-3.0.0/README.md +93 -37
  3. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/pyproject.toml +19 -10
  4. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/pyproject.toml.orig +20 -10
  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-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +18 -11
  10. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py +6 -4
  11. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +1 -1
  12. xtr_http_kernel-3.0.0/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +52 -0
  13. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/__init__.py +12 -1
  14. xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/invalid_argument_error.py +25 -0
  15. xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +26 -0
  16. xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/too_many_requests_error.py +50 -0
  17. xtr_http_kernel-3.0.0/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +34 -0
  18. xtr_http_kernel-3.0.0/src/xtr_http_kernel/rate_limiter/__init__.py +11 -0
  19. xtr_http_kernel-3.0.0/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +40 -0
  20. xtr_http_kernel-3.0.0/src/xtr_http_kernel/rate_limiter/rate_limited.py +283 -0
  21. xtr_http_kernel-3.0.0/src/xtr_http_kernel/recipe/__init__.py +15 -0
  22. xtr_http_kernel-3.0.0/src/xtr_http_kernel/recipe/manifest.toml +24 -0
  23. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py +7 -1
  24. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/LICENSE +0 -0
  25. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/__init__.py +0 -0
  26. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
  27. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/_state.py +0 -0
  28. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/bundle/__init__.py +0 -0
  29. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/__init__.py +0 -0
  30. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/_route_contexts.py +0 -0
  31. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/debug_router_command.py +0 -0
  32. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/route_description.py +0 -0
  33. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_command.py +0 -0
  34. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/command/router_match_command.py +0 -0
  35. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/__init__.py +0 -0
  36. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/exception_event.py +0 -0
  37. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
  38. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/request_event.py +0 -0
  39. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/response_event.py +0 -0
  40. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event/terminate_event.py +0 -0
  41. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
  42. {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
  43. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -0
  44. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
  45. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/event_listener/request_id_listener.py +0 -0
  46. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
  47. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
  48. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/kernel_events.py +0 -0
  49. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_stack.py +0 -0
  50. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/middleware_tag.py +0 -0
  51. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/py.typed +0 -0
  52. {xtr_http_kernel-1.5.0 → xtr_http_kernel-3.0.0}/src/xtr_http_kernel/setup.py +0 -0
  53. {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. Neither is needed to
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) and [console](../xtr-console)
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. Neither is needed to
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) and [console](../xtr-console)
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 = "1.5.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]>=1.0,<2",
31
- "xtr-event-dispatcher>=1.0,<2",
32
- "xtr-event-dispatcher-contracts>=1.0,<2",
33
- "xtr-logging-contracts>=1.0,<2",
34
- "xtr-service-contracts>=1.0,<2",
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>=1.0,<2"]
44
- console = ["xtr-console>=1.0,<2"]
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>=1.0,<2",
58
- "xtr-console>=1.0,<2",
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 = "1.5.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,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]>=1.0,<2",
30
- "xtr-event-dispatcher>=1.0,<2",
31
- "xtr-event-dispatcher-contracts>=1.0,<2",
32
- "xtr-logging-contracts>=1.0,<2",
33
- "xtr-service-contracts>=1.0,<2",
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>=1.0,<2",
39
+ "xtr-logging>=3.0,<4",
40
40
  ]
41
41
  console = [
42
- "xtr-console>=1.0,<2",
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>=1.0,<2",
59
- "xtr-console>=1.0,<2",
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",