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.
Binary file
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ .turbo/
5
+ *.tgz
6
+ .DS_Store
7
+ __pycache__/
8
+ *.pyc
9
+ .venv/
10
+ site/
11
+ .env
12
+ .env.*
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **python:** add Python port ([#5](https://github.com/PabloAlaniz/omnilimit/issues/5)) ([f0256bc](https://github.com/PabloAlaniz/omnilimit/commit/f0256bc9084c95571a1757a1136ed9d0fe094555))
@@ -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.
@@ -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]}"