taskq-py 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.
- taskq/__init__.py +147 -0
- taskq/_di/__init__.py +45 -0
- taskq/_di/_utils.py +13 -0
- taskq/_di/_validate.py +456 -0
- taskq/_di/lifecycle.py +52 -0
- taskq/_di/registry.py +342 -0
- taskq/_di/scope.py +12 -0
- taskq/_di/scopes.py +467 -0
- taskq/_di/solver.py +159 -0
- taskq/_di/types.py +121 -0
- taskq/_dsn.py +18 -0
- taskq/_ids.py +128 -0
- taskq/_json.py +105 -0
- taskq/_scope.py +39 -0
- taskq/actor.py +752 -0
- taskq/backend/__init__.py +69 -0
- taskq/backend/_cursor.py +32 -0
- taskq/backend/_dispatch.py +114 -0
- taskq/backend/_dispatch_sql.py +301 -0
- taskq/backend/_enqueue.py +490 -0
- taskq/backend/_notify.py +49 -0
- taskq/backend/_protocol.py +854 -0
- taskq/backend/_reads.py +177 -0
- taskq/backend/_records.py +116 -0
- taskq/backend/_schedules.py +226 -0
- taskq/backend/_sql.py +103 -0
- taskq/backend/_sql_templates.py +543 -0
- taskq/backend/_sweeps.py +487 -0
- taskq/backend/_terminal.py +830 -0
- taskq/backend/clock.py +47 -0
- taskq/backend/postgres.py +656 -0
- taskq/backend/statemachine.py +50 -0
- taskq/batch.py +274 -0
- taskq/cli.py +670 -0
- taskq/client/__init__.py +25 -0
- taskq/client/_args.py +233 -0
- taskq/client/_enqueuer.py +347 -0
- taskq/client/_handle.py +321 -0
- taskq/client/_jobs.py +756 -0
- taskq/client/_taskq.py +647 -0
- taskq/client/_transport.py +112 -0
- taskq/constants.py +152 -0
- taskq/context.py +171 -0
- taskq/contrib/__init__.py +1 -0
- taskq/contrib/kubernetes/__init__.py +1 -0
- taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
- taskq/contrib/prometheus/__init__.py +10 -0
- taskq/contrib/prometheus/_metrics.py +63 -0
- taskq/contrib/prometheus/rules.yaml +113 -0
- taskq/cron.py +332 -0
- taskq/di.py +7 -0
- taskq/exceptions.py +398 -0
- taskq/migrate.py +248 -0
- taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
- taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
- taskq/migrations/__init__.py +13 -0
- taskq/obs/__init__.py +120 -0
- taskq/obs/_otel.py +583 -0
- taskq/obs/_structlog.py +241 -0
- taskq/obs/error_reporter.py +120 -0
- taskq/progress/__init__.py +5 -0
- taskq/progress/_buffer.py +96 -0
- taskq/progress/_events.py +34 -0
- taskq/progress/_flush.py +140 -0
- taskq/progress/_publish.py +200 -0
- taskq/py.typed +0 -0
- taskq/ratelimit/__init__.py +42 -0
- taskq/ratelimit/_decision_log.py +38 -0
- taskq/ratelimit/_provider.py +90 -0
- taskq/ratelimit/_redis_utils.py +79 -0
- taskq/ratelimit/_scripts.py +265 -0
- taskq/ratelimit/_sliding_window_pg.py +432 -0
- taskq/ratelimit/_sliding_window_redis.py +398 -0
- taskq/ratelimit/composition.py +93 -0
- taskq/ratelimit/decision.py +47 -0
- taskq/ratelimit/refs.py +87 -0
- taskq/ratelimit/registry.py +678 -0
- taskq/ratelimit/reservation.py +593 -0
- taskq/ratelimit/sliding_window.py +570 -0
- taskq/ratelimit/token_bucket.py +754 -0
- taskq/retry.py +582 -0
- taskq/scheduler.py +59 -0
- taskq/settings.py +797 -0
- taskq/testing/__init__.py +102 -0
- taskq/testing/_dispatch.py +158 -0
- taskq/testing/_enqueue.py +225 -0
- taskq/testing/_reads.py +101 -0
- taskq/testing/_runner.py +650 -0
- taskq/testing/_slots.py +108 -0
- taskq/testing/_sweeps.py +184 -0
- taskq/testing/_terminal.py +666 -0
- taskq/testing/actor.py +322 -0
- taskq/testing/assertions.py +311 -0
- taskq/testing/asyncpg_chaos.py +157 -0
- taskq/testing/clock.py +49 -0
- taskq/testing/fixtures.py +860 -0
- taskq/testing/in_memory.py +773 -0
- taskq/testing/job_context.py +63 -0
- taskq/testing/jobs.py +190 -0
- taskq/testing/otel.py +251 -0
- taskq/testing/pg.py +310 -0
- taskq/testing/settings.py +71 -0
- taskq/testing/spy.py +15 -0
- taskq/types.py +48 -0
- taskq/web/__init__.py +1 -0
- taskq/web/admin/__init__.py +28 -0
- taskq/web/admin/_constants.py +26 -0
- taskq/web/admin/_factory.py +403 -0
- taskq/web/admin/_history.py +250 -0
- taskq/web/admin/_jsonb.py +23 -0
- taskq/web/admin/_listen.py +107 -0
- taskq/web/admin/_static.py +25 -0
- taskq/web/admin/auth/__init__.py +42 -0
- taskq/web/admin/auth/_session.py +190 -0
- taskq/web/admin/auth/oidc.py +299 -0
- taskq/web/admin/auth/saml.py +213 -0
- taskq/web/admin/auth/token.py +35 -0
- taskq/web/admin/jobs.py +555 -0
- taskq/web/admin/ops.py +658 -0
- taskq/web/admin/queues.py +197 -0
- taskq/web/admin/sse.py +104 -0
- taskq/web/admin/workers.py +105 -0
- taskq/web/health.py +81 -0
- taskq/web/progress.py +445 -0
- taskq/web/static/admin.css +2 -0
- taskq/web/static/admin.js +218 -0
- taskq/web/static/alpine.min.js +5 -0
- taskq/web/static/htmx.min.js +1 -0
- taskq/web/static/realtime.js +246 -0
- taskq/web/static/sse.min.js +1 -0
- taskq/web/static/tailwind.css +3 -0
- taskq/web/templates/_base.html +75 -0
- taskq/web/templates/_partials/job_card.html +97 -0
- taskq/web/templates/_partials/job_table.html +148 -0
- taskq/web/templates/_partials/sse_console.html +4 -0
- taskq/web/templates/_partials/table.html +37 -0
- taskq/web/templates/history.html +98 -0
- taskq/web/templates/job_detail.html +367 -0
- taskq/web/templates/jobs.html +193 -0
- taskq/web/templates/leader.html +73 -0
- taskq/web/templates/queue_detail.html +59 -0
- taskq/web/templates/queues.html +85 -0
- taskq/web/templates/rate_limits.html +104 -0
- taskq/web/templates/reservations.html +93 -0
- taskq/web/templates/schedules.html +76 -0
- taskq/web/templates/workers.html +69 -0
- taskq/worker/__init__.py +69 -0
- taskq/worker/_bootstrap.py +509 -0
- taskq/worker/_consumer.py +896 -0
- taskq/worker/_handlers.py +709 -0
- taskq/worker/_leader_shared.py +390 -0
- taskq/worker/_leader_sweeps.py +441 -0
- taskq/worker/actor_config.py +19 -0
- taskq/worker/budget.py +93 -0
- taskq/worker/cancel.py +438 -0
- taskq/worker/cron_loop.py +300 -0
- taskq/worker/deps.py +296 -0
- taskq/worker/dev.py +160 -0
- taskq/worker/dispatch.py +290 -0
- taskq/worker/health.py +330 -0
- taskq/worker/heartbeat.py +270 -0
- taskq/worker/leader.py +384 -0
- taskq/worker/notify.py +331 -0
- taskq/worker/run.py +576 -0
- taskq/worker/shutdown.py +287 -0
- taskq/worker/startup.py +214 -0
- taskq/worker/workgroup.py +778 -0
- taskq_py-0.1.0.dist-info/METADATA +364 -0
- taskq_py-0.1.0.dist-info/RECORD +172 -0
- taskq_py-0.1.0.dist-info/WHEEL +4 -0
- taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
- taskq_py-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Token-bucket rate limiter with pluggable backends.
|
|
2
|
+
|
|
3
|
+
The in-memory backend is the reference implementation and arithmetic oracle
|
|
4
|
+
for the Redis Lua script and the PG fallback.
|
|
5
|
+
|
|
6
|
+
Design deviation — always invoke the Lua script instead of a Python
|
|
7
|
+
pre-check ``if self.refill_per_second == 0 and tokens < count: return ...``
|
|
8
|
+
before invoking the script. We deviate by always invoking the Lua script
|
|
9
|
+
and post-processing the result.
|
|
10
|
+
- Why: the pre-check requires local knowledge of ``tokens``.
|
|
11
|
+
The in-memory backend has that knowledge in ``_InMemoryBucket._tokens``
|
|
12
|
+
and implements the pre-check. The Redis backend does NOT
|
|
13
|
+
have local knowledge of ``tokens`` — that state lives in the Redis
|
|
14
|
+
hash and is computed inside the Lua script using ``elapsed * refill``.
|
|
15
|
+
The guard cannot be applied verbatim on the Redis path
|
|
16
|
+
without first issuing an HMGET to read ``tokens``, which would change
|
|
17
|
+
the protocol from "one Lua call" to "HMGET then Lua call." Rather
|
|
18
|
+
than introduce that round-trip, we let the Lua script run
|
|
19
|
+
unconditionally and substitute ``None`` in Python when the denial
|
|
20
|
+
branch produces a ``nan``/``inf`` ``retry_after_seconds``.
|
|
21
|
+
- What we do instead: invoke the Lua script unconditionally; in the
|
|
22
|
+
denial branch with ``refill = 0`` the script's ``retry_after_seconds``
|
|
23
|
+
is ``nan``/``inf`` (division by zero), but the script's ``tokens_remaining``
|
|
24
|
+
(result index 1) IS still valid because the denial branch reports
|
|
25
|
+
the current token count without modification. We discard the Lua
|
|
26
|
+
``retry_after_seconds`` and substitute ``None`` when
|
|
27
|
+
``allowed_int == 0 and self.refill_per_second == 0``.
|
|
28
|
+
- Reversibility: fully reversible. Switching to the
|
|
29
|
+
pre-check is a one-method change (add ``_pre_check_redis()`` issuing
|
|
30
|
+
HMGET, branch before ``register_script`` call). No persistent state
|
|
31
|
+
or external contract relies on the deviation.
|
|
32
|
+
|
|
33
|
+
This file exceeds the 500-line soft ceiling (file-size
|
|
34
|
+
decomposition). It co-locates three concern-clusters — (a) the
|
|
35
|
+
``_InMemoryBucket`` state machine, (b) Lua-result decoding and the Redis
|
|
36
|
+
acquire path, and (c) the PG acquire path — all of which serve the single
|
|
37
|
+
token-bucket primitive. Splitting would move the shared ``RateLimitDecision``
|
|
38
|
+
return contract, the ``capacity``/``refill_per_second`` constructor validation,
|
|
39
|
+
and the ``acquire`` dispatch logic into a fourth module, creating an inner
|
|
40
|
+
platform where every backend module re-imports from a thin orchestrator that
|
|
41
|
+
exists only to satisfy a line-count rule. The three paths share the same
|
|
42
|
+
arithmetic, the same clock/now conventions, and the same logging discipline;
|
|
43
|
+
co-location keeps the arithmetic consistent and the dispatch logic visible
|
|
44
|
+
end-to-end without indirection.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import asyncio
|
|
48
|
+
import math
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from datetime import timedelta
|
|
51
|
+
from typing import TYPE_CHECKING, Final
|
|
52
|
+
|
|
53
|
+
import structlog
|
|
54
|
+
|
|
55
|
+
from taskq.backend._protocol import RateLimitBackend
|
|
56
|
+
from taskq.backend._records import jsonb_param, jsonb_to_dict
|
|
57
|
+
from taskq.backend.clock import Clock
|
|
58
|
+
from taskq.ratelimit._decision_log import log_decision
|
|
59
|
+
from taskq.ratelimit._redis_utils import ensure_redis_script, with_pg_fallback
|
|
60
|
+
from taskq.ratelimit._scripts import REFUND_SCRIPT, TOKEN_BUCKET_SCRIPT
|
|
61
|
+
from taskq.ratelimit.decision import RateLimitDecision, RateLimitState
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
import asyncpg
|
|
65
|
+
import redis.asyncio as redis_async
|
|
66
|
+
from redis.commands.core import AsyncScript
|
|
67
|
+
|
|
68
|
+
from taskq.settings import WorkerSettings
|
|
69
|
+
|
|
70
|
+
logger = structlog.get_logger("taskq.ratelimit.token_bucket")
|
|
71
|
+
|
|
72
|
+
_DEFAULT_FIXED_QUOTA_TTL: Final[timedelta] = timedelta(seconds=86400)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class _LuaResult:
|
|
77
|
+
"""Typed decoding of the three-element list returned by the token-bucket Lua script.
|
|
78
|
+
|
|
79
|
+
The Lua script returns ``{allowed, tokens_remaining,
|
|
80
|
+
retry_after_seconds}`` where ``allowed`` is 0 or 1 (Lua integer) and
|
|
81
|
+
``tokens_remaining`` / ``retry_after_seconds`` are Lua number strings
|
|
82
|
+
(``tostring()``). Redis truncates Lua numbers to integers on return;
|
|
83
|
+
returning floats as strings preserves the fractional part (see Redis
|
|
84
|
+
EVAL docs: "Lua number → RESP2 integer reply — removing the decimal
|
|
85
|
+
part of the number, if any"). This helper normalises them to Python
|
|
86
|
+
types in one place so the rest of the Redis path stays fully typed.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
allowed: bool
|
|
90
|
+
tokens_remaining: float
|
|
91
|
+
retry_after_seconds: float
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _decode_lua_result(raw: list[object]) -> _LuaResult:
|
|
95
|
+
"""Decode the raw Redis response from the token-bucket Lua script.
|
|
96
|
+
|
|
97
|
+
This function is the **only** place where redis-py's untyped
|
|
98
|
+
``AsyncScript.__call__`` return touches our code. ``raw[0]`` is an
|
|
99
|
+
integer (allowed: 0 or 1). ``raw[1]`` and ``raw[2]`` are strings
|
|
100
|
+
(bytes or str depending on ``decode_responses``) produced by Lua's
|
|
101
|
+
``tostring()`` — this is required because Redis truncates Lua number
|
|
102
|
+
returns to integers, losing fractional parts. ``int()`` / ``float()``
|
|
103
|
+
accept bytes, int, and str at runtime.
|
|
104
|
+
"""
|
|
105
|
+
allowed_int = int(raw[0]) # pyright: ignore[reportArgumentType] # Why: raw[0] is int | bytes from Redis; int() accepts both at runtime but pyright cannot model AsyncScript's untyped return
|
|
106
|
+
tokens_remaining = float(raw[1]) # pyright: ignore[reportArgumentType] # Why: raw[1] is bytes | str from Redis (Lua tostring); float() accepts both
|
|
107
|
+
retry_after_seconds = float(raw[2]) # pyright: ignore[reportArgumentType] # Why: raw[2] is bytes | str from Redis (Lua tostring); float() accepts both
|
|
108
|
+
return _LuaResult(
|
|
109
|
+
allowed=allowed_int == 1,
|
|
110
|
+
tokens_remaining=tokens_remaining,
|
|
111
|
+
retry_after_seconds=retry_after_seconds,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _InMemoryBucket:
|
|
116
|
+
"""Per-bucket state for the in-memory token-bucket algorithm."""
|
|
117
|
+
|
|
118
|
+
__slots__ = ("_capacity", "_lock", "_name", "_refill", "_tokens", "_ts")
|
|
119
|
+
|
|
120
|
+
def __init__(self, name: str, capacity: float, refill_per_second: float) -> None:
|
|
121
|
+
self._name = name
|
|
122
|
+
self._capacity = capacity
|
|
123
|
+
self._refill = refill_per_second
|
|
124
|
+
self._tokens: float = capacity
|
|
125
|
+
self._ts: float | None = None
|
|
126
|
+
self._lock = asyncio.Lock()
|
|
127
|
+
|
|
128
|
+
async def acquire(self, count: float, now_ts: float) -> RateLimitDecision:
|
|
129
|
+
async with self._lock:
|
|
130
|
+
if self._ts is None:
|
|
131
|
+
self._ts = now_ts
|
|
132
|
+
|
|
133
|
+
elapsed = max(0.0, now_ts - self._ts)
|
|
134
|
+
tokens = min(self._capacity, self._tokens + elapsed * self._refill)
|
|
135
|
+
|
|
136
|
+
if tokens >= count:
|
|
137
|
+
tokens -= count
|
|
138
|
+
self._tokens = tokens
|
|
139
|
+
self._ts = now_ts
|
|
140
|
+
return RateLimitDecision(
|
|
141
|
+
allowed=True,
|
|
142
|
+
remaining=tokens,
|
|
143
|
+
retry_after=timedelta(0),
|
|
144
|
+
bucket_name=self._name,
|
|
145
|
+
backend="memory",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self._tokens = tokens
|
|
149
|
+
self._ts = now_ts
|
|
150
|
+
|
|
151
|
+
if self._refill == 0.0:
|
|
152
|
+
return RateLimitDecision(
|
|
153
|
+
allowed=False,
|
|
154
|
+
remaining=tokens,
|
|
155
|
+
retry_after=None,
|
|
156
|
+
bucket_name=self._name,
|
|
157
|
+
backend="memory",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
retry_seconds = (count - tokens) / self._refill
|
|
161
|
+
return RateLimitDecision(
|
|
162
|
+
allowed=False,
|
|
163
|
+
remaining=tokens,
|
|
164
|
+
retry_after=timedelta(seconds=retry_seconds),
|
|
165
|
+
bucket_name=self._name,
|
|
166
|
+
backend="memory",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def refund(self, count: float) -> None:
|
|
170
|
+
async with self._lock:
|
|
171
|
+
self._tokens = min(self._capacity, self._tokens + count)
|
|
172
|
+
|
|
173
|
+
async def peek(self, now_ts: float) -> RateLimitState:
|
|
174
|
+
async with self._lock:
|
|
175
|
+
if self._ts is None:
|
|
176
|
+
tokens = self._capacity
|
|
177
|
+
else:
|
|
178
|
+
elapsed = max(0.0, now_ts - self._ts)
|
|
179
|
+
tokens = min(self._capacity, self._tokens + elapsed * self._refill)
|
|
180
|
+
|
|
181
|
+
is_exhausted = tokens <= 0.0
|
|
182
|
+
retry_after: timedelta | None = None
|
|
183
|
+
if is_exhausted and self._refill > 0.0:
|
|
184
|
+
retry_seconds = (1.0 - tokens) / self._refill
|
|
185
|
+
retry_after = timedelta(seconds=max(0.0, retry_seconds))
|
|
186
|
+
|
|
187
|
+
return RateLimitState(
|
|
188
|
+
bucket_name=self._name,
|
|
189
|
+
backend="memory",
|
|
190
|
+
is_exhausted=is_exhausted,
|
|
191
|
+
tokens_remaining=tokens,
|
|
192
|
+
retry_after=retry_after,
|
|
193
|
+
capacity=self._capacity,
|
|
194
|
+
refill_per_second=self._refill,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def reset(self, now_ts: float) -> None:
|
|
198
|
+
async with self._lock:
|
|
199
|
+
self._tokens = self._capacity
|
|
200
|
+
self._ts = now_ts
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TokenBucket:
|
|
204
|
+
"""Token-bucket rate limiter with pluggable backends.
|
|
205
|
+
|
|
206
|
+
Raises :class:`ValueError` if ``capacity <= 0`` or
|
|
207
|
+
``refill_per_second < 0``.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
__slots__ = (
|
|
211
|
+
"_backend",
|
|
212
|
+
"_capacity",
|
|
213
|
+
"_mem_bucket",
|
|
214
|
+
"_name",
|
|
215
|
+
"_redis_refund_script",
|
|
216
|
+
"_redis_script",
|
|
217
|
+
"_refill",
|
|
218
|
+
"_script_lock",
|
|
219
|
+
"_ttl",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
name: str,
|
|
225
|
+
capacity: float,
|
|
226
|
+
refill_per_second: float,
|
|
227
|
+
backend: RateLimitBackend = "redis",
|
|
228
|
+
ttl: timedelta | None = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
if capacity <= 0:
|
|
231
|
+
raise ValueError(f"capacity must be > 0, got {capacity}")
|
|
232
|
+
if refill_per_second < 0:
|
|
233
|
+
raise ValueError(f"refill_per_second must be >= 0, got {refill_per_second}")
|
|
234
|
+
|
|
235
|
+
self._name = name
|
|
236
|
+
self._capacity = capacity
|
|
237
|
+
self._refill = refill_per_second
|
|
238
|
+
self._backend: RateLimitBackend = backend
|
|
239
|
+
|
|
240
|
+
if ttl is not None:
|
|
241
|
+
self._ttl = ttl
|
|
242
|
+
elif refill_per_second == 0.0:
|
|
243
|
+
self._ttl = _DEFAULT_FIXED_QUOTA_TTL
|
|
244
|
+
else:
|
|
245
|
+
self._ttl = timedelta(seconds=math.ceil(capacity / refill_per_second * 2) + 60)
|
|
246
|
+
|
|
247
|
+
self._mem_bucket: _InMemoryBucket | None = None
|
|
248
|
+
if backend == "memory":
|
|
249
|
+
self._mem_bucket = _InMemoryBucket(name, capacity, refill_per_second)
|
|
250
|
+
|
|
251
|
+
self._redis_script: AsyncScript | None = None
|
|
252
|
+
self._redis_refund_script: AsyncScript | None = None
|
|
253
|
+
self._script_lock: asyncio.Lock = asyncio.Lock()
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def name(self) -> str:
|
|
257
|
+
return self._name
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def capacity(self) -> float:
|
|
261
|
+
return self._capacity
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def refill_per_second(self) -> float:
|
|
265
|
+
return self._refill
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def backend(self) -> RateLimitBackend:
|
|
269
|
+
return self._backend
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def ttl(self) -> timedelta:
|
|
273
|
+
return self._ttl
|
|
274
|
+
|
|
275
|
+
async def acquire(
|
|
276
|
+
self,
|
|
277
|
+
count: float = 1.0,
|
|
278
|
+
*,
|
|
279
|
+
redis_client: "redis_async.Redis | None" = None,
|
|
280
|
+
pg_pool: "asyncpg.Pool | None" = None,
|
|
281
|
+
clock: Clock | None = None,
|
|
282
|
+
settings: "WorkerSettings | None" = None,
|
|
283
|
+
) -> RateLimitDecision:
|
|
284
|
+
if self._backend == "memory":
|
|
285
|
+
return await self._acquire_memory(count, clock)
|
|
286
|
+
if self._backend == "redis":
|
|
287
|
+
return await self._acquire_redis_wrapped(count, redis_client, pg_pool, clock, settings)
|
|
288
|
+
if self._backend == "postgres":
|
|
289
|
+
return await self._acquire_pg(count, pg_pool, clock, settings)
|
|
290
|
+
|
|
291
|
+
raise RuntimeError(f"unknown backend: {self._backend!r}")
|
|
292
|
+
|
|
293
|
+
async def refund(
|
|
294
|
+
self,
|
|
295
|
+
decision: RateLimitDecision,
|
|
296
|
+
*,
|
|
297
|
+
count: float = 1.0,
|
|
298
|
+
redis_client: "redis_async.Redis | None" = None,
|
|
299
|
+
pg_pool: "asyncpg.Pool | None" = None,
|
|
300
|
+
clock: Clock | None = None,
|
|
301
|
+
settings: "WorkerSettings | None" = None,
|
|
302
|
+
) -> None:
|
|
303
|
+
if self._backend == "memory":
|
|
304
|
+
await self._refund_memory(count)
|
|
305
|
+
elif self._backend == "redis":
|
|
306
|
+
await self._refund_redis(decision, count, redis_client, clock, settings)
|
|
307
|
+
elif self._backend == "postgres":
|
|
308
|
+
await self._refund_pg(count, pg_pool, clock, settings)
|
|
309
|
+
|
|
310
|
+
async def peek(
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
redis_client: "redis_async.Redis | None" = None,
|
|
314
|
+
pg_pool: "asyncpg.Pool | None" = None,
|
|
315
|
+
clock: Clock | None = None,
|
|
316
|
+
settings: "WorkerSettings | None" = None,
|
|
317
|
+
) -> RateLimitState:
|
|
318
|
+
if self._backend == "memory":
|
|
319
|
+
return await self._peek_memory(clock)
|
|
320
|
+
if self._backend == "redis":
|
|
321
|
+
return await self._peek_redis(redis_client, clock, settings)
|
|
322
|
+
if self._backend == "postgres":
|
|
323
|
+
return await self._peek_pg(pg_pool, clock, settings)
|
|
324
|
+
|
|
325
|
+
raise RuntimeError(f"unknown backend: {self._backend!r}")
|
|
326
|
+
|
|
327
|
+
async def reset(
|
|
328
|
+
self,
|
|
329
|
+
*,
|
|
330
|
+
redis_client: "redis_async.Redis | None" = None,
|
|
331
|
+
pg_pool: "asyncpg.Pool | None" = None,
|
|
332
|
+
clock: Clock | None = None,
|
|
333
|
+
settings: "WorkerSettings | None" = None,
|
|
334
|
+
) -> None:
|
|
335
|
+
if self._backend == "memory":
|
|
336
|
+
await self._reset_memory(clock)
|
|
337
|
+
elif self._backend == "redis":
|
|
338
|
+
await self._reset_redis(redis_client, settings)
|
|
339
|
+
elif self._backend == "postgres":
|
|
340
|
+
await self._reset_pg(pg_pool, settings)
|
|
341
|
+
else:
|
|
342
|
+
raise RuntimeError(f"unknown backend: {self._backend!r}")
|
|
343
|
+
|
|
344
|
+
logger.warning(
|
|
345
|
+
"ratelimit-reset",
|
|
346
|
+
bucket_name=self._name,
|
|
347
|
+
backend=self._backend,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def _peek_memory(self, clock: Clock | None) -> RateLimitState:
|
|
351
|
+
if clock is None:
|
|
352
|
+
raise RuntimeError("clock not injected for memory backend")
|
|
353
|
+
if self._mem_bucket is None:
|
|
354
|
+
raise RuntimeError("memory bucket not initialised")
|
|
355
|
+
now_ts = clock.now().timestamp()
|
|
356
|
+
return await self._mem_bucket.peek(now_ts)
|
|
357
|
+
|
|
358
|
+
async def _reset_memory(self, clock: Clock | None) -> None:
|
|
359
|
+
if clock is None:
|
|
360
|
+
raise RuntimeError("clock not injected for memory backend")
|
|
361
|
+
if self._mem_bucket is None:
|
|
362
|
+
raise RuntimeError("memory bucket not initialised")
|
|
363
|
+
now_ts = clock.now().timestamp()
|
|
364
|
+
await self._mem_bucket.reset(now_ts)
|
|
365
|
+
|
|
366
|
+
async def _peek_redis(
|
|
367
|
+
self,
|
|
368
|
+
redis_client: "redis_async.Redis | None",
|
|
369
|
+
clock: Clock | None,
|
|
370
|
+
settings: "WorkerSettings | None",
|
|
371
|
+
) -> RateLimitState:
|
|
372
|
+
if redis_client is None:
|
|
373
|
+
raise RuntimeError("redis_client not injected for redis backend")
|
|
374
|
+
if clock is None:
|
|
375
|
+
raise RuntimeError("clock not injected for redis backend")
|
|
376
|
+
if settings is None:
|
|
377
|
+
raise RuntimeError("settings not injected for redis backend")
|
|
378
|
+
|
|
379
|
+
schema_name = settings.schema_name
|
|
380
|
+
key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
|
|
381
|
+
now_seconds = clock.now().timestamp()
|
|
382
|
+
|
|
383
|
+
raw = await redis_client.hmget(key, ["tokens", "ts"]) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportGeneralTypeIssues] # Why: redis-py hmget return type is untyped in the stub; all operations reflect correct runtime behavior.
|
|
384
|
+
|
|
385
|
+
tokens_raw = raw[0] if raw else None # pyright: ignore[reportUnknownVariableType] # Why: raw is untyped from redis-py hmget stub; validated at runtime.
|
|
386
|
+
ts_raw = raw[1] if raw else None # pyright: ignore[reportUnknownVariableType] # Why: raw is untyped from redis-py hmget stub; validated at runtime.
|
|
387
|
+
|
|
388
|
+
tokens = self._capacity if tokens_raw is None else float(tokens_raw) # pyright: ignore[reportUnknownArgumentType] # Why: tokens_raw type is unknown due to untyped redis-py stub; validated at runtime.
|
|
389
|
+
ts = now_seconds if ts_raw is None else float(ts_raw) # pyright: ignore[reportUnknownArgumentType] # Why: ts_raw type is unknown due to untyped redis-py stub; validated at runtime.
|
|
390
|
+
|
|
391
|
+
elapsed = max(0.0, now_seconds - ts)
|
|
392
|
+
tokens = min(self._capacity, tokens + elapsed * self._refill)
|
|
393
|
+
|
|
394
|
+
is_exhausted = tokens <= 0.0
|
|
395
|
+
retry_after: timedelta | None = None
|
|
396
|
+
if is_exhausted and self._refill > 0.0:
|
|
397
|
+
retry_seconds = (1.0 - tokens) / self._refill
|
|
398
|
+
retry_after = timedelta(seconds=max(0.0, retry_seconds))
|
|
399
|
+
|
|
400
|
+
return RateLimitState(
|
|
401
|
+
bucket_name=self._name,
|
|
402
|
+
backend="redis",
|
|
403
|
+
is_exhausted=is_exhausted,
|
|
404
|
+
tokens_remaining=tokens,
|
|
405
|
+
retry_after=retry_after,
|
|
406
|
+
capacity=self._capacity,
|
|
407
|
+
refill_per_second=self._refill,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
async def _reset_redis(
|
|
411
|
+
self,
|
|
412
|
+
redis_client: "redis_async.Redis | None",
|
|
413
|
+
settings: "WorkerSettings | None",
|
|
414
|
+
) -> None:
|
|
415
|
+
if redis_client is None:
|
|
416
|
+
raise RuntimeError("redis_client not injected for redis backend")
|
|
417
|
+
if settings is None:
|
|
418
|
+
raise RuntimeError("settings not injected for redis backend")
|
|
419
|
+
|
|
420
|
+
schema_name = settings.schema_name
|
|
421
|
+
key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
|
|
422
|
+
await redis_client.delete(key) # pyright: ignore[reportUnknownMemberType] # Why: redis-py delete return type is untyped in the stub
|
|
423
|
+
|
|
424
|
+
async def _peek_pg(
|
|
425
|
+
self,
|
|
426
|
+
pg_pool: "asyncpg.Pool | None",
|
|
427
|
+
clock: Clock | None,
|
|
428
|
+
settings: "WorkerSettings | None",
|
|
429
|
+
) -> RateLimitState:
|
|
430
|
+
if pg_pool is None:
|
|
431
|
+
raise RuntimeError("pg_pool not injected for postgres backend")
|
|
432
|
+
if settings is None:
|
|
433
|
+
raise RuntimeError("settings not injected for postgres backend")
|
|
434
|
+
if clock is None:
|
|
435
|
+
raise RuntimeError("clock not injected for postgres backend")
|
|
436
|
+
|
|
437
|
+
now = clock.now().timestamp()
|
|
438
|
+
schema = settings.schema_name
|
|
439
|
+
|
|
440
|
+
select_sql = f'SELECT state FROM "{schema}".rate_limit_buckets WHERE bucket_name=$1' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; bucket_name is $1-bound
|
|
441
|
+
|
|
442
|
+
async with pg_pool.acquire() as conn:
|
|
443
|
+
row = await conn.fetchrow(select_sql, self._name)
|
|
444
|
+
|
|
445
|
+
if row is None:
|
|
446
|
+
tokens = self._capacity
|
|
447
|
+
else:
|
|
448
|
+
state = jsonb_to_dict(row["state"])
|
|
449
|
+
tokens = float(state.get("tokens", self._capacity)) # type: ignore[index] # Why: rate_limit_buckets.state is NOT NULL; jsonb_to_dict only returns None for SQL NULL, which cannot occur here; fallback for rows missing keys (e.g. from schema migrations or interop writes)
|
|
450
|
+
ts = float(state.get("ts", now)) # type: ignore[index] # Why: same — state is non-None; fallback to now for rows missing "ts"
|
|
451
|
+
elapsed = max(0.0, now - ts)
|
|
452
|
+
tokens = min(self._capacity, tokens + elapsed * self._refill)
|
|
453
|
+
|
|
454
|
+
is_exhausted = tokens <= 0.0
|
|
455
|
+
retry_after: timedelta | None = None
|
|
456
|
+
if is_exhausted and self._refill > 0.0:
|
|
457
|
+
retry_seconds = (1.0 - tokens) / self._refill
|
|
458
|
+
retry_after = timedelta(seconds=max(0.0, retry_seconds))
|
|
459
|
+
|
|
460
|
+
return RateLimitState(
|
|
461
|
+
bucket_name=self._name,
|
|
462
|
+
backend="postgres",
|
|
463
|
+
is_exhausted=is_exhausted,
|
|
464
|
+
tokens_remaining=tokens,
|
|
465
|
+
retry_after=retry_after,
|
|
466
|
+
capacity=self._capacity,
|
|
467
|
+
refill_per_second=self._refill,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def _reset_pg(
|
|
471
|
+
self,
|
|
472
|
+
pg_pool: "asyncpg.Pool | None",
|
|
473
|
+
settings: "WorkerSettings | None",
|
|
474
|
+
) -> None:
|
|
475
|
+
if pg_pool is None:
|
|
476
|
+
raise RuntimeError("pg_pool not injected for postgres backend")
|
|
477
|
+
if settings is None:
|
|
478
|
+
raise RuntimeError("settings not injected for postgres backend")
|
|
479
|
+
|
|
480
|
+
schema = settings.schema_name
|
|
481
|
+
delete_sql = f'DELETE FROM "{schema}".rate_limit_buckets WHERE bucket_name = $1' # noqa: S608 # Why: schema_name pre-validated; bucket_name is $1-bound
|
|
482
|
+
await pg_pool.execute(delete_sql, self._name)
|
|
483
|
+
|
|
484
|
+
async def _refund_memory(self, count: float) -> None:
|
|
485
|
+
if self._mem_bucket is None:
|
|
486
|
+
raise RuntimeError("memory bucket not initialised")
|
|
487
|
+
await self._mem_bucket.refund(count)
|
|
488
|
+
|
|
489
|
+
async def _refund_redis(
|
|
490
|
+
self,
|
|
491
|
+
decision: RateLimitDecision,
|
|
492
|
+
count: float,
|
|
493
|
+
redis_client: "redis_async.Redis | None",
|
|
494
|
+
clock: Clock | None,
|
|
495
|
+
settings: "WorkerSettings | None",
|
|
496
|
+
) -> None:
|
|
497
|
+
if redis_client is None:
|
|
498
|
+
raise RuntimeError("redis_client not injected for redis backend refund")
|
|
499
|
+
if clock is None:
|
|
500
|
+
raise RuntimeError("clock not injected for redis backend refund")
|
|
501
|
+
if settings is None:
|
|
502
|
+
raise RuntimeError("settings not injected for redis backend refund")
|
|
503
|
+
|
|
504
|
+
script = await self._ensure_refund_script(redis_client)
|
|
505
|
+
|
|
506
|
+
schema_name = settings.schema_name
|
|
507
|
+
key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
|
|
508
|
+
now_seconds = clock.now().timestamp()
|
|
509
|
+
|
|
510
|
+
argv: list[float] = [count, now_seconds, self._capacity, self._refill]
|
|
511
|
+
await script(keys=[key], args=argv) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # Why: redis-py AsyncScript.__call__ has no return-type annotation; refund return value is not consumed
|
|
512
|
+
|
|
513
|
+
async def _ensure_refund_script(self, redis_client: "redis_async.Redis") -> "AsyncScript":
|
|
514
|
+
return await ensure_redis_script(
|
|
515
|
+
lambda: self._redis_refund_script,
|
|
516
|
+
lambda s: setattr(self, "_redis_refund_script", s),
|
|
517
|
+
lambda: redis_client.register_script(REFUND_SCRIPT),
|
|
518
|
+
self._script_lock,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
async def _refund_pg(
|
|
522
|
+
self,
|
|
523
|
+
count: float,
|
|
524
|
+
pg_pool: "asyncpg.Pool | None",
|
|
525
|
+
clock: Clock | None,
|
|
526
|
+
settings: "WorkerSettings | None",
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Refund tokens on the PG backend using FOR UPDATE on rate_limit_buckets.
|
|
529
|
+
|
|
530
|
+
Mirrors the Redis refund script: apply the elapsed-refill step so a
|
|
531
|
+
refund landing after idle time does not lose accrued tokens, then add
|
|
532
|
+
``count`` capped at ``capacity``. If the bucket row does not exist
|
|
533
|
+
(never created or already reset), this is a no-op.
|
|
534
|
+
"""
|
|
535
|
+
if pg_pool is None:
|
|
536
|
+
raise RuntimeError("pg_pool not injected for postgres backend refund")
|
|
537
|
+
if settings is None:
|
|
538
|
+
raise RuntimeError("settings not injected for postgres backend refund")
|
|
539
|
+
if clock is None:
|
|
540
|
+
raise RuntimeError("clock not injected for postgres backend refund")
|
|
541
|
+
|
|
542
|
+
now = clock.now().timestamp()
|
|
543
|
+
schema = settings.schema_name
|
|
544
|
+
|
|
545
|
+
select_sql = (
|
|
546
|
+
f'SELECT state FROM "{schema}".rate_limit_buckets WHERE bucket_name=$1 FOR UPDATE' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; bucket_name is $1-bound
|
|
547
|
+
)
|
|
548
|
+
update_sql = f'UPDATE "{schema}".rate_limit_buckets SET state=$1::jsonb, updated_at=now() WHERE bucket_name=$2' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; values are $1/$2-bound
|
|
549
|
+
|
|
550
|
+
async with pg_pool.acquire() as conn, conn.transaction():
|
|
551
|
+
row = await conn.fetchrow(select_sql, self._name)
|
|
552
|
+
|
|
553
|
+
if row is None:
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
state = jsonb_to_dict(row["state"])
|
|
557
|
+
tokens = float(state.get("tokens", self._capacity)) # type: ignore[index] # Why: rate_limit_buckets.state is NOT NULL; jsonb_to_dict only returns None for SQL NULL, which cannot occur here; fallback for rows missing keys (e.g. from schema migrations or interop writes)
|
|
558
|
+
ts = float(state.get("ts", now)) # type: ignore[index] # Why: same — state is non-None; fallback to now for rows missing "ts"
|
|
559
|
+
|
|
560
|
+
elapsed = max(0.0, now - ts)
|
|
561
|
+
tokens = min(self._capacity, tokens + elapsed * self._refill)
|
|
562
|
+
tokens = min(self._capacity, tokens + count)
|
|
563
|
+
|
|
564
|
+
state_param = jsonb_param({"tokens": tokens, "ts": now})
|
|
565
|
+
await conn.execute(update_sql, state_param, self._name)
|
|
566
|
+
|
|
567
|
+
async def _acquire_memory(self, count: float, clock: Clock | None) -> RateLimitDecision:
|
|
568
|
+
if clock is None:
|
|
569
|
+
raise RuntimeError("clock not injected for memory backend")
|
|
570
|
+
if self._mem_bucket is None:
|
|
571
|
+
raise RuntimeError("memory bucket not initialised")
|
|
572
|
+
|
|
573
|
+
now_ts = clock.now().timestamp()
|
|
574
|
+
result = await self._mem_bucket.acquire(count, now_ts)
|
|
575
|
+
log_decision(result)
|
|
576
|
+
return result
|
|
577
|
+
|
|
578
|
+
async def _acquire_redis(
|
|
579
|
+
self,
|
|
580
|
+
count: float,
|
|
581
|
+
redis_client: "redis_async.Redis | None",
|
|
582
|
+
clock: Clock | None,
|
|
583
|
+
settings: "WorkerSettings | None",
|
|
584
|
+
) -> RateLimitDecision:
|
|
585
|
+
if redis_client is None:
|
|
586
|
+
raise RuntimeError("redis_client not injected for redis backend")
|
|
587
|
+
if settings is None:
|
|
588
|
+
raise RuntimeError("settings not injected for redis backend")
|
|
589
|
+
if clock is None:
|
|
590
|
+
raise RuntimeError("clock not injected for redis backend")
|
|
591
|
+
|
|
592
|
+
script = await self._ensure_script(redis_client)
|
|
593
|
+
|
|
594
|
+
schema_name = settings.schema_name
|
|
595
|
+
key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
|
|
596
|
+
|
|
597
|
+
now_seconds = clock.now().timestamp()
|
|
598
|
+
ttl_seconds = self._compute_ttl_seconds()
|
|
599
|
+
|
|
600
|
+
argv: list[float | int] = [
|
|
601
|
+
now_seconds,
|
|
602
|
+
self._capacity,
|
|
603
|
+
self._refill,
|
|
604
|
+
count,
|
|
605
|
+
ttl_seconds,
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
raw: list[object] = await script(keys=[key], args=argv) # pyright: ignore[reportAssignmentType, reportUnknownMemberType, reportUnknownVariableType] # Why: redis-py AsyncScript.__call__ has no return-type annotation — pyright cannot model the return shape; the three-element list structure is guaranteed by the Lua script contract
|
|
609
|
+
|
|
610
|
+
lua = _decode_lua_result(raw)
|
|
611
|
+
|
|
612
|
+
retry_after: timedelta | None
|
|
613
|
+
if lua.allowed:
|
|
614
|
+
retry_after = timedelta(0)
|
|
615
|
+
elif self._refill == 0.0:
|
|
616
|
+
retry_after = None
|
|
617
|
+
else:
|
|
618
|
+
retry_after = timedelta(seconds=lua.retry_after_seconds)
|
|
619
|
+
|
|
620
|
+
result = RateLimitDecision(
|
|
621
|
+
allowed=lua.allowed,
|
|
622
|
+
remaining=lua.tokens_remaining,
|
|
623
|
+
retry_after=retry_after,
|
|
624
|
+
bucket_name=self._name,
|
|
625
|
+
backend="redis",
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
log_decision(result)
|
|
629
|
+
return result
|
|
630
|
+
|
|
631
|
+
async def _ensure_script(self, redis_client: "redis_async.Redis") -> "AsyncScript":
|
|
632
|
+
return await ensure_redis_script(
|
|
633
|
+
lambda: self._redis_script,
|
|
634
|
+
lambda s: setattr(self, "_redis_script", s),
|
|
635
|
+
lambda: redis_client.register_script(TOKEN_BUCKET_SCRIPT),
|
|
636
|
+
self._script_lock,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
async def _acquire_redis_wrapped(
|
|
640
|
+
self,
|
|
641
|
+
count: float,
|
|
642
|
+
redis_client: "redis_async.Redis | None",
|
|
643
|
+
pg_pool: "asyncpg.Pool | None",
|
|
644
|
+
clock: Clock | None,
|
|
645
|
+
settings: "WorkerSettings | None",
|
|
646
|
+
) -> RateLimitDecision:
|
|
647
|
+
"""Redis path with optional PG fallback on ConnectionError/TimeoutError."""
|
|
648
|
+
return await with_pg_fallback(
|
|
649
|
+
self._acquire_redis(count, redis_client, clock, settings),
|
|
650
|
+
lambda: self._acquire_pg(count, pg_pool, clock, settings),
|
|
651
|
+
bucket_name=self._name,
|
|
652
|
+
settings=settings,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
async def _acquire_pg(
|
|
656
|
+
self,
|
|
657
|
+
count: float,
|
|
658
|
+
pg_pool: "asyncpg.Pool | None",
|
|
659
|
+
clock: Clock | None,
|
|
660
|
+
settings: "WorkerSettings | None",
|
|
661
|
+
) -> RateLimitDecision:
|
|
662
|
+
"""PG fallback path using FOR UPDATE on rate_limit_buckets.
|
|
663
|
+
|
|
664
|
+
Runs in a single transaction: SELECT … FOR UPDATE (blocking, NOT SKIP
|
|
665
|
+
LOCKED), compute token arithmetic in Python, upsert the new state.
|
|
666
|
+
"""
|
|
667
|
+
if pg_pool is None:
|
|
668
|
+
raise RuntimeError("pg_pool not injected for postgres backend")
|
|
669
|
+
if settings is None:
|
|
670
|
+
raise RuntimeError("settings not injected for postgres backend")
|
|
671
|
+
if clock is None:
|
|
672
|
+
raise RuntimeError("clock not injected for postgres backend")
|
|
673
|
+
|
|
674
|
+
now = clock.now().timestamp()
|
|
675
|
+
schema = settings.schema_name
|
|
676
|
+
|
|
677
|
+
# Schema-name interpolation ; schema_name is
|
|
678
|
+
# pre-validated against _IDENT_RE at WorkerSettings load time.
|
|
679
|
+
preseed_sql = (
|
|
680
|
+
f'INSERT INTO "{schema}".rate_limit_buckets (bucket_name, kind, state, updated_at) ' # noqa: S608 # Why: schema_name pre-validated; values are $1/$2-bound
|
|
681
|
+
f"VALUES ($1, 'token_bucket', $2::jsonb, now()) "
|
|
682
|
+
f"ON CONFLICT (bucket_name) DO NOTHING"
|
|
683
|
+
)
|
|
684
|
+
select_sql = (
|
|
685
|
+
f'SELECT state FROM "{schema}".rate_limit_buckets WHERE bucket_name=$1 FOR UPDATE' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; bucket_name is $1-bound
|
|
686
|
+
)
|
|
687
|
+
upsert_sql = (
|
|
688
|
+
f'INSERT INTO "{schema}".rate_limit_buckets (bucket_name, kind, state, updated_at) ' # noqa: S608 # Why: schema_name pre-validated; values are $1/$2-bound
|
|
689
|
+
f"VALUES ($1, 'token_bucket', $2::jsonb, now()) "
|
|
690
|
+
f"ON CONFLICT (bucket_name) DO UPDATE SET state=EXCLUDED.state, updated_at=now()"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
async with pg_pool.acquire() as conn, conn.transaction():
|
|
694
|
+
# Cold-start guard: SELECT ... FOR UPDATE cannot lock a row that
|
|
695
|
+
# does not exist yet, so concurrent first acquires would each
|
|
696
|
+
# read `row is None` and independently admit up to `capacity`
|
|
697
|
+
# tokens. Pre-seed a full-capacity row (idempotent — DO NOTHING
|
|
698
|
+
# on conflict) so the very first acquire also serializes on the
|
|
699
|
+
# row lock below.
|
|
700
|
+
preseed_state = jsonb_param({"tokens": self._capacity, "ts": now})
|
|
701
|
+
await conn.execute(preseed_sql, self._name, preseed_state)
|
|
702
|
+
row = await conn.fetchrow(select_sql, self._name)
|
|
703
|
+
|
|
704
|
+
if row is None:
|
|
705
|
+
# Unreachable in the normal path — the preseed above guarantees
|
|
706
|
+
# the row exists before the SELECT. Kept as a defensive fallback
|
|
707
|
+
# (e.g. a concurrent DELETE between preseed and select).
|
|
708
|
+
tokens = self._capacity
|
|
709
|
+
ts = now
|
|
710
|
+
else:
|
|
711
|
+
state = jsonb_to_dict(row["state"])
|
|
712
|
+
tokens = float(state.get("tokens", self._capacity)) # type: ignore[index] # Why: rate_limit_buckets.state is NOT NULL; jsonb_to_dict only returns None for SQL NULL, which cannot occur here; fallback for rows missing keys (e.g. from schema migrations or interop writes)
|
|
713
|
+
ts = float(state.get("ts", now)) # type: ignore[index] # Why: same — state is non-None; fallback to now for rows missing "ts"
|
|
714
|
+
|
|
715
|
+
elapsed = max(0.0, now - ts)
|
|
716
|
+
tokens = min(self._capacity, tokens + elapsed * self._refill)
|
|
717
|
+
|
|
718
|
+
retry_after: timedelta | None
|
|
719
|
+
allowed: bool
|
|
720
|
+
|
|
721
|
+
if tokens >= count:
|
|
722
|
+
tokens -= count
|
|
723
|
+
allowed = True
|
|
724
|
+
retry_after = timedelta(0)
|
|
725
|
+
else:
|
|
726
|
+
allowed = False
|
|
727
|
+
if self._refill == 0.0:
|
|
728
|
+
retry_after = None
|
|
729
|
+
else:
|
|
730
|
+
retry_after_seconds = (count - tokens) / self._refill
|
|
731
|
+
retry_after = timedelta(seconds=retry_after_seconds)
|
|
732
|
+
|
|
733
|
+
# _jsonb_param serializes via orjson — passing a dict directly
|
|
734
|
+
# to conn.execute fails because asyncpg does not auto-encode
|
|
735
|
+
# Python dicts as jsonb.
|
|
736
|
+
state_param = jsonb_param({"tokens": tokens, "ts": now})
|
|
737
|
+
# updated_at uses server-side now(); state ts is client-supplied
|
|
738
|
+
await conn.execute(upsert_sql, self._name, state_param)
|
|
739
|
+
|
|
740
|
+
result = RateLimitDecision(
|
|
741
|
+
allowed=allowed,
|
|
742
|
+
remaining=tokens,
|
|
743
|
+
retry_after=retry_after,
|
|
744
|
+
bucket_name=self._name,
|
|
745
|
+
backend="postgres",
|
|
746
|
+
)
|
|
747
|
+
log_decision(result)
|
|
748
|
+
return result
|
|
749
|
+
|
|
750
|
+
def _compute_ttl_seconds(self) -> int:
|
|
751
|
+
"""Compute the TTL for the Redis key based on bucket parameters."""
|
|
752
|
+
if self._refill == 0.0:
|
|
753
|
+
return 86400
|
|
754
|
+
return math.ceil(self._capacity / self._refill * 2) + 60
|