usagerail 0.1.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.
- usagerail-0.1.0/.gitignore +40 -0
- usagerail-0.1.0/LICENSE +21 -0
- usagerail-0.1.0/PKG-INFO +107 -0
- usagerail-0.1.0/README.md +91 -0
- usagerail-0.1.0/pyproject.toml +61 -0
- usagerail-0.1.0/src/usagerail/__init__.py +43 -0
- usagerail-0.1.0/src/usagerail/_adapters.py +14 -0
- usagerail-0.1.0/src/usagerail/_futures.py +100 -0
- usagerail-0.1.0/src/usagerail/_time.py +24 -0
- usagerail-0.1.0/src/usagerail/clock.py +95 -0
- usagerail-0.1.0/src/usagerail/django.py +152 -0
- usagerail-0.1.0/src/usagerail/engine.py +1044 -0
- usagerail-0.1.0/src/usagerail/fastapi.py +275 -0
- usagerail-0.1.0/src/usagerail/flask.py +142 -0
- usagerail-0.1.0/src/usagerail/key_format.py +99 -0
- usagerail-0.1.0/src/usagerail/key_store.py +332 -0
- usagerail-0.1.0/src/usagerail/meter.py +592 -0
- usagerail-0.1.0/src/usagerail/protocol.py +72 -0
- usagerail-0.1.0/src/usagerail/py.typed +0 -0
- usagerail-0.1.0/src/usagerail/quota.py +704 -0
- usagerail-0.1.0/src/usagerail/rate_limiter.py +125 -0
- usagerail-0.1.0/src/usagerail/routes.py +111 -0
- usagerail-0.1.0/src/usagerail/snapshot.py +85 -0
- usagerail-0.1.0/src/usagerail/transport.py +279 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 密钥和本地环境:永远不提交
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.pem
|
|
6
|
+
*.key
|
|
7
|
+
secrets/
|
|
8
|
+
|
|
9
|
+
# 依赖和构建产物
|
|
10
|
+
node_modules/
|
|
11
|
+
.pnpm-store/
|
|
12
|
+
.next/
|
|
13
|
+
out/
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
coverage/
|
|
17
|
+
*.tsbuildinfo
|
|
18
|
+
.turbo/
|
|
19
|
+
.vercel/
|
|
20
|
+
|
|
21
|
+
# Python
|
|
22
|
+
__pycache__/
|
|
23
|
+
*.py[cod]
|
|
24
|
+
.venv/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
.mypy_cache/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
|
|
30
|
+
# 交叉审查的原始输出(本地留档)
|
|
31
|
+
.review/
|
|
32
|
+
|
|
33
|
+
# 系统和编辑器
|
|
34
|
+
.DS_Store
|
|
35
|
+
*.log
|
|
36
|
+
.idea/
|
|
37
|
+
.vscode/
|
|
38
|
+
|
|
39
|
+
# Stripe 沙盒每次运行建的对象 ID(清理用)
|
|
40
|
+
.stripe-runs/
|
usagerail-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UsageRail
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
usagerail-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: usagerail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: UsageRail SDK for Python: API keys, rate limits, quotas and usage metering inside your API server
|
|
5
|
+
Project-URL: Homepage, https://usagerail.com
|
|
6
|
+
Project-URL: Documentation, https://usagerail.com/docs
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# usagerail
|
|
18
|
+
|
|
19
|
+
The UsageRail SDK for Python. It checks API keys, applies rate limits and quotas, and counts usage per customer inside your own process, then reports the counts to UsageRail in the background. Requests never pass through us.
|
|
20
|
+
|
|
21
|
+
It needs Python 3.10 or newer and has no dependencies of its own. Versions start at 0.x, so names may still change between minor versions.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install usagerail
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Get the app key from the console at https://app.usagerail.com. Setup for each framework, options and known limitations are in the docs at https://usagerail.com/docs.
|
|
28
|
+
|
|
29
|
+
## FastAPI and Starlette
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import os
|
|
33
|
+
|
|
34
|
+
from fastapi import FastAPI, Request
|
|
35
|
+
from usagerail.fastapi import UsageRailMiddleware
|
|
36
|
+
|
|
37
|
+
app = FastAPI()
|
|
38
|
+
app.add_middleware(UsageRailMiddleware, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.post("/v1/generate")
|
|
42
|
+
async def generate(request: Request):
|
|
43
|
+
result = await run_model(await request.json())
|
|
44
|
+
request.state.usagerail.track(result.tokens) # optional: count tokens instead of requests
|
|
45
|
+
return result.body
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Flask
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import os
|
|
52
|
+
|
|
53
|
+
from flask import Flask, g, request
|
|
54
|
+
from usagerail.flask import UsageRail
|
|
55
|
+
|
|
56
|
+
app = Flask(__name__)
|
|
57
|
+
UsageRail(app, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.post("/v1/generate")
|
|
61
|
+
def generate():
|
|
62
|
+
result = run_model(request.json)
|
|
63
|
+
g.usagerail.track(result.tokens)
|
|
64
|
+
return result.body
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Django
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# settings.py
|
|
71
|
+
MIDDLEWARE = [
|
|
72
|
+
# ...
|
|
73
|
+
"usagerail.django.UsageRailMiddleware",
|
|
74
|
+
]
|
|
75
|
+
USAGERAIL = {"app_key": os.environ["USAGERAIL_APP_KEY"], "auth": "api-key"}
|
|
76
|
+
|
|
77
|
+
# views.py
|
|
78
|
+
def generate(request):
|
|
79
|
+
result = run_model(request.body)
|
|
80
|
+
request.usagerail.track(result.tokens)
|
|
81
|
+
return JsonResponse(result.body)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Your own authentication
|
|
85
|
+
|
|
86
|
+
If your API already has its own keys or logins, pass `auth="custom"` and a `consumer` function. It gets the framework's request and returns your customer's ID, or `None` for anonymous traffic. Return an identity your own code has verified, never a value the caller can set, such as a plain header:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
app.add_middleware(
|
|
90
|
+
UsageRailMiddleware,
|
|
91
|
+
app_key=os.environ["USAGERAIL_APP_KEY"],
|
|
92
|
+
auth="custom",
|
|
93
|
+
consumer=lambda request: request.user.identity if request.user.is_authenticated else None,
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The function runs before your route handlers. In FastAPI that is before `Depends(...)`, so the identity has to come from an authentication middleware that runs first. FastAPI runs the middleware added last first, so call `app.add_middleware()` for your authentication after the line that adds UsageRail. In Django, list UsageRail after `AuthenticationMiddleware` if you want `request.user`. In Flask, register your own `before_request` hook first.
|
|
98
|
+
|
|
99
|
+
## Good to know before you deploy
|
|
100
|
+
|
|
101
|
+
- Rejected requests get 401 (unknown or revoked key), 403 (suspended customer), 429 (rate limit or quota), 402 (prepaid credit used up) or 503 (UsageRail unreachable and you chose to refuse). The body is `{"error": "...", "retry_after": 30}`. Pass `on_reject` to send your own response instead.
|
|
102
|
+
- Counts are sent every 60 seconds and once more when the app shuts down. Under uvicorn, FastAPI's lifespan shutdown sends them. Flask and Django send them when the process exits normally, which gunicorn does on SIGTERM; uvicorn re-raises SIGTERM after shutting down, so Django under uvicorn loses the counts it hadn't sent. If a process is killed hard (`kill -9`, out of memory), up to about 10 minutes of unsent counts are lost and never billed.
|
|
103
|
+
- With `gunicorn --preload`, each worker becomes a separate SDK instance after the fork and keeps the key list it inherited.
|
|
104
|
+
- The key list is cached in a private folder in the system temp directory, so a restart doesn't wait for the first download. Pass `snapshot=False` to turn this off.
|
|
105
|
+
- WebSocket connections are not checked or counted.
|
|
106
|
+
- Memory: around 15 MB with 10,000 keys and customers, and at most about 85 MB at the limits: 100,000 keys, 100,000 customers' settings, and the 40,000 customers used most recently. Customers beyond that are fetched again when they come back, which adds one round trip to their next request.
|
|
107
|
+
- Other frameworks can use `usagerail.create_engine()` directly: call `begin()` when a request arrives and `end()` when it finishes.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# usagerail
|
|
2
|
+
|
|
3
|
+
The UsageRail SDK for Python. It checks API keys, applies rate limits and quotas, and counts usage per customer inside your own process, then reports the counts to UsageRail in the background. Requests never pass through us.
|
|
4
|
+
|
|
5
|
+
It needs Python 3.10 or newer and has no dependencies of its own. Versions start at 0.x, so names may still change between minor versions.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install usagerail
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Get the app key from the console at https://app.usagerail.com. Setup for each framework, options and known limitations are in the docs at https://usagerail.com/docs.
|
|
12
|
+
|
|
13
|
+
## FastAPI and Starlette
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI, Request
|
|
19
|
+
from usagerail.fastapi import UsageRailMiddleware
|
|
20
|
+
|
|
21
|
+
app = FastAPI()
|
|
22
|
+
app.add_middleware(UsageRailMiddleware, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.post("/v1/generate")
|
|
26
|
+
async def generate(request: Request):
|
|
27
|
+
result = await run_model(await request.json())
|
|
28
|
+
request.state.usagerail.track(result.tokens) # optional: count tokens instead of requests
|
|
29
|
+
return result.body
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Flask
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
|
|
37
|
+
from flask import Flask, g, request
|
|
38
|
+
from usagerail.flask import UsageRail
|
|
39
|
+
|
|
40
|
+
app = Flask(__name__)
|
|
41
|
+
UsageRail(app, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.post("/v1/generate")
|
|
45
|
+
def generate():
|
|
46
|
+
result = run_model(request.json)
|
|
47
|
+
g.usagerail.track(result.tokens)
|
|
48
|
+
return result.body
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Django
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# settings.py
|
|
55
|
+
MIDDLEWARE = [
|
|
56
|
+
# ...
|
|
57
|
+
"usagerail.django.UsageRailMiddleware",
|
|
58
|
+
]
|
|
59
|
+
USAGERAIL = {"app_key": os.environ["USAGERAIL_APP_KEY"], "auth": "api-key"}
|
|
60
|
+
|
|
61
|
+
# views.py
|
|
62
|
+
def generate(request):
|
|
63
|
+
result = run_model(request.body)
|
|
64
|
+
request.usagerail.track(result.tokens)
|
|
65
|
+
return JsonResponse(result.body)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Your own authentication
|
|
69
|
+
|
|
70
|
+
If your API already has its own keys or logins, pass `auth="custom"` and a `consumer` function. It gets the framework's request and returns your customer's ID, or `None` for anonymous traffic. Return an identity your own code has verified, never a value the caller can set, such as a plain header:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
UsageRailMiddleware,
|
|
75
|
+
app_key=os.environ["USAGERAIL_APP_KEY"],
|
|
76
|
+
auth="custom",
|
|
77
|
+
consumer=lambda request: request.user.identity if request.user.is_authenticated else None,
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The function runs before your route handlers. In FastAPI that is before `Depends(...)`, so the identity has to come from an authentication middleware that runs first. FastAPI runs the middleware added last first, so call `app.add_middleware()` for your authentication after the line that adds UsageRail. In Django, list UsageRail after `AuthenticationMiddleware` if you want `request.user`. In Flask, register your own `before_request` hook first.
|
|
82
|
+
|
|
83
|
+
## Good to know before you deploy
|
|
84
|
+
|
|
85
|
+
- Rejected requests get 401 (unknown or revoked key), 403 (suspended customer), 429 (rate limit or quota), 402 (prepaid credit used up) or 503 (UsageRail unreachable and you chose to refuse). The body is `{"error": "...", "retry_after": 30}`. Pass `on_reject` to send your own response instead.
|
|
86
|
+
- Counts are sent every 60 seconds and once more when the app shuts down. Under uvicorn, FastAPI's lifespan shutdown sends them. Flask and Django send them when the process exits normally, which gunicorn does on SIGTERM; uvicorn re-raises SIGTERM after shutting down, so Django under uvicorn loses the counts it hadn't sent. If a process is killed hard (`kill -9`, out of memory), up to about 10 minutes of unsent counts are lost and never billed.
|
|
87
|
+
- With `gunicorn --preload`, each worker becomes a separate SDK instance after the fork and keeps the key list it inherited.
|
|
88
|
+
- The key list is cached in a private folder in the system temp directory, so a restart doesn't wait for the first download. Pass `snapshot=False` to turn this off.
|
|
89
|
+
- WebSocket connections are not checked or counted.
|
|
90
|
+
- Memory: around 15 MB with 10,000 keys and customers, and at most about 85 MB at the limits: 100,000 keys, 100,000 customers' settings, and the 40,000 customers used most recently. Customers beyond that are fetched again when they come back, which adds one round trip to their next request.
|
|
91
|
+
- Other frameworks can use `usagerail.create_engine()` directly: call `begin()` when a request arrives and `end()` when it finishes.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.32.4"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "usagerail"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "UsageRail SDK for Python: API keys, rate limits, quotas and usage metering inside your API server"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
# 运行时不依赖任何第三方包:适配器只在用到时才导入对应的框架
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Framework :: Django",
|
|
16
|
+
"Framework :: FastAPI",
|
|
17
|
+
"Framework :: Flask",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://usagerail.com"
|
|
24
|
+
Documentation = "https://usagerail.com/docs"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest==9.1.1",
|
|
29
|
+
"mypy==2.4.0",
|
|
30
|
+
"fastapi==0.142.2",
|
|
31
|
+
"starlette==1.7.0",
|
|
32
|
+
# Starlette 1.2 起 TestClient 用 httpx2,用 httpx 会警告
|
|
33
|
+
"httpx2==2.13.1",
|
|
34
|
+
"flask==3.1.3",
|
|
35
|
+
"werkzeug==3.1.9",
|
|
36
|
+
"django==6.1.1; python_version >= '3.12'",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.uv]
|
|
40
|
+
# 只装发布满两周的版本(和 pnpm 的 minimumReleaseAge 同一条规矩),间接依赖也算
|
|
41
|
+
exclude-newer = "14 days"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/usagerail"]
|
|
45
|
+
|
|
46
|
+
# sdist 和 Node 包一样只放装包用得到的:实现笔记(NOTES.md)、测试、基准不发出去(D47)。
|
|
47
|
+
# pyproject.toml、README、LICENSE hatchling 会自己带上
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["src/usagerail"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
|
|
54
|
+
[tool.mypy]
|
|
55
|
+
files = ["src"]
|
|
56
|
+
python_version = "3.10"
|
|
57
|
+
strict = true
|
|
58
|
+
# Django 不带类型标注(要另装 django-stubs),只查我们自己的代码
|
|
59
|
+
[[tool.mypy.overrides]]
|
|
60
|
+
module = ["django.*"]
|
|
61
|
+
ignore_missing_imports = true
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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__()
|
|
@@ -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")
|
|
@@ -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()
|