xtr-http-kernel 1.4.0__tar.gz → 2.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 (47) hide show
  1. xtr_http_kernel-1.4.0/README.md → xtr_http_kernel-2.0.0/PKG-INFO +124 -6
  2. xtr_http_kernel-1.4.0/PKG-INFO → xtr_http_kernel-2.0.0/README.md +90 -37
  3. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/pyproject.toml +18 -12
  4. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/pyproject.toml.orig +17 -11
  5. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +11 -1
  6. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py +6 -4
  7. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +1 -1
  8. xtr_http_kernel-2.0.0/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +52 -0
  9. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/exception/__init__.py +12 -1
  10. xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/invalid_argument_error.py +25 -0
  11. xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +26 -0
  12. xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/too_many_requests_error.py +50 -0
  13. xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +34 -0
  14. xtr_http_kernel-2.0.0/src/xtr_http_kernel/rate_limiter/__init__.py +11 -0
  15. xtr_http_kernel-2.0.0/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +40 -0
  16. xtr_http_kernel-2.0.0/src/xtr_http_kernel/rate_limiter/rate_limited.py +283 -0
  17. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py +7 -1
  18. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/LICENSE +0 -0
  19. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/__init__.py +0 -0
  20. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
  21. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/_state.py +0 -0
  22. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/__init__.py +0 -0
  23. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/__init__.py +0 -0
  24. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/_route_contexts.py +0 -0
  25. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/debug_router_command.py +0 -0
  26. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/route_description.py +0 -0
  27. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/router_command.py +0 -0
  28. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/router_match_command.py +0 -0
  29. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/__init__.py +0 -0
  30. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/exception_event.py +0 -0
  31. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
  32. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/request_event.py +0 -0
  33. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/response_event.py +0 -0
  34. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/terminate_event.py +0 -0
  35. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
  36. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +0 -0
  37. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -0
  38. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
  39. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/request_id_listener.py +0 -0
  40. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
  41. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
  42. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/kernel_events.py +0 -0
  43. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/middleware_stack.py +0 -0
  44. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/middleware_tag.py +0 -0
  45. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/py.typed +0 -0
  46. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/setup.py +0 -0
  47. {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.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: 2.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]>=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
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'
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,13 @@ 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.
211
322
  - **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
212
323
  from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
213
324
  - **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
325
+ lifecycle dispatches through it; the [logging](../xtr-logging), [console](../xtr-console) and
326
+ [rate limiter](../xtr-rate-limiter) bundles whenever those packages are *installed* — they are required peers, pulled in and made
216
327
  active without being listed, and left out silently when the package is not installed. Listing
217
328
  is not what activates them; installing the extra is.
218
329
  - **Configure** — nothing is required: the zero-config path gives a `uuid4` request id under
@@ -243,7 +354,7 @@ default:
243
354
 
244
355
  The constructor refuses values the lifecycle would silently misread: a `request_id_header` that
245
356
  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`.
357
+ attribute around a single `:`, each raise `InvalidArgumentError` — also a `ValueError`.
247
358
 
248
359
  The bundle declares the default `log_channel` on the logging config for you. An application
249
360
  renaming it must declare the new channel in its own logging configuration — this bundle's
@@ -352,6 +463,8 @@ async def test_it_uses_the_fake_catalogue() -> None:
352
463
  ```
353
464
 
354
465
  A key is a type, or a `(type, qualifier)` pair for a qualified service.
466
+ The overrides live on the application itself, so two tests serving one application must not
467
+ run at the same time: the block that exits last would restore what the other replaced.
355
468
 
356
469
  ## Errors
357
470
 
@@ -360,7 +473,11 @@ typed attributes rather than only a message.
360
473
 
361
474
  | Error | Raised when |
362
475
  |---|---|
476
+ | `InvalidArgumentError` | an `HttpKernelConfig` field holds a value the lifecycle would misread; also a `ValueError` |
363
477
  | `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
478
+ | `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` |
479
+ | `TooManyRequestsError` | a limiter refused the request — a 429 the framework answers, with `Retry-After` |
480
+ | `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
364
481
 
365
482
  ## Layout
366
483
 
@@ -374,6 +491,7 @@ xtr_http_kernel/
374
491
  ├── request_lifecycle_middleware.py the middleware that dispatches the events
375
492
  ├── middleware_stack.py the ordered chain a bundle contributes to
376
493
  ├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
494
+ ├── rate_limiter/ RateLimited, with the rate-limiter extra
377
495
  ├── command/ debug:router and router:match
378
496
  ├── exception/ HttpKernelError, the root of everything this library raises
379
497
  └── bundle/ HttpKernelBundle and HttpKernelConfig
