usagerail 0.1.0__py3-none-any.whl
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.
- usagerail/__init__.py +43 -0
- usagerail/_adapters.py +14 -0
- usagerail/_futures.py +100 -0
- usagerail/_time.py +24 -0
- usagerail/clock.py +95 -0
- usagerail/django.py +152 -0
- usagerail/engine.py +1044 -0
- usagerail/fastapi.py +275 -0
- usagerail/flask.py +142 -0
- usagerail/key_format.py +99 -0
- usagerail/key_store.py +332 -0
- usagerail/meter.py +592 -0
- usagerail/protocol.py +72 -0
- usagerail/py.typed +0 -0
- usagerail/quota.py +704 -0
- usagerail/rate_limiter.py +125 -0
- usagerail/routes.py +111 -0
- usagerail/snapshot.py +85 -0
- usagerail/transport.py +279 -0
- usagerail-0.1.0.dist-info/METADATA +107 -0
- usagerail-0.1.0.dist-info/RECORD +23 -0
- usagerail-0.1.0.dist-info/WHEEL +4 -0
- usagerail-0.1.0.dist-info/licenses/LICENSE +21 -0
usagerail/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""UsageRail 的 Python SDK:在你自己的 API 服务里发 key、限流、管额度、计量(PRD 5.1)。
|
|
2
|
+
|
|
3
|
+
FastAPI / Starlette 用 usagerail.asgi,Flask 用 usagerail.flask,Django 用 usagerail.django。
|
|
4
|
+
自己接别的框架时用 create_engine:begin() 拿到结论,请求结束调 end()。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .engine import (
|
|
8
|
+
MAX_TRACK_UNITS,
|
|
9
|
+
Admission,
|
|
10
|
+
ConsumerIdentity,
|
|
11
|
+
Decision,
|
|
12
|
+
Engine,
|
|
13
|
+
Pending,
|
|
14
|
+
Rejection,
|
|
15
|
+
RejectionCode,
|
|
16
|
+
RequestHandle,
|
|
17
|
+
create_engine,
|
|
18
|
+
read_api_key,
|
|
19
|
+
rejection_body,
|
|
20
|
+
)
|
|
21
|
+
from .key_store import SnapshotStore
|
|
22
|
+
from .snapshot import FileSnapshotStore
|
|
23
|
+
from .transport import HttpTransport, Transport, TransportError
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"MAX_TRACK_UNITS",
|
|
27
|
+
"Admission",
|
|
28
|
+
"ConsumerIdentity",
|
|
29
|
+
"Decision",
|
|
30
|
+
"Engine",
|
|
31
|
+
"FileSnapshotStore",
|
|
32
|
+
"HttpTransport",
|
|
33
|
+
"Pending",
|
|
34
|
+
"Rejection",
|
|
35
|
+
"RejectionCode",
|
|
36
|
+
"RequestHandle",
|
|
37
|
+
"SnapshotStore",
|
|
38
|
+
"Transport",
|
|
39
|
+
"TransportError",
|
|
40
|
+
"create_engine",
|
|
41
|
+
"read_api_key",
|
|
42
|
+
"rejection_body",
|
|
43
|
+
]
|
usagerail/_adapters.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""三个框架适配器共用的一点东西。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .engine import Engine, create_engine
|
|
8
|
+
from .snapshot import FileSnapshotStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def engine_for(app_key: str, auth: str, snapshot: bool, options: dict[str, Any]) -> Engine[Any]:
|
|
12
|
+
"""key 快照只对用我们 key 的应用有意义,默认落盘到本地临时目录(PRD 5.3)"""
|
|
13
|
+
store = FileSnapshotStore(app_key) if snapshot and auth == "api-key" and app_key else None
|
|
14
|
+
return create_engine(app_key=app_key, auth=auth, snapshot_store=store, **options)
|
usagerail/_futures.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""把 concurrent.futures.Future 当成 Node 的 Promise 用的几个小工具。
|
|
2
|
+
|
|
3
|
+
引擎里走网络的地方(远程校验、领租约、同步、上报)都返回 Future:Flask、Django 的线程里阻塞等它,
|
|
4
|
+
FastAPI 里 await 它。等的时候不占线程,几百个请求同时等租约也不会把线程池耗光(PRD「最多多等 300 毫秒」)。
|
|
5
|
+
回调在完成它的那个线程里跑,已经完成的 Future 加回调会当场跑,所以引擎的状态一律在锁里改。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from concurrent.futures import Future, InvalidStateError
|
|
12
|
+
from typing import Any, Callable, Generator, Generic, TypeVar
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
U = TypeVar("U")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolved(value: T) -> Future[T]:
|
|
19
|
+
future: Future[T] = Future()
|
|
20
|
+
future.set_result(value)
|
|
21
|
+
return future
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def settle(future: Future[T], value: T) -> None:
|
|
25
|
+
"""已经有结果的不再改(超时和完成同时到时,先到的算)"""
|
|
26
|
+
try:
|
|
27
|
+
future.set_result(value)
|
|
28
|
+
except InvalidStateError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fail(future: Future[Any], error: BaseException) -> None:
|
|
33
|
+
try:
|
|
34
|
+
future.set_exception(error)
|
|
35
|
+
except InvalidStateError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def error_of(future: Future[Any]) -> BaseException | None:
|
|
40
|
+
"""已经完成的 Future 的异常;取消的当作出错"""
|
|
41
|
+
if future.cancelled():
|
|
42
|
+
return RuntimeError("cancelled")
|
|
43
|
+
return future.exception()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def then(
|
|
47
|
+
future: Future[T],
|
|
48
|
+
on_value: Callable[[T], U | Future[U]],
|
|
49
|
+
on_error: Callable[[BaseException], U | Future[U]] | None = None,
|
|
50
|
+
) -> Future[U]:
|
|
51
|
+
"""Promise.then:回调可以返回值,也可以返回 Future(会展开)"""
|
|
52
|
+
out: Future[U] = Future()
|
|
53
|
+
|
|
54
|
+
def run(callback: Callable[[Any], U | Future[U]], argument: Any) -> None:
|
|
55
|
+
try:
|
|
56
|
+
result = callback(argument)
|
|
57
|
+
except BaseException as error: # noqa: BLE001 - 原样交给下一环
|
|
58
|
+
fail(out, error)
|
|
59
|
+
return
|
|
60
|
+
if isinstance(result, Future):
|
|
61
|
+
result.add_done_callback(lambda inner: _copy(inner, out))
|
|
62
|
+
else:
|
|
63
|
+
settle(out, result)
|
|
64
|
+
|
|
65
|
+
def done(completed: Future[T]) -> None:
|
|
66
|
+
error = error_of(completed)
|
|
67
|
+
if error is None:
|
|
68
|
+
run(on_value, completed.result())
|
|
69
|
+
elif on_error is not None:
|
|
70
|
+
run(on_error, error)
|
|
71
|
+
else:
|
|
72
|
+
fail(out, error)
|
|
73
|
+
|
|
74
|
+
future.add_done_callback(done)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _copy(source: Future[Any], target: Future[Any]) -> None:
|
|
79
|
+
error = error_of(source)
|
|
80
|
+
if error is None:
|
|
81
|
+
settle(target, source.result())
|
|
82
|
+
else:
|
|
83
|
+
fail(target, error)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Pending(Generic[T]):
|
|
87
|
+
"""还要等服务端的结论。WSGI 里 .result() 阻塞等;ASGI 里直接 await"""
|
|
88
|
+
|
|
89
|
+
__slots__ = ("future",)
|
|
90
|
+
|
|
91
|
+
def __init__(self, future: Future[T]) -> None:
|
|
92
|
+
self.future = future
|
|
93
|
+
|
|
94
|
+
def result(self, timeout: float | None = None) -> T:
|
|
95
|
+
return self.future.result(timeout)
|
|
96
|
+
|
|
97
|
+
def __await__(self) -> Generator[Any, None, T]:
|
|
98
|
+
# shield:等的一方被取消(客户端走了)不能连带取消底下的 Future。引擎还会给它结论(比如领到的租约),
|
|
99
|
+
# 取消了结论就没处放,预留一直挂着;调用方自己决定被取消以后怎么收尾
|
|
100
|
+
return asyncio.shield(asyncio.wrap_future(self.future)).__await__()
|
usagerail/_time.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""ISO 8601 时间和毫秒时间戳之间的换算,格式和 JavaScript 的 Date 一致(服务端和 Node SDK 用的就是它)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_iso_ms(value: object) -> float | None:
|
|
9
|
+
"""读不懂的返回 None。没带时区的按 UTC 算(服务端总是带 Z)"""
|
|
10
|
+
if not isinstance(value, str) or not value:
|
|
11
|
+
return None
|
|
12
|
+
text = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
|
|
13
|
+
try:
|
|
14
|
+
parsed = datetime.fromisoformat(text)
|
|
15
|
+
except ValueError:
|
|
16
|
+
return None
|
|
17
|
+
if parsed.tzinfo is None:
|
|
18
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
19
|
+
return parsed.timestamp() * 1000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def iso_from_ms(ms: float) -> str:
|
|
23
|
+
"""2026-10-20T12:00:10.000Z,和 new Date(ms).toISOString() 一样"""
|
|
24
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
usagerail/clock.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""时钟。SDK 里所有「现在」都从这里取,测试注入假时钟,不用真的等(docs/decisions.md D4)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
import itertools
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Callable, Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Clock(Protocol):
|
|
14
|
+
def now(self) -> float:
|
|
15
|
+
"""墙上时间(毫秒):分钟归属、key 过期、周期边界。这些要和服务端、Stripe 的时间对得上"""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def monotonic(self) -> float:
|
|
19
|
+
"""单调时间(毫秒):租约本地有效期、令牌桶、退避、各种缓存时长。机器改时间、NTP 校时都不影响(PRD 5.5)"""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def set_timer(self, callback: Callable[[], None], delay_ms: float) -> Callable[[], None]:
|
|
23
|
+
"""定时器,返回取消函数。后台的同步、上报、续租都走它,测试里由假时钟推进"""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Timer:
|
|
28
|
+
__slots__ = ("callback", "cancelled")
|
|
29
|
+
|
|
30
|
+
def __init__(self, callback: Callable[[], None]) -> None:
|
|
31
|
+
self.callback = callback
|
|
32
|
+
self.cancelled = False
|
|
33
|
+
|
|
34
|
+
def cancel(self) -> None:
|
|
35
|
+
self.cancelled = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SystemClock:
|
|
39
|
+
"""所有定时器共用一个后台线程,按到期时间排队跑,不是一个定时器一个线程:等租约的请求每个都有一个定时器。
|
|
40
|
+
线程是 daemon 的,不拖住进程退出(同 Node 的 unref)。回调要快,走网络的事交给 transport 的线程。"""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._reset()
|
|
44
|
+
# gunicorn --preload 这类先建好应用再 fork 的:子进程里没有父进程的线程,锁也可能停在被持有的状态。
|
|
45
|
+
# 子进程重新来过;父进程排着的定时器属于父进程的引擎,引擎自己会在子进程里重新排(engine.py)
|
|
46
|
+
# Windows 没有 fork,也没有这个函数
|
|
47
|
+
if hasattr(os, "register_at_fork"):
|
|
48
|
+
os.register_at_fork(after_in_child=self._reset)
|
|
49
|
+
|
|
50
|
+
def _reset(self) -> None:
|
|
51
|
+
self._condition = threading.Condition()
|
|
52
|
+
self._queue: list[tuple[float, int, _Timer]] = []
|
|
53
|
+
self._order = itertools.count()
|
|
54
|
+
self._thread: threading.Thread | None = None
|
|
55
|
+
|
|
56
|
+
def now(self) -> float:
|
|
57
|
+
return time.time() * 1000
|
|
58
|
+
|
|
59
|
+
def monotonic(self) -> float:
|
|
60
|
+
return time.monotonic() * 1000
|
|
61
|
+
|
|
62
|
+
def set_timer(self, callback: Callable[[], None], delay_ms: float) -> Callable[[], None]:
|
|
63
|
+
timer = _Timer(callback)
|
|
64
|
+
due = time.monotonic() + max(0.0, delay_ms) / 1000
|
|
65
|
+
with self._condition:
|
|
66
|
+
heapq.heappush(self._queue, (due, next(self._order), timer))
|
|
67
|
+
if self._thread is None or not self._thread.is_alive():
|
|
68
|
+
self._thread = threading.Thread(target=self._run, name="usagerail-timers", daemon=True)
|
|
69
|
+
self._thread.start()
|
|
70
|
+
self._condition.notify()
|
|
71
|
+
return timer.cancel
|
|
72
|
+
|
|
73
|
+
def _run(self) -> None:
|
|
74
|
+
while True:
|
|
75
|
+
with self._condition:
|
|
76
|
+
while True:
|
|
77
|
+
while self._queue and self._queue[0][2].cancelled:
|
|
78
|
+
heapq.heappop(self._queue)
|
|
79
|
+
if not self._queue:
|
|
80
|
+
self._condition.wait()
|
|
81
|
+
continue
|
|
82
|
+
wait = self._queue[0][0] - time.monotonic()
|
|
83
|
+
if wait <= 0:
|
|
84
|
+
timer = heapq.heappop(self._queue)[2]
|
|
85
|
+
break
|
|
86
|
+
self._condition.wait(wait)
|
|
87
|
+
if timer.cancelled:
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
timer.callback()
|
|
91
|
+
except Exception: # noqa: BLE001 - 回调出错不能让定时器线程死掉,引擎自己会打警告
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
system_clock = SystemClock()
|
usagerail/django.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Django 中间件(PRD 5.1)。settings.py 里:
|
|
2
|
+
|
|
3
|
+
MIDDLEWARE = [..., "usagerail.django.UsageRailMiddleware", ...]
|
|
4
|
+
USAGERAIL = {"app_key": os.environ["USAGERAIL_APP_KEY"], "auth": "api-key"}
|
|
5
|
+
|
|
6
|
+
视图里按自定义单位计量:request.usagerail.track(tokens)
|
|
7
|
+
跑在 uvicorn 这类 ASGI 服务器上时,asgi.py 里用 asgi() 包一层(见 asgi 的说明),不然停机时最后一批用量会丢。
|
|
8
|
+
用自己的鉴权时:"auth": "custom", "consumer": 一个函数,或者它的点分路径("myapp.usage.consumer"),收到 request 返回是谁。
|
|
9
|
+
要用 request.user 的,把这个中间件放在 AuthenticationMiddleware 后面。
|
|
10
|
+
USAGERAIL 里其余的键原样交给 Engine(on_reject 收 (rejection, request),返回一个 HttpResponse)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import threading
|
|
17
|
+
import weakref
|
|
18
|
+
from collections.abc import Awaitable, MutableMapping
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
from django.conf import settings
|
|
22
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
23
|
+
from django.urls import Resolver404, resolve
|
|
24
|
+
from django.utils.module_loading import import_string
|
|
25
|
+
|
|
26
|
+
from . import _adapters
|
|
27
|
+
from ._futures import Pending
|
|
28
|
+
from .engine import Engine, Rejection, read_api_key, rejection_body
|
|
29
|
+
|
|
30
|
+
# 一个进程一个引擎:Django 每加载一次中间件链就建一个中间件实例(测试里常常不止一次),引擎要共用
|
|
31
|
+
_engines: dict[str, Engine[HttpRequest]] = {}
|
|
32
|
+
_engines_lock = threading.Lock()
|
|
33
|
+
# 中间件实际在用的引擎(包括 USAGERAIL 里直接传进来的),asgi() 停机时逐个关
|
|
34
|
+
_middleware_engines: weakref.WeakSet[Engine[Any]] = weakref.WeakSet()
|
|
35
|
+
|
|
36
|
+
Scope = MutableMapping[str, Any]
|
|
37
|
+
Message = MutableMapping[str, Any]
|
|
38
|
+
Receive = Callable[[], Awaitable[Message]]
|
|
39
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
40
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UsageRailMiddleware:
|
|
44
|
+
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
|
45
|
+
self.get_response = get_response
|
|
46
|
+
config = dict(getattr(settings, "USAGERAIL", {}))
|
|
47
|
+
on_reject = config.pop("on_reject", None)
|
|
48
|
+
self.on_reject: Callable[[Rejection, HttpRequest], HttpResponse] | None = import_string(on_reject) if isinstance(on_reject, str) else on_reject
|
|
49
|
+
self.engine: Engine[Any] = config.pop("engine", None) or _shared_engine(config)
|
|
50
|
+
_middleware_engines.add(self.engine)
|
|
51
|
+
|
|
52
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
53
|
+
route = _template(request)
|
|
54
|
+
decision = self.engine.begin(
|
|
55
|
+
method=request.method or "GET",
|
|
56
|
+
path=request.path_info,
|
|
57
|
+
# 先按 Django 的 URL 配置解析一遍:路由模板开始时就知道,re_path 的正则模板也能精确对上卖家的配置
|
|
58
|
+
route=route,
|
|
59
|
+
api_key=read_api_key(request.headers.get("Authorization"), request.headers.get("X-Api-Key")),
|
|
60
|
+
context=request,
|
|
61
|
+
)
|
|
62
|
+
if isinstance(decision, Pending):
|
|
63
|
+
decision = decision.result()
|
|
64
|
+
if not decision.allowed:
|
|
65
|
+
return self.on_reject(decision, request) if self.on_reject else _rejection_response(decision)
|
|
66
|
+
handle = decision.handle
|
|
67
|
+
request.usagerail = handle
|
|
68
|
+
try:
|
|
69
|
+
response = self.get_response(request)
|
|
70
|
+
except BaseException:
|
|
71
|
+
# 一般走不到:Django 自己把视图的异常变成 500 响应。别的中间件抛出来的才会到这里
|
|
72
|
+
self.engine.end(handle, status=500, route=route)
|
|
73
|
+
raise
|
|
74
|
+
engine, status = self.engine, response.status_code
|
|
75
|
+
# SSE 一开始发送就算建立了一个长连接,当场计 1 次(v0.3 5.1),单位数等结束再补
|
|
76
|
+
if status < 300 and response.get("Content-Type", "").lower().startswith("text/event-stream"):
|
|
77
|
+
engine.stream_started(handle, route)
|
|
78
|
+
# 等服务器把响应发完、关掉它(流式响应发完最后一块)再结算。挂在 request.close 上:Django 把它挂进最终那个响应的
|
|
79
|
+
# 关闭回调(BaseHandler.get_response),外层的中间件把响应换掉了(CommonMiddleware 把 404 换成补斜杠的重定向)
|
|
80
|
+
# 也照样会跑。挂在这个响应自己的 close 上的话,它一被换掉就永远不跑,用量不记、预留一直挂着。
|
|
81
|
+
# 换掉的响应按这里看到的状态码记
|
|
82
|
+
close_request = request.close
|
|
83
|
+
|
|
84
|
+
def close_and_end() -> None:
|
|
85
|
+
try:
|
|
86
|
+
close_request()
|
|
87
|
+
finally:
|
|
88
|
+
engine.end(handle, status=status, route=route)
|
|
89
|
+
|
|
90
|
+
request.close = close_and_end
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _shared_engine(config: dict[str, Any]) -> Engine[HttpRequest]:
|
|
95
|
+
app_key = config.pop("app_key", "")
|
|
96
|
+
auth = config.pop("auth", "api-key")
|
|
97
|
+
snapshot = config.pop("snapshot", True)
|
|
98
|
+
consumer = config.get("consumer")
|
|
99
|
+
if isinstance(consumer, str):
|
|
100
|
+
config["consumer"] = import_string(consumer)
|
|
101
|
+
with _engines_lock:
|
|
102
|
+
engine = _engines.get(app_key)
|
|
103
|
+
if engine is None:
|
|
104
|
+
engine = _adapters.engine_for(app_key, auth, snapshot, config)
|
|
105
|
+
_engines[app_key] = engine
|
|
106
|
+
return engine
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _template(request: HttpRequest) -> str | None:
|
|
110
|
+
"""Django 的路由不带开头的斜杠,补上;没匹配到的为 None(结束时记 _unmatched)"""
|
|
111
|
+
try:
|
|
112
|
+
match = resolve(request.path_info, getattr(request, "urlconf", None))
|
|
113
|
+
except Resolver404:
|
|
114
|
+
return None
|
|
115
|
+
return "/" + match.route if match.route is not None else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _rejection_response(rejection: Rejection) -> HttpResponse:
|
|
119
|
+
response = JsonResponse(rejection_body(rejection), status=rejection.status)
|
|
120
|
+
for name, value in rejection.headers.items():
|
|
121
|
+
response[name] = value
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def asgi(application: ASGIApp) -> ASGIApp:
|
|
126
|
+
"""Django 跑在 ASGI 服务器上(uvicorn 等)时包一层,asgi.py 里:
|
|
127
|
+
|
|
128
|
+
from django.core.asgi import get_asgi_application
|
|
129
|
+
from usagerail.django import asgi
|
|
130
|
+
|
|
131
|
+
application = asgi(get_asgi_application())
|
|
132
|
+
|
|
133
|
+
Django 自己不接 lifespan,uvicorn 收完尾又会把 SIGTERM 再抛一次,进程直接结束、atexit 不跑,不包的话最后一批用量会丢。
|
|
134
|
+
包了以后由这里应答 lifespan:停机时先把剩下的用量报上去(最多等 5 秒),再告诉服务器停好了,和 FastAPI 的中间件一样。
|
|
135
|
+
gunicorn 跑 WSGI 用不着:它收到 SIGTERM 正常退出,atexit 会报"""
|
|
136
|
+
|
|
137
|
+
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
138
|
+
if scope["type"] != "lifespan":
|
|
139
|
+
await application(scope, receive, send)
|
|
140
|
+
return
|
|
141
|
+
while True:
|
|
142
|
+
message = await receive()
|
|
143
|
+
if message["type"] == "lifespan.startup":
|
|
144
|
+
await send({"type": "lifespan.startup.complete"})
|
|
145
|
+
elif message["type"] == "lifespan.shutdown":
|
|
146
|
+
# 几个引擎同时关、一起等,总共还是最多 5 秒。报不上去也要让服务器停下来,所以不管出错
|
|
147
|
+
closing = [asyncio.wrap_future(engine.close()) for engine in list(_middleware_engines)]
|
|
148
|
+
await asyncio.gather(*closing, return_exceptions=True)
|
|
149
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
return app
|