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.
- xtr_http_kernel-1.4.0/README.md → xtr_http_kernel-2.0.0/PKG-INFO +124 -6
- xtr_http_kernel-1.4.0/PKG-INFO → xtr_http_kernel-2.0.0/README.md +90 -37
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/pyproject.toml +18 -12
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/pyproject.toml.orig +17 -11
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +11 -1
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py +6 -4
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +1 -1
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +52 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/exception/__init__.py +12 -1
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/invalid_argument_error.py +25 -0
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +26 -0
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/too_many_requests_error.py +50 -0
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +34 -0
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/rate_limiter/__init__.py +11 -0
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +40 -0
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/rate_limiter/rate_limited.py +283 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py +7 -1
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/LICENSE +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/__init__.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/_state.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/__init__.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/__init__.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/_route_contexts.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/debug_router_command.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/route_description.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/router_command.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/router_match_command.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/__init__.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/exception_event.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/request_event.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/response_event.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/terminate_event.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
- {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
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/request_id_listener.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/kernel_events.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/middleware_stack.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/middleware_tag.py +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/py.typed +0 -0
- {xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/setup.py +0 -0
- {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
|
|
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)
|
|
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
|
|
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)
|
|
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 = "
|
|
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]>=
|
|
31
|
-
"xtr-event-dispatcher>=
|
|
32
|
-
"xtr-event-dispatcher-contracts>=
|
|
33
|
-
"xtr-logging-contracts>=
|
|
34
|
-
"xtr-service-contracts>=
|
|
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 = "
|
|
40
|
-
email = "
|
|
39
|
+
name = "Xterr"
|
|
40
|
+
email = "me@xterr.dev"
|
|
41
41
|
|
|
42
42
|
[project.optional-dependencies]
|
|
43
|
-
logging = ["xtr-logging>=
|
|
44
|
-
console = ["xtr-console>=
|
|
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>=
|
|
58
|
-
"xtr-console>=
|
|
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 = "
|
|
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 = "
|
|
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]>=
|
|
30
|
-
"xtr-event-dispatcher>=
|
|
31
|
-
"xtr-event-dispatcher-contracts>=
|
|
32
|
-
"xtr-logging-contracts>=
|
|
33
|
-
"xtr-service-contracts>=
|
|
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>=
|
|
39
|
+
"xtr-logging>=2.0,<3",
|
|
40
40
|
]
|
|
41
41
|
console = [
|
|
42
|
-
"xtr-console>=
|
|
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>=
|
|
59
|
-
"xtr-console>=
|
|
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",
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py
RENAMED
|
@@ -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
|
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
56
|
+
raise InvalidArgumentError(message)
|
|
55
57
|
if not self.log_channel:
|
|
56
58
|
message = "log_channel must not be empty"
|
|
57
|
-
raise
|
|
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
|
|
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__ = [
|
|
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))
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/_route_contexts.py
RENAMED
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/debug_router_command.py
RENAMED
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/route_description.py
RENAMED
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/router_command.py
RENAMED
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/command/router_match_command.py
RENAMED
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/exception_event.py
RENAMED
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/finish_request_event.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event/terminate_event.py
RENAMED
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/event_listener/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtr_http_kernel-1.4.0 → xtr_http_kernel-2.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|