@@ -1,34 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: xtr-http-kernel
3
- Version: 1.4.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: Razvan Ceana
7
- Author-email: Razvan Ceana <razvan@ceana.ro>
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,13 @@ 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.
242
288
  - **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
243
289
  from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
244
290
  - **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
291
+ lifecycle dispatches through it; the [logging](../xtr-logging), [console](../xtr-console) and
292
+ [rate limiter](../xtr-rate-limiter) bundles whenever those packages are *installed* — they are required peers, pulled in and made
247
293
  active without being listed, and left out silently when the package is not installed. Listing
248
294
  is not what activates them; installing the extra is.
249
295
  - **Configure** — nothing is required: the zero-config path gives a `uuid4` request id under
@@ -274,7 +320,7 @@ default:
274
320
 
275
321
  The constructor refuses values the lifecycle would silently misread: a `request_id_header` that
276
322
  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`.
323
+ attribute around a single `:`, each raise `InvalidArgumentError` — also a `ValueError`.
278
324
 
279
325
  The bundle declares the default `log_channel` on the logging config for you. An application
280
326
  renaming it must declare the new channel in its own logging configuration — this bundle's
@@ -383,6 +429,8 @@ async def test_it_uses_the_fake_catalogue() -> None:
383
429
  ```
384
430
 
385
431
  A key is a type, or a `(type, qualifier)` pair for a qualified service.
432
+ The overrides live on the application itself, so two tests serving one application must not
433
+ run at the same time: the block that exits last would restore what the other replaced.
386
434
 
387
435
  ## Errors
388
436
 
@@ -391,7 +439,11 @@ typed attributes rather than only a message.
391
439
 
392
440
  | Error | Raised when |
393
441
  |---|---|
442
+ | `InvalidArgumentError` | an `HttpKernelConfig` field holds a value the lifecycle would misread; also a `ValueError` |
394
443
  | `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
444
+ | `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` |
445
+ | `TooManyRequestsError` | a limiter refused the request — a 429 the framework answers, with `Retry-After` |
446
+ | `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
395
447
 
396
448
  ## Layout
397
449
 
@@ -405,6 +457,7 @@ xtr_http_kernel/
405
457
  ├── request_lifecycle_middleware.py the middleware that dispatches the events
406
458
  ├── middleware_stack.py the ordered chain a bundle contributes to
407
459
  ├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
460
+ ├── rate_limiter/ RateLimited, with the rate-limiter extra
408
461
  ├── command/ debug:router and router:match
409
462
  ├── exception/ HttpKernelError, the root of everything this library raises
410
463
  └── bundle/ HttpKernelBundle and HttpKernelConfig
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-http-kernel"
3
- version = "1.4.0"
3
+ version = "2.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,21 +27,25 @@ 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]>=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",
35
35
  "typing-extensions>=4.4",
36
36
  ]
37
37
 
38
38
  [[project.authors]]
39
- name = "Razvan Ceana"
40
- email = "razvan@ceana.ro"
39
+ name = "Xterr"
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>=2.0,<3"]
44
+ console = ["xtr-console>=2.0,<3"]
45
+ rate-limiter = [
46
+ "xtr-clock>=2.0,<3",
47
+ "xtr-rate-limiter[di]>=2.0,<3",
48
+ ]
45
49
 
46
50
  [project.entry-points."xtr_dependency_injection.bundles"]
47
51
  http_kernel = "xtr_http_kernel.bundle:HttpKernelBundle"
@@ -54,8 +58,10 @@ dev = [
54
58
  "pytest-cov>=5",
55
59
  "anyio>=4.0",
56
60
  "ty>=0.0.83",
57
- "xtr-logging>=1.0,<2",
58
- "xtr-console>=1.0,<2",
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",
59
65
  "httpx>=0.27",
60
66
  ]
61
67
 
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "xtr-http-kernel"
3
- version = "1.4.0"
3
+ version = "2.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"
7
7
  license = "MIT"
8
8
  license-files = ["LICENSE"]
9
9
  authors = [
10
- { name = "Razvan Ceana", email = "razvan@ceana.ro" }
10
+ { name = "Xterr", email = "me@xterr.dev" }
11
11
  ]
12
12
  keywords = ["http", "kernel", "request", "lifecycle", "events", "middleware", "fastapi"]
13
13
  classifiers = [
@@ -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]>=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",
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>=2.0,<3",
40
40
  ]
41
41
  console = [
42
- "xtr-console>=1.0,<2",
42
+ "xtr-console>=2.0,<3",
43
+ ]
44
+ rate-limiter = [
45
+ "xtr-clock>=2.0,<3",
46
+ "xtr-rate-limiter[di]>=2.0,<3",
43
47
  ]
44
48
 
45
49
  # Advertises the bundle so debug:bundles can name it when installed but not listed.
