webagent-protocol 0.2.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.
@@ -0,0 +1,30 @@
1
+ """Pluggable state storage for WAP servers (in-memory by default, Redis for multi-worker deployments)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .base import LockTimeout, StateStore
8
+ from .memory import MemoryStore
9
+ from .meters import MeterTable, RateLimitDecision, SlidingWindowCounter, TokenBucket
10
+
11
+
12
+ def __getattr__(name: str) -> Any:
13
+ # RedisStore needs the optional ``redis`` package; import it lazily.
14
+ if name == "RedisStore":
15
+ from .redis import RedisStore
16
+
17
+ return RedisStore
18
+ raise AttributeError(name)
19
+
20
+
21
+ __all__ = [
22
+ "LockTimeout",
23
+ "MemoryStore",
24
+ "MeterTable",
25
+ "RateLimitDecision",
26
+ "RedisStore",
27
+ "SlidingWindowCounter",
28
+ "StateStore",
29
+ "TokenBucket",
30
+ ]
wap/storage/base.py ADDED
@@ -0,0 +1,70 @@
1
+ """The storage interface every piece of server state goes through.
2
+
3
+ A WAP server keeps several kinds of short-lived state: rate-limit meters,
4
+ replay caches, spent proof-of-work seeds, dialogue sessions, loop-guard
5
+ history and idempotency records. By default all of it lives in process memory
6
+ (:class:`~wap.storage.memory.MemoryStore`), which is right for a single worker.
7
+ Deployments with several workers or machines pass a shared backend such as
8
+ :class:`~wap.storage.redis.RedisStore` so that limits, replay protection and
9
+ sessions hold across all of them::
10
+
11
+ from wap.storage import RedisStore
12
+ wap = WAPServer(..., store=RedisStore.from_url("redis://localhost:6379/0"))
13
+
14
+ Values are JSON documents. Implementations must make :meth:`StateStore.add`
15
+ and :meth:`StateStore.rate_limit` atomic across every process sharing the store.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from contextlib import AbstractAsyncContextManager
21
+ from typing import Any, Protocol, runtime_checkable
22
+
23
+ from .meters import RateLimitDecision
24
+
25
+
26
+ class LockTimeout(Exception):
27
+ """A named lock could not be acquired within the allowed wait."""
28
+
29
+
30
+ @runtime_checkable
31
+ class StateStore(Protocol):
32
+ """Async key/value store with TTLs, atomic set-if-absent, locks and rate limiting."""
33
+
34
+ async def get(self, key: str) -> Any | None:
35
+ """Return the JSON value stored at ``key`` (``None`` if missing or expired)."""
36
+ ...
37
+
38
+ async def set(self, key: str, value: Any, ttl: float | None = None) -> None:
39
+ """Store a JSON-serialisable ``value``, expiring after ``ttl`` seconds if given."""
40
+ ...
41
+
42
+ async def add(self, key: str, value: Any, ttl: float | None = None) -> bool:
43
+ """Atomically store ``value`` only if ``key`` is absent. Returns ``True`` if stored."""
44
+ ...
45
+
46
+ async def delete(self, key: str) -> None: ...
47
+
48
+ async def incr(self, key: str, ttl: float) -> int:
49
+ """Atomically increment an integer counter, creating it with ``ttl`` if absent."""
50
+ ...
51
+
52
+ def lock(self, key: str, *, timeout: float, wait: float) -> AbstractAsyncContextManager[None]:
53
+ """Mutual exclusion on ``key`` across all processes sharing the store.
54
+
55
+ ``timeout`` bounds how long the lock may be held (so a crashed holder
56
+ cannot block forever); ``wait`` bounds how long to wait for it before
57
+ raising :class:`LockTimeout`.
58
+ """
59
+ ...
60
+
61
+ async def rate_limit(
62
+ self, keys: list[tuple[str, str]], *, limit: int, window: float, burst: int, cost: float, now: float
63
+ ) -> RateLimitDecision:
64
+ """Atomically check and (if admitted) charge every ``(scope, key)`` meter."""
65
+ ...
66
+
67
+ async def aclose(self) -> None: ...
68
+
69
+
70
+ __all__ = ["LockTimeout", "StateStore"]
wap/storage/memory.py ADDED
@@ -0,0 +1,115 @@
1
+ """In-process :class:`StateStore`: the zero-configuration default for single-worker servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from collections import OrderedDict
9
+ from collections.abc import AsyncIterator, Callable
10
+ from contextlib import asynccontextmanager
11
+ from typing import Any
12
+
13
+ from .base import LockTimeout
14
+ from .meters import MeterTable, RateLimitDecision
15
+
16
+
17
+ class MemoryStore:
18
+ """Dictionary-backed store with TTLs, LRU bounding and asyncio locks.
19
+
20
+ Values are stored as JSON text, exactly as a networked backend would, so code
21
+ that works against this store behaves the same against Redis. State is lost on
22
+ restart and is not shared between processes; use a shared backend for that.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ max_entries: int = 200_000,
29
+ max_rate_keys: int = 100_000,
30
+ rate_idle_ttl: float = 600.0,
31
+ clock: Callable[[], float] = time.time,
32
+ ) -> None:
33
+ self.max_entries = max_entries
34
+ self._clock = clock
35
+ self._data: OrderedDict[str, tuple[str, float | None]] = OrderedDict()
36
+ self._locks: dict[str, asyncio.Lock] = {}
37
+ self.meters = MeterTable(idle_ttl=rate_idle_ttl, max_keys=max_rate_keys)
38
+ self._meter_lock = asyncio.Lock()
39
+
40
+ def _live(self, key: str) -> str | None:
41
+ entry = self._data.get(key)
42
+ if entry is None:
43
+ return None
44
+ raw, expires = entry
45
+ if expires is not None and expires <= self._clock():
46
+ del self._data[key]
47
+ return None
48
+ return raw
49
+
50
+ def _put(self, key: str, value: Any, ttl: float | None) -> None:
51
+ expires = self._clock() + ttl if ttl is not None else None
52
+ self._data[key] = (json.dumps(value, separators=(",", ":")), expires)
53
+ self._data.move_to_end(key)
54
+ if len(self._data) > self.max_entries:
55
+ now = self._clock()
56
+ for stale in [k for k, (_, exp) in self._data.items() if exp is not None and exp <= now]:
57
+ del self._data[stale]
58
+ while len(self._data) > self.max_entries:
59
+ self._data.popitem(last=False)
60
+
61
+ async def get(self, key: str) -> Any | None:
62
+ raw = self._live(key)
63
+ return None if raw is None else json.loads(raw)
64
+
65
+ async def set(self, key: str, value: Any, ttl: float | None = None) -> None:
66
+ self._put(key, value, ttl)
67
+
68
+ async def add(self, key: str, value: Any, ttl: float | None = None) -> bool:
69
+ if self._live(key) is not None:
70
+ return False
71
+ self._put(key, value, ttl)
72
+ return True
73
+
74
+ async def delete(self, key: str) -> None:
75
+ self._data.pop(key, None)
76
+
77
+ async def incr(self, key: str, ttl: float) -> int:
78
+ raw = self._live(key)
79
+ if raw is None:
80
+ self._put(key, 1, ttl)
81
+ return 1
82
+ value = int(json.loads(raw)) + 1
83
+ expires = self._data[key][1]
84
+ self._data[key] = (json.dumps(value), expires)
85
+ return value
86
+
87
+ @asynccontextmanager
88
+ async def lock(self, key: str, *, timeout: float, wait: float) -> AsyncIterator[None]:
89
+ lock = self._locks.setdefault(key, asyncio.Lock())
90
+ try:
91
+ await asyncio.wait_for(lock.acquire(), timeout=wait)
92
+ except TimeoutError as exc:
93
+ raise LockTimeout(key) from exc
94
+ try:
95
+ yield
96
+ finally:
97
+ lock.release()
98
+ if not lock.locked() and not getattr(lock, "_waiters", None):
99
+ self._locks.pop(key, None)
100
+
101
+ async def rate_limit(
102
+ self, keys: list[tuple[str, str]], *, limit: int, window: float, burst: int, cost: float, now: float
103
+ ) -> RateLimitDecision:
104
+ async with self._meter_lock:
105
+ return self.meters.check(keys, limit=limit, window=window, burst=burst, cost=cost, now=now)
106
+
107
+ async def aclose(self) -> None:
108
+ self._data.clear()
109
+ self.meters.clear()
110
+
111
+ def __len__(self) -> int:
112
+ return len(self._data)
113
+
114
+
115
+ __all__ = ["MemoryStore"]
wap/storage/meters.py ADDED
@@ -0,0 +1,204 @@
1
+ """Rate-limiting algorithms shared by the storage backends.
2
+
3
+ Each principal gets two meters:
4
+
5
+ * a **sliding-window counter** bounding the sustained rate (``limit`` requests
6
+ over a rolling ``window``), and
7
+ * a **token bucket** bounding bursts (``burst`` tokens refilled at
8
+ ``limit / window`` per second).
9
+
10
+ A request is admitted only if *every* meter it touches admits it, and meters
11
+ are charged only on admission. :class:`MeterTable` is the in-process
12
+ implementation; the Redis backend runs the same arithmetic in a Lua script.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RateLimitDecision:
23
+ allowed: bool
24
+ limit: int
25
+ remaining: int
26
+ retry_after: float
27
+ scope: str | None = None
28
+
29
+ def headers(self) -> dict[str, str]:
30
+ """Standard ``RateLimit-*`` style headers for this decision."""
31
+ headers = {
32
+ "X-RateLimit-Limit": str(self.limit),
33
+ "X-RateLimit-Remaining": str(max(0, self.remaining)),
34
+ }
35
+ if not self.allowed:
36
+ headers["Retry-After"] = str(max(1, math.ceil(self.retry_after)))
37
+ return headers
38
+
39
+
40
+ class TokenBucket:
41
+ """Classic token bucket: ``capacity`` tokens, refilled at ``rate`` tokens/second."""
42
+
43
+ __slots__ = ("capacity", "rate", "tokens", "updated")
44
+
45
+ def __init__(self, capacity: float, rate: float, now: float) -> None:
46
+ self.capacity = float(capacity)
47
+ self.rate = float(rate)
48
+ self.tokens = float(capacity)
49
+ self.updated = now
50
+
51
+ def _refill(self, now: float) -> None:
52
+ elapsed = max(0.0, now - self.updated)
53
+ self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
54
+ self.updated = now
55
+
56
+ def peek(self, now: float, cost: float = 1.0) -> float:
57
+ """Seconds until ``cost`` tokens are available (0 if available now)."""
58
+ self._refill(now)
59
+ if self.tokens >= cost:
60
+ return 0.0
61
+ return (cost - self.tokens) / self.rate
62
+
63
+ def consume(self, now: float, cost: float = 1.0) -> None:
64
+ self._refill(now)
65
+ self.tokens -= cost
66
+
67
+ @property
68
+ def remaining(self) -> int:
69
+ return int(self.tokens)
70
+
71
+
72
+ class SlidingWindowCounter:
73
+ """Sliding-window counter (Cloudflare-style weighted two-bucket approximation).
74
+
75
+ The estimated count over the last ``window`` seconds is
76
+ ``previous * (1 - elapsed_fraction) + current``. Memory is O(1) per key and
77
+ the error versus a true sliding log is bounded by one window's skew.
78
+ """
79
+
80
+ __slots__ = ("limit", "window", "window_start", "current", "previous")
81
+
82
+ def __init__(self, limit: int, window: float, now: float) -> None:
83
+ self.limit = limit
84
+ self.window = window
85
+ self.window_start = now - (now % window)
86
+ self.current = 0
87
+ self.previous = 0
88
+
89
+ def _roll(self, now: float) -> None:
90
+ start = now - (now % self.window)
91
+ if start == self.window_start:
92
+ return
93
+ if start - self.window_start >= 2 * self.window:
94
+ self.previous = 0
95
+ else:
96
+ self.previous = self.current
97
+ self.current = 0
98
+ self.window_start = start
99
+
100
+ def estimate(self, now: float) -> float:
101
+ self._roll(now)
102
+ elapsed = (now - self.window_start) / self.window
103
+ return self.previous * (1.0 - elapsed) + self.current
104
+
105
+ def peek(self, now: float) -> float:
106
+ """Seconds until one more request fits (0 if it fits now)."""
107
+ estimate = self.estimate(now)
108
+ if estimate + 1 <= self.limit:
109
+ return 0.0
110
+ if self.current + 1 > self.limit:
111
+ return self.window_start + self.window - now
112
+ if self.previous == 0:
113
+ return self.window_start + self.window - now
114
+ # Solve previous * (1 - (t - start)/window) + current + 1 <= limit for t.
115
+ needed_fraction = 1.0 - (self.limit - self.current - 1) / self.previous
116
+ target = self.window_start + needed_fraction * self.window
117
+ return max(0.0, target - now)
118
+
119
+ def consume(self, now: float) -> None:
120
+ self._roll(now)
121
+ self.current += 1
122
+
123
+ def remaining(self, now: float) -> int:
124
+ return max(0, int(self.limit - self.estimate(now)))
125
+
126
+
127
+ class Meter:
128
+ __slots__ = ("window", "bucket", "last_seen", "params")
129
+
130
+ def __init__(self, limit: int, window: float, burst: int, now: float) -> None:
131
+ self.window = SlidingWindowCounter(limit, window, now)
132
+ self.bucket = TokenBucket(burst, limit / window, now)
133
+ self.last_seen = now
134
+ self.params = (limit, window, burst)
135
+
136
+ def wait_time(self, now: float, cost: float) -> float:
137
+ return max(self.window.peek(now), self.bucket.peek(now, cost))
138
+
139
+ def consume(self, now: float, cost: float) -> None:
140
+ self.window.consume(now)
141
+ self.bucket.consume(now, cost)
142
+ self.last_seen = now
143
+
144
+ def remaining(self, now: float) -> int:
145
+ return min(self.window.remaining(now), self.bucket.remaining)
146
+
147
+
148
+ class MeterTable:
149
+ """Bounded in-process table of meters with idle eviction."""
150
+
151
+ def __init__(self, *, idle_ttl: float = 600.0, max_keys: int = 100_000) -> None:
152
+ self.idle_ttl = idle_ttl
153
+ self.max_keys = max_keys
154
+ self._meters: dict[str, Meter] = {}
155
+ self._last_gc: float | None = None
156
+
157
+ def _gc(self, now: float, window: float) -> None:
158
+ if self._last_gc is None:
159
+ self._last_gc = now
160
+ if now - self._last_gc < window and len(self._meters) < self.max_keys:
161
+ return
162
+ cutoff = now - self.idle_ttl
163
+ for key in [k for k, m in self._meters.items() if m.last_seen < cutoff]:
164
+ del self._meters[key]
165
+ if len(self._meters) >= self.max_keys:
166
+ # Evict least-recently-seen principals to bound memory under key-spraying.
167
+ ordered = sorted(self._meters.items(), key=lambda item: item[1].last_seen)
168
+ for key, _ in ordered[: len(self._meters) - self.max_keys + 1]:
169
+ del self._meters[key]
170
+ self._last_gc = now
171
+
172
+ def check(
173
+ self, keys: list[tuple[str, str]], *, limit: int, window: float, burst: int, cost: float, now: float
174
+ ) -> RateLimitDecision:
175
+ self._gc(now, window)
176
+ meters = []
177
+ for scope, key in keys:
178
+ meter = self._meters.get(key)
179
+ if meter is None or meter.params != (limit, window, burst):
180
+ meter = self._meters[key] = Meter(limit, window, burst, now)
181
+ meters.append((scope, meter))
182
+ worst_scope, worst_wait = None, 0.0
183
+ for scope, meter in meters:
184
+ wait = meter.wait_time(now, cost)
185
+ if wait > worst_wait:
186
+ worst_scope, worst_wait = scope, wait
187
+ if worst_wait > 0:
188
+ remaining = min(m.remaining(now) for _, m in meters)
189
+ return RateLimitDecision(
190
+ allowed=False, limit=limit, remaining=remaining, retry_after=worst_wait, scope=worst_scope
191
+ )
192
+ for _, meter in meters:
193
+ meter.consume(now, cost)
194
+ remaining = min(m.remaining(now) for _, m in meters)
195
+ return RateLimitDecision(allowed=True, limit=limit, remaining=remaining, retry_after=0.0)
196
+
197
+ def clear(self) -> None:
198
+ self._meters.clear()
199
+
200
+ def __len__(self) -> int:
201
+ return len(self._meters)
202
+
203
+
204
+ __all__ = ["Meter", "MeterTable", "RateLimitDecision", "SlidingWindowCounter", "TokenBucket"]
wap/storage/redis.py ADDED
@@ -0,0 +1,202 @@
1
+ """Redis-backed :class:`StateStore` for multi-worker and multi-machine deployments.
2
+
3
+ Install with ``pip install "webagent-protocol[redis]"`` and pass it to the server::
4
+
5
+ from wap.storage import RedisStore
6
+ wap = WAPServer(..., store=RedisStore.from_url("redis://redis:6379/0", prefix="bakery:"))
7
+
8
+ All workers pointing at the same Redis database (and prefix) share rate limits,
9
+ replay protection, spent proof-of-work seeds, sessions, loop guards and
10
+ idempotency records. Rate limiting runs as a single Lua script, so the
11
+ check-and-charge of every meter involved in a request is atomic.
12
+
13
+ Keys are namespaced with ``prefix``. Multi-key rate-limit calls must hash to one
14
+ slot on Redis Cluster; use a single-shard deployment or a ``prefix`` containing a
15
+ hash tag such as ``"{wap}:"`` if you run a cluster.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import secrets
22
+ from collections.abc import AsyncIterator
23
+ from contextlib import asynccontextmanager
24
+ from typing import Any
25
+
26
+ try:
27
+ from redis.asyncio import Redis
28
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
29
+ raise ImportError('RedisStore requires the redis extra: pip install "webagent-protocol[redis]"') from exc
30
+
31
+ from .base import LockTimeout
32
+ from .meters import RateLimitDecision
33
+
34
+ # KEYS: meter keys. ARGV: limit, window, burst, cost, now, idle_ttl_ms.
35
+ # Returns {allowed(0/1), retry_after(string), worst_index, remaining}.
36
+ _RATE_LIMIT_LUA = """
37
+ local limit = tonumber(ARGV[1])
38
+ local window = tonumber(ARGV[2])
39
+ local burst = tonumber(ARGV[3])
40
+ local cost = tonumber(ARGV[4])
41
+ local now = tonumber(ARGV[5])
42
+ local idle_ms = tonumber(ARGV[6])
43
+ local rate = limit / window
44
+ local state = {}
45
+ local worst, worst_i = 0, 0
46
+ for i, key in ipairs(KEYS) do
47
+ local v = redis.call('HMGET', key, 'ws', 'cur', 'prev', 'tokens', 'upd')
48
+ local ws, cur, prev, tokens, upd
49
+ if v[1] then
50
+ ws, cur, prev = tonumber(v[1]), tonumber(v[2]), tonumber(v[3])
51
+ tokens, upd = tonumber(v[4]), tonumber(v[5])
52
+ else
53
+ ws, cur, prev, tokens, upd = now - (now % window), 0, 0, burst, now
54
+ end
55
+ local start = now - (now % window)
56
+ if start ~= ws then
57
+ if start - ws >= 2 * window then prev = 0 else prev = cur end
58
+ cur = 0
59
+ ws = start
60
+ end
61
+ local elapsed = now - upd
62
+ if elapsed < 0 then elapsed = 0 end
63
+ tokens = math.min(burst, tokens + elapsed * rate)
64
+ upd = now
65
+ local estimate = prev * (1 - (now - ws) / window) + cur
66
+ local wait_w = 0
67
+ if estimate + 1 > limit then
68
+ if cur + 1 > limit or prev == 0 then
69
+ wait_w = ws + window - now
70
+ else
71
+ local needed = 1 - (limit - cur - 1) / prev
72
+ wait_w = math.max(0, ws + needed * window - now)
73
+ end
74
+ end
75
+ local wait_b = 0
76
+ if tokens < cost then wait_b = (cost - tokens) / rate end
77
+ local wait = math.max(wait_w, wait_b)
78
+ if wait > worst then worst, worst_i = wait, i end
79
+ state[i] = {ws, cur, prev, tokens, upd}
80
+ end
81
+ local allowed = 1
82
+ if worst > 0 then allowed = 0 end
83
+ local remaining = nil
84
+ for i, key in ipairs(KEYS) do
85
+ local s = state[i]
86
+ if allowed == 1 then
87
+ s[2] = s[2] + 1
88
+ s[4] = s[4] - cost
89
+ end
90
+ redis.call('HSET', key, 'ws', tostring(s[1]), 'cur', tostring(s[2]), 'prev', tostring(s[3]),
91
+ 'tokens', tostring(s[4]), 'upd', tostring(s[5]))
92
+ redis.call('PEXPIRE', key, idle_ms)
93
+ local est = s[3] * (1 - (now - s[1]) / window) + s[2]
94
+ local r = math.min(math.floor(limit - est), math.floor(s[4]))
95
+ if r < 0 then r = 0 end
96
+ if remaining == nil or r < remaining then remaining = r end
97
+ end
98
+ return {allowed, tostring(worst), worst_i, remaining}
99
+ """
100
+
101
+ _RELEASE_LUA = """
102
+ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
103
+ return 0
104
+ """
105
+
106
+
107
+ class RedisStore:
108
+ """Shared state in Redis. Safe to use from many workers and machines at once."""
109
+
110
+ def __init__(
111
+ self,
112
+ client: Redis,
113
+ *,
114
+ prefix: str = "wap:",
115
+ rate_idle_ttl: float = 600.0,
116
+ lock_poll_interval: float = 0.05,
117
+ ) -> None:
118
+ self.redis = client
119
+ self.prefix = prefix
120
+ self.rate_idle_ttl = rate_idle_ttl
121
+ self.lock_poll_interval = lock_poll_interval
122
+ self._rate_script = client.register_script(_RATE_LIMIT_LUA)
123
+ self._release_script = client.register_script(_RELEASE_LUA)
124
+
125
+ @classmethod
126
+ def from_url(cls, url: str, **kwargs: Any) -> RedisStore:
127
+ """Connect with a ``redis://`` / ``rediss://`` URL (``rediss`` for TLS)."""
128
+ store_kwargs = {k: kwargs.pop(k) for k in ("prefix", "rate_idle_ttl", "lock_poll_interval") if k in kwargs}
129
+ return cls(Redis.from_url(url, **kwargs), **store_kwargs)
130
+
131
+ def _k(self, key: str) -> str:
132
+ return self.prefix + key
133
+
134
+ async def get(self, key: str) -> Any | None:
135
+ raw = await self.redis.get(self._k(key))
136
+ return None if raw is None else json.loads(raw)
137
+
138
+ async def set(self, key: str, value: Any, ttl: float | None = None) -> None:
139
+ px = max(1, int(ttl * 1000)) if ttl is not None else None
140
+ await self.redis.set(self._k(key), json.dumps(value, separators=(",", ":")), px=px)
141
+
142
+ async def add(self, key: str, value: Any, ttl: float | None = None) -> bool:
143
+ px = max(1, int(ttl * 1000)) if ttl is not None else None
144
+ stored = await self.redis.set(self._k(key), json.dumps(value, separators=(",", ":")), px=px, nx=True)
145
+ return bool(stored)
146
+
147
+ async def delete(self, key: str) -> None:
148
+ await self.redis.delete(self._k(key))
149
+
150
+ async def incr(self, key: str, ttl: float) -> int:
151
+ name = self._k(key)
152
+ pipe = self.redis.pipeline(transaction=True)
153
+ pipe.incr(name)
154
+ pipe.pexpire(name, max(1, int(ttl * 1000)), nx=True)
155
+ value, _ = await pipe.execute()
156
+ return int(value)
157
+
158
+ @asynccontextmanager
159
+ async def lock(self, key: str, *, timeout: float, wait: float) -> AsyncIterator[None]:
160
+ import anyio
161
+
162
+ name = self._k("lock:" + key)
163
+ token = secrets.token_hex(16)
164
+ px = max(1, int(timeout * 1000))
165
+ acquired = False
166
+ with anyio.move_on_after(wait):
167
+ while not acquired:
168
+ acquired = bool(await self.redis.set(name, token, px=px, nx=True))
169
+ if not acquired:
170
+ await anyio.sleep(self.lock_poll_interval)
171
+ if not acquired:
172
+ raise LockTimeout(key)
173
+ try:
174
+ yield
175
+ finally:
176
+ # Only release if we still own it (the lock may have expired and been re-taken).
177
+ await self._release_script(keys=[name], args=[token])
178
+
179
+ async def rate_limit(
180
+ self, keys: list[tuple[str, str]], *, limit: int, window: float, burst: int, cost: float, now: float
181
+ ) -> RateLimitDecision:
182
+ if not keys:
183
+ return RateLimitDecision(allowed=True, limit=limit, remaining=limit, retry_after=0.0)
184
+ redis_keys = [self._k(f"rl:{limit}:{window}:{burst}:{key}") for _, key in keys]
185
+ allowed, retry_after, worst_index, remaining = await self._rate_script(
186
+ keys=redis_keys,
187
+ args=[limit, window, burst, cost, repr(float(now)), int(self.rate_idle_ttl * 1000)],
188
+ )
189
+ scope = keys[int(worst_index) - 1][0] if int(worst_index) > 0 else None
190
+ return RateLimitDecision(
191
+ allowed=bool(int(allowed)),
192
+ limit=limit,
193
+ remaining=int(remaining),
194
+ retry_after=float(retry_after),
195
+ scope=None if int(allowed) else scope,
196
+ )
197
+
198
+ async def aclose(self) -> None:
199
+ await self.redis.aclose()
200
+
201
+
202
+ __all__ = ["RedisStore"]