omnilimit 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.
- omnilimit-0.1.0/.coverage +0 -0
- omnilimit-0.1.0/.gitignore +12 -0
- omnilimit-0.1.0/CHANGELOG.md +8 -0
- omnilimit-0.1.0/LICENSE +21 -0
- omnilimit-0.1.0/PKG-INFO +110 -0
- omnilimit-0.1.0/README.md +81 -0
- omnilimit-0.1.0/pyproject.toml +83 -0
- omnilimit-0.1.0/src/omnilimit/__init__.py +46 -0
- omnilimit-0.1.0/src/omnilimit/_gcra.py +96 -0
- omnilimit-0.1.0/src/omnilimit/_generated.py +5 -0
- omnilimit-0.1.0/src/omnilimit/_headers.py +27 -0
- omnilimit-0.1.0/src/omnilimit/_key.py +26 -0
- omnilimit-0.1.0/src/omnilimit/_limiter.py +291 -0
- omnilimit-0.1.0/src/omnilimit/_types.py +89 -0
- omnilimit-0.1.0/src/omnilimit/fastapi.py +118 -0
- omnilimit-0.1.0/src/omnilimit/ip.py +131 -0
- omnilimit-0.1.0/src/omnilimit/py.typed +0 -0
- omnilimit-0.1.0/src/omnilimit/stores/__init__.py +1 -0
- omnilimit-0.1.0/src/omnilimit/stores/memory.py +49 -0
- omnilimit-0.1.0/src/omnilimit/stores/postgres.py +91 -0
- omnilimit-0.1.0/src/omnilimit/stores/redis.py +77 -0
- omnilimit-0.1.0/tests/conftest.py +6 -0
- omnilimit-0.1.0/tests/support.py +153 -0
- omnilimit-0.1.0/tests/test_conformance.py +250 -0
- omnilimit-0.1.0/tests/test_cross_language.py +109 -0
- omnilimit-0.1.0/tests/test_fastapi.py +158 -0
- omnilimit-0.1.0/tests/test_limiter.py +295 -0
- omnilimit-0.1.0/tests/test_stores.py +340 -0
- omnilimit-0.1.0/uv.lock +1151 -0
|
Binary file
|
omnilimit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pablo Alaniz
|
|
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.
|
omnilimit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: omnilimit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rate limiting that behaves the same across stacks, with trusted client IPs and an explicit failure mode.
|
|
5
|
+
Project-URL: Homepage, https://github.com/PabloAlaniz/omnilimit
|
|
6
|
+
Project-URL: Repository, https://github.com/PabloAlaniz/omnilimit
|
|
7
|
+
Project-URL: Specification, https://github.com/PabloAlaniz/omnilimit/blob/main/spec/SPEC.md
|
|
8
|
+
Author: Pablo Alaniz
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: fastapi,gcra,postgres,rate-limit,rate-limiting,redis,x-forwarded-for
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Provides-Extra: fastapi
|
|
23
|
+
Requires-Dist: fastapi>=0.109; extra == 'fastapi'
|
|
24
|
+
Provides-Extra: postgres
|
|
25
|
+
Requires-Dist: psycopg>=3.1; extra == 'postgres'
|
|
26
|
+
Provides-Extra: redis
|
|
27
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# omnilimit for Python
|
|
31
|
+
|
|
32
|
+
Rate limiting that behaves the same across stacks, and proves it. This port follows the same [spec](https://github.com/PabloAlaniz/omnilimit/blob/main/spec/SPEC.md) and passes the same conformance vectors as the TypeScript one, and both can share a counter in Redis or Postgres.
|
|
33
|
+
|
|
34
|
+
- **Trusted client IPs.** It reads `X-Forwarded-For` from the right. Rotating the header does not evade the limit.
|
|
35
|
+
- **An explicit failure mode.** You must decide, per limiter, what happens when the store is down.
|
|
36
|
+
- **No dependencies.** Extras only install a client: `omnilimit[redis]`, `omnilimit[postgres]`, `omnilimit[fastapi]`.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install omnilimit
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from redis import Redis
|
|
46
|
+
|
|
47
|
+
from omnilimit import Limiter
|
|
48
|
+
from omnilimit.stores.redis import RedisStore
|
|
49
|
+
|
|
50
|
+
client = Redis.from_url(url, socket_timeout=0.25, socket_connect_timeout=0.25)
|
|
51
|
+
limiter = Limiter(
|
|
52
|
+
store=RedisStore(client),
|
|
53
|
+
scope="login",
|
|
54
|
+
limit=5,
|
|
55
|
+
window_ms=60_000,
|
|
56
|
+
burst=2,
|
|
57
|
+
on_store_error="closed", # required: "open" lets requests through, "closed" answers 503
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
decision = limiter.check(user_id)
|
|
61
|
+
if not decision.allowed:
|
|
62
|
+
... # answer decision.status with decision.headers, which include Retry-After
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`5` per minute with burst 5 can admit 9 requests in one minute: this is a rate, not a counter that resets. Lower `burst` if you need fewer.
|
|
66
|
+
|
|
67
|
+
## Sync or async
|
|
68
|
+
|
|
69
|
+
| Code | Limiter | Stores |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Flask, Django, `def` dependencies | `Limiter` | `MemoryStore`, `RedisStore`, `PostgresStore` |
|
|
72
|
+
| `async def` | `AsyncLimiter` | `MemoryStore`, `AsyncRedisStore`, `AsyncPostgresStore` |
|
|
73
|
+
|
|
74
|
+
`Limiter` runs each store call in its own daemon thread, because Python cannot abandon a blocking call. `store_timeout_ms` bounds the request, and `max_store_threads` bounds the calls in flight. Set socket timeouts on your client as well, so a hung call ends.
|
|
75
|
+
|
|
76
|
+
redis-py caps its pool and raises past that cap, which `omnilimit` reports as a store failure. If you expect bursts of concurrent checks, use `BlockingConnectionPool`.
|
|
77
|
+
|
|
78
|
+
## FastAPI
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from fastapi import Depends, FastAPI
|
|
82
|
+
|
|
83
|
+
from omnilimit import MemoryStore, Limiter
|
|
84
|
+
from omnilimit.fastapi import ip_key, rate_limit
|
|
85
|
+
|
|
86
|
+
limiter = Limiter(
|
|
87
|
+
store=MemoryStore(), scope="api", limit=100, window_ms=60_000, on_store_error="open"
|
|
88
|
+
)
|
|
89
|
+
app = FastAPI(dependencies=[
|
|
90
|
+
Depends(rate_limit(limiter, key=ip_key(preset="cloud-run"), on_missing_key="shared")),
|
|
91
|
+
])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Denials raise `HTTPException`, so your own handlers format them. Headers on allowed requests are not added when a route returns its own `Response` object, as a file download usually does.
|
|
95
|
+
|
|
96
|
+
## Client IP only
|
|
97
|
+
|
|
98
|
+
`omnilimit.ip` uses only the standard library and also works next to `slowapi`:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from omnilimit.ip import resolve_client_ip
|
|
102
|
+
|
|
103
|
+
ip = resolve_client_ip(request.headers, request.client.host, preset="cloud-run-lb")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
It returns `None` rather than guessing. Check that the preset matches your deployment: see [client IP](https://github.com/PabloAlaniz/omnilimit/blob/main/docs/client-ip.md).
|
|
107
|
+
|
|
108
|
+
## Postgres
|
|
109
|
+
|
|
110
|
+
`omnilimit.stores.postgres.MIGRATION_SQL` creates the table and functions: apply it with your migration tool. Stores take a psycopg 3 pool or a connection with `autocommit=True`. Each hit is a write, so it is not meant for high traffic, and `cleanup()` is yours to schedule.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# omnilimit for Python
|
|
2
|
+
|
|
3
|
+
Rate limiting that behaves the same across stacks, and proves it. This port follows the same [spec](https://github.com/PabloAlaniz/omnilimit/blob/main/spec/SPEC.md) and passes the same conformance vectors as the TypeScript one, and both can share a counter in Redis or Postgres.
|
|
4
|
+
|
|
5
|
+
- **Trusted client IPs.** It reads `X-Forwarded-For` from the right. Rotating the header does not evade the limit.
|
|
6
|
+
- **An explicit failure mode.** You must decide, per limiter, what happens when the store is down.
|
|
7
|
+
- **No dependencies.** Extras only install a client: `omnilimit[redis]`, `omnilimit[postgres]`, `omnilimit[fastapi]`.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install omnilimit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from redis import Redis
|
|
17
|
+
|
|
18
|
+
from omnilimit import Limiter
|
|
19
|
+
from omnilimit.stores.redis import RedisStore
|
|
20
|
+
|
|
21
|
+
client = Redis.from_url(url, socket_timeout=0.25, socket_connect_timeout=0.25)
|
|
22
|
+
limiter = Limiter(
|
|
23
|
+
store=RedisStore(client),
|
|
24
|
+
scope="login",
|
|
25
|
+
limit=5,
|
|
26
|
+
window_ms=60_000,
|
|
27
|
+
burst=2,
|
|
28
|
+
on_store_error="closed", # required: "open" lets requests through, "closed" answers 503
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
decision = limiter.check(user_id)
|
|
32
|
+
if not decision.allowed:
|
|
33
|
+
... # answer decision.status with decision.headers, which include Retry-After
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`5` per minute with burst 5 can admit 9 requests in one minute: this is a rate, not a counter that resets. Lower `burst` if you need fewer.
|
|
37
|
+
|
|
38
|
+
## Sync or async
|
|
39
|
+
|
|
40
|
+
| Code | Limiter | Stores |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Flask, Django, `def` dependencies | `Limiter` | `MemoryStore`, `RedisStore`, `PostgresStore` |
|
|
43
|
+
| `async def` | `AsyncLimiter` | `MemoryStore`, `AsyncRedisStore`, `AsyncPostgresStore` |
|
|
44
|
+
|
|
45
|
+
`Limiter` runs each store call in its own daemon thread, because Python cannot abandon a blocking call. `store_timeout_ms` bounds the request, and `max_store_threads` bounds the calls in flight. Set socket timeouts on your client as well, so a hung call ends.
|
|
46
|
+
|
|
47
|
+
redis-py caps its pool and raises past that cap, which `omnilimit` reports as a store failure. If you expect bursts of concurrent checks, use `BlockingConnectionPool`.
|
|
48
|
+
|
|
49
|
+
## FastAPI
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from fastapi import Depends, FastAPI
|
|
53
|
+
|
|
54
|
+
from omnilimit import MemoryStore, Limiter
|
|
55
|
+
from omnilimit.fastapi import ip_key, rate_limit
|
|
56
|
+
|
|
57
|
+
limiter = Limiter(
|
|
58
|
+
store=MemoryStore(), scope="api", limit=100, window_ms=60_000, on_store_error="open"
|
|
59
|
+
)
|
|
60
|
+
app = FastAPI(dependencies=[
|
|
61
|
+
Depends(rate_limit(limiter, key=ip_key(preset="cloud-run"), on_missing_key="shared")),
|
|
62
|
+
])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Denials raise `HTTPException`, so your own handlers format them. Headers on allowed requests are not added when a route returns its own `Response` object, as a file download usually does.
|
|
66
|
+
|
|
67
|
+
## Client IP only
|
|
68
|
+
|
|
69
|
+
`omnilimit.ip` uses only the standard library and also works next to `slowapi`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from omnilimit.ip import resolve_client_ip
|
|
73
|
+
|
|
74
|
+
ip = resolve_client_ip(request.headers, request.client.host, preset="cloud-run-lb")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It returns `None` rather than guessing. Check that the preset matches your deployment: see [client IP](https://github.com/PabloAlaniz/omnilimit/blob/main/docs/client-ip.md).
|
|
78
|
+
|
|
79
|
+
## Postgres
|
|
80
|
+
|
|
81
|
+
`omnilimit.stores.postgres.MIGRATION_SQL` creates the table and functions: apply it with your migration tool. Stores take a psycopg 3 pool or a connection with `autocommit=True`. Each hit is a write, so it is not meant for high traffic, and `cleanup()` is yours to schedule.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "omnilimit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Rate limiting that behaves the same across stacks, with trusted client IPs and an explicit failure mode."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "Pablo Alaniz" }]
|
|
14
|
+
keywords = ["rate-limit", "rate-limiting", "gcra", "redis", "postgres", "fastapi", "x-forwarded-for"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Framework :: FastAPI",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
redis = ["redis>=5.0"]
|
|
30
|
+
postgres = ["psycopg>=3.1"]
|
|
31
|
+
fastapi = ["fastapi>=0.109"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/PabloAlaniz/omnilimit"
|
|
35
|
+
Repository = "https://github.com/PabloAlaniz/omnilimit"
|
|
36
|
+
Specification = "https://github.com/PabloAlaniz/omnilimit/blob/main/spec/SPEC.md"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"coverage>=7.6",
|
|
41
|
+
"fastapi>=0.109",
|
|
42
|
+
"httpx>=0.27",
|
|
43
|
+
"jsonschema>=4.23",
|
|
44
|
+
"mypy>=1.13",
|
|
45
|
+
"psycopg[binary,pool]>=3.2",
|
|
46
|
+
"pytest>=8.3",
|
|
47
|
+
"redis>=5.0",
|
|
48
|
+
"ruff>=0.8",
|
|
49
|
+
"valkey>=6.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/omnilimit"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = "-ra"
|
|
58
|
+
|
|
59
|
+
[tool.coverage.run]
|
|
60
|
+
branch = true
|
|
61
|
+
source = ["omnilimit"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
fail_under = 95
|
|
65
|
+
show_missing = true
|
|
66
|
+
skip_covered = true
|
|
67
|
+
exclude_also = ["if TYPE_CHECKING:", "\\.\\.\\.$"]
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
strict = true
|
|
71
|
+
python_version = "3.11"
|
|
72
|
+
files = ["src"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff]
|
|
75
|
+
target-version = "py311"
|
|
76
|
+
line-length = 100
|
|
77
|
+
extend-exclude = ["src/omnilimit/_generated.py"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint]
|
|
80
|
+
select = ["E", "F", "I", "UP", "B", "S", "C4", "SIM", "RUF"]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint.per-file-ignores]
|
|
83
|
+
"tests/*" = ["S101", "S105", "S106", "S603", "S607"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Rate limiting that behaves the same across stacks, and proves it. See spec/SPEC.md."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from omnilimit._generated import SPEC_VERSION
|
|
6
|
+
from omnilimit._headers import build_headers
|
|
7
|
+
from omnilimit._limiter import AsyncLimiter, Limiter
|
|
8
|
+
from omnilimit._types import (
|
|
9
|
+
AsyncStore,
|
|
10
|
+
Decision,
|
|
11
|
+
HeaderInput,
|
|
12
|
+
HeaderProfile,
|
|
13
|
+
HitParams,
|
|
14
|
+
LimiterEvent,
|
|
15
|
+
OnStoreError,
|
|
16
|
+
RawHit,
|
|
17
|
+
Reason,
|
|
18
|
+
Store,
|
|
19
|
+
Tier,
|
|
20
|
+
)
|
|
21
|
+
from omnilimit.stores.memory import MemoryStore
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = version("omnilimit")
|
|
25
|
+
except PackageNotFoundError: # pragma: no cover
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"SPEC_VERSION",
|
|
30
|
+
"AsyncLimiter",
|
|
31
|
+
"AsyncStore",
|
|
32
|
+
"Decision",
|
|
33
|
+
"HeaderInput",
|
|
34
|
+
"HeaderProfile",
|
|
35
|
+
"HitParams",
|
|
36
|
+
"Limiter",
|
|
37
|
+
"LimiterEvent",
|
|
38
|
+
"MemoryStore",
|
|
39
|
+
"OnStoreError",
|
|
40
|
+
"RawHit",
|
|
41
|
+
"Reason",
|
|
42
|
+
"Store",
|
|
43
|
+
"Tier",
|
|
44
|
+
"__version__",
|
|
45
|
+
"build_headers",
|
|
46
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The algorithm, on integers only. Every division in this port lives in this file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from omnilimit._types import HitParams, RawHit
|
|
9
|
+
|
|
10
|
+
MIN_WINDOW_MS = 1000
|
|
11
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
12
|
+
_SCOPE = re.compile(r"[A-Za-z0-9._-]{1,64}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ceil_div(a: int, b: int) -> int:
|
|
16
|
+
"""`ceil(a / b)` for non-negative integers."""
|
|
17
|
+
return (a + b - 1) // b
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_positive_int(value: object) -> bool:
|
|
21
|
+
# bool is an int in Python, but `limit=True` is a bug rather than a limit of one.
|
|
22
|
+
return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= MAX_SAFE_INTEGER
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ResolvedPolicy(HitParams):
|
|
27
|
+
burst: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_policy(limit: int, window_ms: int, burst: int | None = None) -> ResolvedPolicy:
|
|
31
|
+
"""Validate a policy and compute the integers shared with every store."""
|
|
32
|
+
resolved_burst = limit if burst is None else burst
|
|
33
|
+
if not _is_positive_int(limit):
|
|
34
|
+
raise ValueError("omnilimit: limit must be a positive integer")
|
|
35
|
+
if not _is_positive_int(resolved_burst):
|
|
36
|
+
raise ValueError("omnilimit: burst must be a positive integer")
|
|
37
|
+
if not _is_positive_int(window_ms) or window_ms < MIN_WINDOW_MS:
|
|
38
|
+
raise ValueError(f"omnilimit: window_ms must be an integer of at least {MIN_WINDOW_MS}")
|
|
39
|
+
# Python would not overflow, but the same policy must be valid in every language.
|
|
40
|
+
if 2 * resolved_burst * window_ms + limit > MAX_SAFE_INTEGER:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"omnilimit: policy too large for exact integer arithmetic (2*burst*window_ms + limit)"
|
|
43
|
+
)
|
|
44
|
+
capacity = resolved_burst * window_ms
|
|
45
|
+
return ResolvedPolicy(
|
|
46
|
+
limit=limit,
|
|
47
|
+
window_ms=window_ms,
|
|
48
|
+
capacity=capacity,
|
|
49
|
+
drain_ms=ceil_div(capacity, limit),
|
|
50
|
+
burst=resolved_burst,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def assert_scope(scope: str) -> None:
|
|
55
|
+
if not isinstance(scope, str) or _SCOPE.fullmatch(scope) is None:
|
|
56
|
+
raise ValueError("omnilimit: scope must match ^[A-Za-z0-9._-]{1,64}$")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class MeterState:
|
|
61
|
+
level: int
|
|
62
|
+
ts: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_hit(
|
|
66
|
+
state: MeterState | None, params: HitParams, now_ms: int
|
|
67
|
+
) -> tuple[RawHit, MeterState | None]:
|
|
68
|
+
"""Return the hit and the state to persist, which is `None` on denial: denials never write."""
|
|
69
|
+
ts = state.ts if state is not None else now_ms
|
|
70
|
+
level = state.level if state is not None else 0
|
|
71
|
+
t = max(now_ms, ts)
|
|
72
|
+
elapsed = min(t - ts, params.drain_ms)
|
|
73
|
+
drained = max(0, level - elapsed * params.limit)
|
|
74
|
+
|
|
75
|
+
if drained + params.window_ms > params.capacity:
|
|
76
|
+
return RawHit(allowed=False, level=drained, ts=ts), None
|
|
77
|
+
new_level = drained + params.window_ms
|
|
78
|
+
return RawHit(allowed=True, level=new_level, ts=t), MeterState(level=new_level, ts=t)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class MeterView:
|
|
83
|
+
remaining: int
|
|
84
|
+
reset_ms: int
|
|
85
|
+
retry_after_ms: int | None
|
|
86
|
+
effective_ms: int
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def view_of(allowed: bool, level: int, params: HitParams) -> MeterView:
|
|
90
|
+
"""Derive the public numbers from a level."""
|
|
91
|
+
remaining = (params.capacity - level) // params.window_ms
|
|
92
|
+
reset_ms = ceil_div(level, params.limit)
|
|
93
|
+
next_unit_ms = ceil_div(max(0, level + params.window_ms - params.capacity), params.limit)
|
|
94
|
+
if not allowed:
|
|
95
|
+
return MeterView(remaining, reset_ms, next_unit_ms, next_unit_ms)
|
|
96
|
+
return MeterView(remaining, reset_ms, None, next_unit_ms if remaining == 0 else reset_ms)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# GENERATED by tools/sync_assets.py from spec/scripts. Do not edit.
|
|
2
|
+
SPEC_VERSION = "1"
|
|
3
|
+
REDIS_HIT_SHA1 = "f525d75f3e6a26f742e789522c9055c49bad3cce"
|
|
4
|
+
REDIS_HIT_LUA = "-- omnilimit state format 1. KEYS[1] = bucket key.\n-- ARGV: limit, window_ms, capacity, drain_ms, now_ms ('' = use the server clock).\n-- The script has no division on policy values: every port computes capacity and\n-- drain_ms once, so it behaves identically regardless of the calling language.\nlocal limit = tonumber(ARGV[1])\nlocal window_ms = tonumber(ARGV[2])\nlocal capacity = tonumber(ARGV[3])\nlocal drain_ms = tonumber(ARGV[4])\n\nlocal now\nlocal injected = ARGV[5] ~= nil and ARGV[5] ~= ''\nif injected then\n now = tonumber(ARGV[5])\nelse\n local clock = redis.call('TIME')\n now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)\nend\n\nlocal stored = redis.call('HMGET', KEYS[1], 'l', 't')\nlocal level = tonumber(stored[1]) or 0\nlocal ts = tonumber(stored[2]) or now\n\nlocal t = math.max(now, ts)\nlocal elapsed = math.min(t - ts, drain_ms)\nlocal drained = math.max(0, level - elapsed * limit)\n\nif drained + window_ms > capacity then\n -- Denied requests never write, so a retry storm cannot extend the wait.\n return {0, string.format('%.0f', drained), string.format('%.0f', ts)}\nend\n\nlocal new_level = drained + window_ms\n-- tostring() would render large doubles in scientific notation and lose digits.\nredis.call('HSET', KEYS[1], 'l', string.format('%.0f', new_level), 't', string.format('%.0f', t))\n-- A TTL runs on real time, so it cannot be mixed with an injected clock (conformance vectors only).\nif not injected then\n redis.call('PEXPIRE', KEYS[1], drain_ms)\nend\nreturn {1, string.format('%.0f', new_level), string.format('%.0f', t)}\n"
|
|
5
|
+
POSTGRES_MIGRATION_SQL = "-- omnilimit state format 1. Apply once with your migration tool; omnilimit never runs it for you.\nCREATE TABLE IF NOT EXISTS omnilimit_state (\n key text PRIMARY KEY,\n level bigint NOT NULL,\n ts_ms bigint NOT NULL,\n expires_at_ms bigint NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS omnilimit_state_expires_idx ON omnilimit_state (expires_at_ms);\n\nCREATE OR REPLACE FUNCTION omnilimit_hit_v1(\n p_key text,\n p_limit bigint,\n p_window_ms bigint,\n p_capacity bigint,\n p_drain_ms bigint,\n p_now_ms bigint DEFAULT NULL\n) RETURNS TABLE (allowed boolean, new_level bigint, new_ts bigint)\nLANGUAGE plpgsql\nAS $$\nDECLARE\n v_level bigint;\n v_ts bigint;\n v_now bigint;\n v_t bigint;\n v_drained bigint;\nBEGIN\n -- The placeholder (level 0, ts 0) behaves exactly like a missing row.\n INSERT INTO omnilimit_state AS s (key, level, ts_ms, expires_at_ms)\n VALUES (p_key, 0, 0, 0)\n ON CONFLICT (key) DO NOTHING;\n\n SELECT s.level, s.ts_ms INTO v_level, v_ts\n FROM omnilimit_state AS s\n WHERE s.key = p_key\n FOR UPDATE;\n\n -- Read the clock after taking the lock: waiters must not act on a stale time.\n v_now := COALESCE(p_now_ms, floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint);\n v_t := GREATEST(v_now, v_ts);\n v_drained := GREATEST(0, v_level - LEAST(v_t - v_ts, p_drain_ms) * p_limit);\n\n IF v_drained + p_window_ms > p_capacity THEN\n RETURN QUERY SELECT false, v_drained, v_ts;\n RETURN;\n END IF;\n\n UPDATE omnilimit_state AS s\n SET level = v_drained + p_window_ms,\n ts_ms = v_t,\n expires_at_ms = v_t + p_drain_ms\n WHERE s.key = p_key;\n\n RETURN QUERY SELECT true, v_drained + p_window_ms, v_t;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION omnilimit_cleanup_v1(p_batch integer DEFAULT 1000, p_now_ms bigint DEFAULT NULL)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nDECLARE\n v_deleted integer;\n v_now bigint := COALESCE(p_now_ms, floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint);\nBEGIN\n DELETE FROM omnilimit_state\n WHERE key IN (\n SELECT s.key FROM omnilimit_state AS s\n WHERE s.expires_at_ms < v_now\n ORDER BY s.expires_at_ms\n LIMIT p_batch\n FOR UPDATE SKIP LOCKED\n );\n GET DIAGNOSTICS v_deleted = ROW_COUNT;\n RETURN v_deleted;\nEND;\n$$;\n"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Response headers. `draft-11` is pinned: a newer draft becomes a new profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from omnilimit._gcra import ceil_div
|
|
6
|
+
from omnilimit._types import HeaderInput, HeaderProfile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _seconds(ms: int) -> int:
|
|
10
|
+
return ceil_div(ms, 1000)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_headers(profile: HeaderProfile, data: HeaderInput) -> dict[str, str]:
|
|
14
|
+
decision = data.decision
|
|
15
|
+
out: dict[str, str] = {}
|
|
16
|
+
if decision.remaining is not None:
|
|
17
|
+
t = _seconds(data.effective_ms)
|
|
18
|
+
if profile == "draft-11":
|
|
19
|
+
out["ratelimit-policy"] = f'"{data.scope}";q={data.limit};w={_seconds(data.window_ms)}'
|
|
20
|
+
out["ratelimit"] = f'"{data.scope}";r={decision.remaining};t={t}'
|
|
21
|
+
elif profile == "legacy":
|
|
22
|
+
out["x-ratelimit-limit"] = str(data.limit)
|
|
23
|
+
out["x-ratelimit-remaining"] = str(decision.remaining)
|
|
24
|
+
out["x-ratelimit-reset"] = str(t)
|
|
25
|
+
if not decision.allowed and decision.retry_after_ms is not None:
|
|
26
|
+
out["retry-after"] = str(_seconds(decision.retry_after_ms))
|
|
27
|
+
return out
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Bucket keys. The id is hashed: keys stay short and stores never hold raw IPs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
from omnilimit._gcra import ResolvedPolicy
|
|
8
|
+
|
|
9
|
+
_HASH_HEX_LENGTH = 32
|
|
10
|
+
_STATE_FORMAT = 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _utf8(text: str) -> bytes:
|
|
14
|
+
"""Encode like JavaScript's TextEncoder, where a lone surrogate becomes U+FFFD."""
|
|
15
|
+
try:
|
|
16
|
+
return text.encode("utf-8")
|
|
17
|
+
except UnicodeEncodeError:
|
|
18
|
+
repaired = text.encode("utf-16", "surrogatepass").decode("utf-16", "replace")
|
|
19
|
+
return repaired.encode("utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def derive_key(scope: str, policy: ResolvedPolicy, salt: str, id_value: str) -> str:
|
|
23
|
+
"""Build the bucket key. The policy is part of it, since levels are measured in windows."""
|
|
24
|
+
digest = hashlib.sha256(_utf8(salt) + b"\x00" + _utf8(id_value)).hexdigest()
|
|
25
|
+
shape = f"{policy.limit}-{policy.window_ms}-{policy.burst}"
|
|
26
|
+
return f"ol:{_STATE_FORMAT}:{scope}:{shape}:{digest[:_HASH_HEX_LENGTH]}"
|