@@ -55,8 +59,10 @@ dev = [
55
59
  "pytest-cov>=5",
56
60
  "anyio>=4.0",
57
61
  "ty>=0.0.83",
58
- "xtr-logging>=1.0,<2",
59
- "xtr-console>=1.0,<2",
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",
60
66
  # Requests are driven straight at the application through an ASGI
61
67
  # transport, so the suite never binds a socket.
62
68
  "httpx>=0.27",
@@ -65,6 +65,7 @@ _UNIT_CLOSE_PRIORITY: Final = -8192
65
65
  @required_bundle(EventDispatcherBundle)
66
66
  @required_bundle("xtr_logging.bundle:LoggingBundle", ignore_on_invalid=True)
67
67
  @required_bundle("xtr_console.bundle:ConsoleBundle", ignore_on_invalid=True)
68
+ @required_bundle("xtr_rate_limiter.bundle:RateLimiterBundle", ignore_on_invalid=True)
68
69
  @as_bundle("http_kernel", config=HttpKernelConfig)
69
70
  class HttpKernelBundle(Bundle[HttpKernelConfig]):
70
71
  """Puts the request lifecycle's services under the container."""
@@ -117,7 +118,7 @@ class HttpKernelBundle(Bundle[HttpKernelConfig]):
117
118
  )
118
119
  if bundle_active(builder, "logging"):
119
120
  # Needs the optional logging extra, which being here proves installed.
120
- from xtr_http_kernel.event_listener.log_unit_listener import ( # noqa: PLC0415
121
+ from xtr_http_kernel.event_listener.log_unit_listener import ( # noqa: PLC0415 — optional extra
121
122
  LogUnitListener,
122
123
  )
123
124
 
@@ -139,6 +140,15 @@ class HttpKernelBundle(Bundle[HttpKernelConfig]):
139
140
  _ = services.set(_error_logging_listener(config.log_channel)).add_tag(
140
141
  _LISTENER_TAG, event=ExceptionEvent, method="on_exception"
141
142
  )
143
+ if bundle_active(builder, "rate_limiter"):
144
+ # Reads what the rate-limit dependency leaves on a request; the extra is installed.
145
+ from xtr_http_kernel.event_listener.rate_limit_headers_listener import ( # noqa: PLC0415
146
+ RateLimitHeadersListener,
147
+ )
148
+
149
+ _ = services.set(RateLimitHeadersListener).add_tag(
150
+ _LISTENER_TAG, event=ResponseEvent, method="on_response"
151
+ )
142
152
  if bundle_active(builder, "console"):
143
153
  services.load("xtr_http_kernel.command")
144
154
 
@@ -6,6 +6,8 @@ import re
6
6
  from dataclasses import dataclass
7
7
  from typing import Final
8
8
 
9
+ from xtr_http_kernel.exception.invalid_argument_error import InvalidArgumentError
10
+
9
11
  __all__ = ["HttpKernelConfig"]
10
12
 
11
13
  _HTTP_TOKEN: Final = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
@@ -33,7 +35,7 @@ class HttpKernelConfig:
33
35
  load the application from, when they need one.
34
36
 
35
37
  Raises:
36
- ValueError: When ``request_id_header`` is not an HTTP token,
38
+ InvalidArgumentError: When ``request_id_header`` is not an HTTP token,
37
39
  ``log_channel`` is empty, or ``app`` does not hold exactly one
38
40
  module and one attribute around a single ``:``.
39
41
  """
@@ -51,12 +53,12 @@ class HttpKernelConfig:
51
53
  message = (
52
54
  f"request_id_header must be a non-empty HTTP token, not {self.request_id_header!r}"
53
55
  )
54
- raise ValueError(message)
56
+ raise InvalidArgumentError(message)
55
57
  if not self.log_channel:
56
58
  message = "log_channel must not be empty"
57
- raise ValueError(message)
59
+ raise InvalidArgumentError(message)
58
60
  if self.app is not None:
59
61
  module, separator, attribute = self.app.partition(":")
60
62
  if not (module and separator and attribute) or ":" in attribute:
61
63
  message = f'app must read "package.module:app", not {self.app!r}'
62
- raise ValueError(message)
64
+ raise InvalidArgumentError(message)
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING, final
6
6
 
7
7
  # Read at runtime: the container fills the constructor from this annotation.
8
- from xtr_event_dispatcher_contracts import EventDispatcherInterface # noqa: TC002
8
+ from xtr_event_dispatcher_contracts import EventDispatcherInterface # noqa: TC002 — read at runtime
9
9
 
10
10
  from xtr_http_kernel.request_lifecycle_middleware import RequestLifecycleMiddleware
11
11
 
@@ -0,0 +1,52 @@
1
+ """Responses say how much of their rate limit is left."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Final, final
6
+
7
+ from xtr_http_kernel.rate_limiter._applied_rate_limit import applied_rate_limit
8
+
9
+ if TYPE_CHECKING:
10
+ from xtr_http_kernel.event import ResponseEvent
11
+
12
+ __all__ = ["RateLimitHeadersListener"]
13
+
14
+ _LIMIT: Final = "X-RateLimit-Limit"
15
+ _REMAINING: Final = "X-RateLimit-Remaining"
16
+ _RESET: Final = "X-RateLimit-Reset"
17
+
18
+
19
+ @final
20
+ class RateLimitHeadersListener:
21
+ """Writes the ``X-RateLimit-*`` headers of the limit that speaks for a response.
22
+
23
+ The limit is the one a route's rate limit left on the request, when it
24
+ exposes its state: the calls a route may still make, how many a full
25
+ limit allows, and when it is full again, in seconds since the epoch. A
26
+ response already carrying any of the three keeps its own. The response
27
+ is made private too, so a shared cache never serves one caller's count
28
+ to another.
29
+ """
30
+
31
+ __slots__ = ()
32
+
33
+ def on_response(self, event: ResponseEvent) -> None:
34
+ """Report the limit that speaks for the response, if one does."""
35
+ applied = applied_rate_limit(event.request)
36
+ if applied is None or applied.rate_limit.reset_at is None:
37
+ return
38
+ headers = event.headers
39
+ if any(name in headers for name in (_LIMIT, _REMAINING, _RESET)):
40
+ return
41
+
42
+ headers[_LIMIT] = str(applied.rate_limit.limit // applied.tokens)
43
+ headers[_REMAINING] = str(max(0, int(applied.remaining_calls)))
44
+ headers[_RESET] = str(int(applied.rate_limit.reset_at.timestamp()))
45
+ directives = [
46
+ directive.strip()
47
+ for directive in headers.get("cache-control", "").split(",")
48
+ if directive.strip() and directive.strip().lower() != "public"
49
+ ]
50
+ if not any(directive.lower() in {"private", "no-store"} for directive in directives):
51
+ directives.append("private")
52
+ headers["cache-control"] = ", ".join(directives)
@@ -9,6 +9,17 @@ than forcing a message to be parsed.
9
9
  from __future__ import annotations
10
10
 
11
11
  from .http_kernel_error import HttpKernelError
12
+ from .invalid_argument_error import InvalidArgumentError
12
13
  from .invalid_middleware_priority_error import InvalidMiddlewarePriorityError
14
+ from .invalid_rate_limit_error import InvalidRateLimitError
15
+ from .too_many_requests_error import TooManyRequestsError
16
+ from .unknown_rate_limiter_error import UnknownRateLimiterError
13
17
 
14
- __all__ = ["HttpKernelError", "InvalidMiddlewarePriorityError"]
18
+ __all__ = [
19
+ "HttpKernelError",
20
+ "InvalidArgumentError",
21
+ "InvalidMiddlewarePriorityError",
22
+ "InvalidRateLimitError",
23
+ "TooManyRequestsError",
24
+ "UnknownRateLimiterError",
25
+ ]
@@ -0,0 +1,25 @@
1
+ """The lifecycle or its configuration was given a value it cannot work with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_kernel_error import HttpKernelError
6
+
7
+ __all__ = ["InvalidArgumentError"]
8
+
9
+
10
+ class InvalidArgumentError(HttpKernelError, ValueError):
11
+ """The lifecycle or its configuration was given a value it cannot work with.
12
+
13
+ Also a :class:`ValueError`, so code that already guards its configuration
14
+ with ``except ValueError`` keeps working without learning a new exception.
15
+
16
+ Attributes:
17
+ reason: What is wrong with the value.
18
+ """
19
+
20
+ reason: str
21
+
22
+ def __init__(self, reason: str) -> None:
23
+ """Record what is wrong with the value."""
24
+ self.reason = reason
25
+ super().__init__(reason)
@@ -0,0 +1,26 @@
1
+ """A route's rate limit was declared in a way it cannot be applied."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_kernel_error import HttpKernelError
6
+
7
+ __all__ = ["InvalidRateLimitError"]
8
+
9
+
10
+ class InvalidRateLimitError(HttpKernelError, ValueError):
11
+ """A route's rate limit was declared in a way it cannot be applied.
12
+
13
+ Raised where it is declared — fewer than one token, a router that
14
+ already has routes, a key that is not a string — rather than silently
15
+ limiting nothing.
16
+
17
+ Attributes:
18
+ reason: What is wrong with the declaration.
19
+ """
20
+
21
+ reason: str
22
+
23
+ def __init__(self, reason: str) -> None:
24
+ """Record what is wrong with the declaration."""
25
+ self.reason = reason
26
+ super().__init__(reason)
@@ -0,0 +1,50 @@
1
+ """A route turned a request away because a rate limit refused it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ from starlette.exceptions import HTTPException
9
+
10
+ from .http_kernel_error import HttpKernelError
11
+
12
+ if TYPE_CHECKING:
13
+ from xtr_rate_limiter import RateLimit
14
+
15
+ __all__ = ["TooManyRequestsError"]
16
+
17
+ _TOO_MANY_REQUESTS: Final = 429
18
+
19
+
20
+ class TooManyRequestsError(HttpKernelError, HTTPException): # pyright: ignore[reportUnsafeMultipleInheritance] -- HttpKernelError adds no __init__; HTTPException's is called below
21
+ """A route turned a request away because a rate limit refused it.
22
+
23
+ Also the framework's own HTTP exception, so the application answers it
24
+ as it answers any other: ``429 Too Many Requests`` with a ``Retry-After``
25
+ header, reshaped by an exception handler registered for this class when
26
+ the application wants another body.
27
+
28
+ Attributes:
29
+ rate_limit: The limit that refused the request.
30
+ limiter: The configured limiter's name.
31
+ key: The key the request was counted under.
32
+ retry_after: Whole seconds until the request would be accepted.
33
+ """
34
+
35
+ rate_limit: RateLimit
36
+ limiter: str
37
+ key: str
38
+ retry_after: int
39
+
40
+ def __init__(self, rate_limit: RateLimit, limiter: str, key: str, now: float) -> None:
41
+ """Record the refused limit, counting the wait from ``now``."""
42
+ self.rate_limit = rate_limit
43
+ self.limiter = limiter
44
+ self.key = key
45
+ self.retry_after = max(0, math.ceil(rate_limit.retry_after.timestamp() - now))
46
+ HTTPException.__init__(
47
+ self,
48
+ _TOO_MANY_REQUESTS,
49
+ headers={"Retry-After": str(self.retry_after)},
50
+ )
@@ -0,0 +1,34 @@
1
+ """A route names a rate limiter nobody configured."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_kernel_error import HttpKernelError
6
+
7
+ __all__ = ["UnknownRateLimiterError"]
8
+
9
+
10
+ class UnknownRateLimiterError(HttpKernelError, LookupError):
11
+ """A route names a rate limiter nobody configured.
12
+
13
+ Raised when the first request reaches the route, since routes are
14
+ declared before any kernel is built. A limiter is configured in the
15
+ rate limiter bundle's configuration, under the name the route uses.
16
+
17
+ Attributes:
18
+ limiter: The name the route used.
19
+ available: The names the application configured.
20
+ """
21
+
22
+ limiter: str
23
+ available: tuple[str, ...]
24
+
25
+ def __init__(self, limiter: str, available: tuple[str, ...]) -> None:
26
+ """Record the unknown name and the ones that exist."""
27
+ self.limiter = limiter
28
+ self.available = available
29
+ names = '", "'.join(available)
30
+ known = f'"{names}"' if available else "none"
31
+ super().__init__(
32
+ f'Rate limiter "{limiter}" does not exist. Did you forget to configure it? '
33
+ f"Available limiters: {known}.",
34
+ )
@@ -0,0 +1,11 @@
1
+ """Rate limits on routes, routers and whole applications, through the rate limiter bundle.
2
+
3
+ Needs the ``rate-limiter`` extra, which brings xtr-rate-limiter; importing
4
+ this package without it fails, which is why nothing else here imports it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .rate_limited import RateLimited, RateLimitKey
10
+
11
+ __all__ = ["RateLimitKey", "RateLimited"]
@@ -0,0 +1,40 @@
1
+ """The rate limit that speaks for a response, out of every one a request consumed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Final, final
7
+
8
+ if TYPE_CHECKING:
9
+ from starlette.requests import Request
10
+ from xtr_rate_limiter import RateLimit
11
+
12
+ __all__ = ["STATE_KEY", "AppliedRateLimit", "applied_rate_limit"]
13
+
14
+ STATE_KEY: Final = "_xtr_rate_limit"
15
+ """Where a request keeps the limit its response reports, on ``request.state``."""
16
+
17
+
18
+ @final
19
+ @dataclass(frozen=True, slots=True)
20
+ class AppliedRateLimit:
21
+ """A consumed limit, and how many tokens each call of its route takes.
22
+
23
+ Attributes:
24
+ rate_limit: The limit as the request left it.
25
+ tokens: The tokens one call consumes.
26
+ """
27
+
28
+ rate_limit: RateLimit
29
+ tokens: int
30
+
31
+ @property
32
+ def remaining_calls(self) -> float:
33
+ """The calls left, rather than the tokens."""
34
+ return self.rate_limit.remaining_tokens / self.tokens
35
+
36
+
37
+ def applied_rate_limit(request: Request) -> AppliedRateLimit | None:
38
+ """Return the limit ``request``'s response reports; ``None`` when there is none."""
39
+ applied: object = getattr(request.state, STATE_KEY, None)
40
+ return applied if isinstance(applied, AppliedRateLimit) else None
@@ -0,0 +1,283 @@
1
+ """A rate limit on a route, a router or a whole application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import TYPE_CHECKING, Final, ParamSpec, TypeVar, cast, overload
9
+
10
+ from fastapi import APIRouter
11
+ from fastapi.params import Depends
12
+
13
+ # The framework reads the dependency's signature at runtime to hand it the request.
14
+ from starlette.requests import Request
15
+ from xtr_clock import Clock
16
+ from xtr_dependency_injection import current_unit_of_work
17
+ from xtr_event_dispatcher_contracts import EventDispatcherInterface
18
+ from xtr_rate_limiter import RateLimiterFactoryInterface, RateLimitExceededEvent
19
+
20
+ from xtr_http_kernel.exception import (
21
+ InvalidRateLimitError,
22
+ TooManyRequestsError,
23
+ UnknownRateLimiterError,
24
+ )
25
+
26
+ from ._applied_rate_limit import STATE_KEY, AppliedRateLimit, applied_rate_limit
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Iterable
30
+
31
+ from xtr_service_contracts import ContainerInterface
32
+
33
+ __all__ = ["RateLimitKey", "RateLimited"]
34
+
35
+ RateLimitKey = str | Callable[[Request], str | Awaitable[str]]
36
+ """What a request is counted under: a fixed key, or one worked out from the request."""
37
+
38
+ _P = ParamSpec("_P")
39
+ _R = TypeVar("_R")
40
+
41
+ _HIDDEN_PREFIX: Final = "_xtr_rate_limit_"
42
+ """What the parameter a decorated endpoint gains is called, numbered."""
43
+
44
+
45
+ class RateLimited(Depends):
46
+ """A rate limit a request must pass before its endpoint runs.
47
+
48
+ Attributes:
49
+ limiter: The name the limiter is configured under.
50
+ key: What a request is counted under; the client's address, the
51
+ method and the route's path template when ``None``.
52
+ tokens: The tokens one request consumes.
53
+ methods: The methods limited; every method when empty.
54
+ expose_headers: Whether the response reports this limit.
55
+
56
+ One declaration, written wherever the framework takes a dependency, or
57
+ as a decorator:
58
+
59
+ ```python
60
+ @router.get("/books")
61
+ @RateLimited("api") # below the route: the route reads what it decorates
62
+ async def list_books() -> list[Book]: ...
63
+
64
+
65
+ @router.post("/login", dependencies=[RateLimited("login", key=by_username)])
66
+ async def login() -> None: ...
67
+
68
+
69
+ reports = RateLimited("reports", expose_headers=True)(APIRouter(prefix="/reports"))
70
+ app.include_router(admin, dependencies=[RateLimited("admin")])
71
+ app = FastAPI(dependencies=[RateLimited("global")])
72
+ ```
73
+
74
+ The limit runs after routing and before the endpoint, in the order the
75
+ framework resolves dependencies: the application's, the routers', then
76
+ the route's. A request the limiter refuses is answered ``429 Too Many
77
+ Requests`` with ``Retry-After``, and a
78
+ :class:`~xtr_rate_limiter.RateLimitExceededEvent` is dispatched first.
79
+ Limits consulted before a refusal keep their spend.
80
+
81
+ With ``expose_headers``, the response carries ``X-RateLimit-Limit``,
82
+ ``X-RateLimit-Remaining`` and ``X-RateLimit-Reset`` for the limit closest
83
+ to refusing among those exposing theirs; a refusing limit always speaks,
84
+ and one that does not expose its state leaves the response without them.
85
+ """
86
+
87
+ # Set through object.__setattr__ in __init__: the base is a frozen dataclass.
88
+ limiter: str # pyright: ignore[reportUninitializedInstanceVariable]
89
+ key: RateLimitKey | None # pyright: ignore[reportUninitializedInstanceVariable]
90
+ tokens: int # pyright: ignore[reportUninitializedInstanceVariable]
91
+ methods: frozenset[str] # pyright: ignore[reportUninitializedInstanceVariable]
92
+ expose_headers: bool # pyright: ignore[reportUninitializedInstanceVariable]
93
+
94
+ def __init__(
95
+ self,
96
+ limiter: str,
97
+ *,
98
+ key: RateLimitKey | None = None,
99
+ tokens: int = 1,
100
+ methods: str | Iterable[str] = (),
101
+ expose_headers: bool = False,
102
+ ) -> None:
103
+ """Limit requests through the configured limiter ``limiter``.
104
+
105
+ Args:
106
+ limiter: The name the limiter is configured under.
107
+ key: What the request is counted under — a string, or a
108
+ function of the request returning one, awaited when it
109
+ returns an awaitable. By default the client's address, the
110
+ method and the route's path template — ``/books/{isbn}``,
111
+ so every book shares one count.
112
+ tokens: The tokens one request consumes.
113
+ methods: The methods limited; every method when empty. ``GET``
114
+ also limits ``HEAD``.
115
+ expose_headers: Report this limit in ``X-RateLimit-*`` headers.
116
+
117
+ Raises:
118
+ InvalidRateLimitError: When ``tokens`` is below one.
119
+ """
120
+ if tokens < 1:
121
+ raise InvalidRateLimitError(
122
+ f"A rate limit must consume at least one token, got {tokens}.",
123
+ )
124
+ normalized = {methods.upper()} if isinstance(methods, str) else {m.upper() for m in methods}
125
+ if "GET" in normalized:
126
+ normalized.add("HEAD")
127
+ # Every declaration runs on its own: two limits sharing a limiter both count.
128
+ super().__init__(dependency=self._enforce, use_cache=False)
129
+ # The framework's dependency marker is a frozen dataclass.
130
+ object.__setattr__(self, "limiter", limiter)
131
+ object.__setattr__(self, "key", key)
132
+ object.__setattr__(self, "tokens", tokens)
133
+ object.__setattr__(self, "methods", frozenset(normalized))
134
+ object.__setattr__(self, "expose_headers", expose_headers)
135
+
136
+ @overload
137
+ def __call__(self, target: APIRouter, /) -> APIRouter: ...
138
+
139
+ @overload
140
+ def __call__(self, target: Callable[_P, _R], /) -> Callable[_P, _R]: ...
141
+
142
+ def __call__(self, target: APIRouter | Callable[_P, _R], /) -> APIRouter | Callable[_P, _R]:
143
+ """Apply this limit to every route of ``target``, or to the endpoint ``target``.
144
+
145
+ A router must have no route yet: the framework copies a router's
146
+ dependencies into each route as the route is added. An endpoint must
147
+ be decorated before the route is declared — below its route
148
+ decorator — since the route reads the endpoint's signature then.
149
+
150
+ Raises:
151
+ InvalidRateLimitError: When ``target`` is a router that already
152
+ has routes.
153
+ """
154
+ if isinstance(target, APIRouter):
155
+ if target.routes:
156
+ raise InvalidRateLimitError(
157
+ "Rate limit a router before adding its routes, or give the limit to "
158
+ "include_router(..., dependencies=[...]).",
159
+ )
160
+ target.dependencies.append(self)
161
+ return target
162
+ return self._decorate(target)
163
+
164
+ def _decorate(self, endpoint: Callable[_P, _R]) -> Callable[_P, _R]:
165
+ """Return ``endpoint`` taking this limit as a hidden dependency.
166
+
167
+ The wrapper's signature is the endpoint's with one keyword-only
168
+ parameter more, defaulting to this limit — which the framework reads
169
+ as a dependency, and keeps out of the published schema. The wrapper
170
+ drops it before calling the endpoint.
171
+ """
172
+ signature = inspect.signature(endpoint)
173
+ taken = set(signature.parameters)
174
+ index = 0
175
+ while f"{_HIDDEN_PREFIX}{index}" in taken:
176
+ index += 1
177
+ name = f"{_HIDDEN_PREFIX}{index}"
178
+
179
+ parameters = list(signature.parameters.values())
180
+ keywords = [p for p in parameters if p.kind is inspect.Parameter.VAR_KEYWORD]
181
+ others = [p for p in parameters if p.kind is not inspect.Parameter.VAR_KEYWORD]
182
+ hidden = inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=self)
183
+ extended = signature.replace(parameters=[*others, hidden, *keywords])
184
+
185
+ if inspect.iscoroutinefunction(endpoint):
186
+ call = cast("Callable[_P, Awaitable[object]]", endpoint)
187
+
188
+ @functools.wraps(endpoint)
189
+ async def asynchronous(*args: _P.args, **kwargs: _P.kwargs) -> object:
190
+ _ = kwargs.pop(name, None)
191
+ return await call(*args, **kwargs)
192
+
193
+ wrapper = cast("Callable[_P, _R]", asynchronous)
194
+ else:
195
+
196
+ @functools.wraps(endpoint)
197
+ def synchronous(*args: _P.args, **kwargs: _P.kwargs) -> _R:
198
+ _ = kwargs.pop(name, None)
199
+ return endpoint(*args, **kwargs)
200
+
201
+ wrapper = synchronous
202
+ # The framework reads the signature from here; functions take any attribute.
203
+ wrapper.__signature__ = extended # pyright: ignore[reportAttributeAccessIssue] # ty: ignore[unresolved-attribute]
204
+ return wrapper
205
+
206
+ async def _enforce(self, request: Request) -> None:
207
+ """Consume this limit for ``request``, refusing it with 429 when the limiter does.
208
+
209
+ Raises:
210
+ UnknownRateLimiterError: When no limiter is configured under the
211
+ name, or no kernel serves the request.
212
+ TooManyRequestsError: When the limiter refuses the request.
213
+ """
214
+ if self.methods and request.method not in self.methods:
215
+ return
216
+ container = current_unit_of_work()
217
+ if container is None or not container.has(RateLimiterFactoryInterface, self.limiter):
218
+ raise UnknownRateLimiterError(self.limiter, await _configured(container))
219
+
220
+ key = await self._key_of(request)
221
+ factory = await container.get(RateLimiterFactoryInterface, self.limiter)
222
+ rate_limit = await factory.create(key).consume(self.tokens)
223
+ candidate = (
224
+ AppliedRateLimit(rate_limit, self.tokens)
225
+ if self.expose_headers and rate_limit.reset_at is not None
226
+ else None
227
+ )
228
+
229
+ if not rate_limit.is_accepted():
230
+ # A refusing limit speaks for the response, even to say nothing.
231
+ setattr(request.state, STATE_KEY, candidate)
232
+ if container.has(EventDispatcherInterface):
233
+ dispatcher = await container.get(EventDispatcherInterface)
234
+ _ = await dispatcher.dispatch(
235
+ RateLimitExceededEvent(rate_limit, self.limiter, key),
236
+ )
237
+ raise TooManyRequestsError(rate_limit, self.limiter, key, Clock().now().timestamp())
238
+
239
+ applied = applied_rate_limit(request)
240
+ if candidate is not None and (
241
+ applied is None or candidate.remaining_calls < applied.remaining_calls
242
+ ):
243
+ setattr(request.state, STATE_KEY, candidate)
244
+
245
+ async def _key_of(self, request: Request) -> str:
246
+ """Return what ``request`` is counted under.
247
+
248
+ Raises:
249
+ InvalidRateLimitError: When the key function returns anything
250
+ but a string.
251
+ """
252
+ if self.key is None:
253
+ client = request.client.host if request.client is not None else "unknown"
254
+ # The route's template, not the path: /books/1 and /books/2 share one count, so
255
+ # varying a path parameter never earns a fresh limit.
256
+ route: object = request.scope.get("route")
257
+ path = getattr(route, "path_format", None)
258
+ return (
259
+ f"{client}~{request.method}~{path if isinstance(path, str) else request.url.path}"
260
+ )
261
+ if isinstance(self.key, str):
262
+ return self.key
263
+ key: object = self.key(request)
264
+ if inspect.isawaitable(key):
265
+ key = await key
266
+ if not isinstance(key, str): # pyright: ignore[reportUnnecessaryIsInstance] -- the application's function; its annotation is not enforced
267
+ raise InvalidRateLimitError( # pyright: ignore[reportUnreachable] -- as above
268
+ f'The key of the "{self.limiter}" rate limit must be a string, got {key!r}.',
269
+ )
270
+ return key
271
+
272
+
273
+ async def _configured(container: ContainerInterface | None) -> tuple[str, ...]:
274
+ """Return the names the application configured limiters under; none without a kernel."""
275
+ if container is None:
276
+ return ()
277
+ # The bundle's configuration names every limiter; only its bundle registers it.
278
+ from xtr_rate_limiter.bundle import RateLimiterConfig # noqa: PLC0415
279
+
280
+ if not container.has(RateLimiterConfig):
281
+ return ()
282
+ config = await container.get(RateLimiterConfig)
283
+ return tuple(sorted(config.limiters))
@@ -175,7 +175,13 @@ class RequestLifecycleMiddleware:
175
175
  exception_event = ExceptionEvent(lifecycle.request, error)
176
176
  _ = await self._dispatcher.dispatch(exception_event)
177
177
  answer = exception_event.response
178
- if answer is not None and not lifecycle.response_started:
178
+ # Only a failure is answered: a cancellation, an interrupt or an exit goes on
179
+ # as it arrived, or timeouts, cancelled tasks and shutdowns would stop working.
180
+ if (
181
+ answer is not None
182
+ and not lifecycle.response_started
183
+ and isinstance(error, Exception)
184
+ ):
179
185
  await answer(scope, receive, lifecycle.send)
180
186
  return
181
187
  await lifecycle.finish()
File without changes