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.
- wap/__init__.py +75 -0
- wap/cli/__init__.py +5 -0
- wap/cli/main.py +369 -0
- wap/client/__init__.py +47 -0
- wap/client/dnskey.py +82 -0
- wap/client/exceptions.py +137 -0
- wap/client/resolver.py +361 -0
- wap/client/session.py +621 -0
- wap/mcp/__init__.py +5 -0
- wap/mcp/bridge.py +793 -0
- wap/mcp/safety.py +120 -0
- wap/py.typed +0 -0
- wap/server/__init__.py +29 -0
- wap/server/admission.py +117 -0
- wap/server/app.py +1071 -0
- wap/server/mcp_endpoint.py +305 -0
- wap/server/mcp_import.py +122 -0
- wap/server/middleware.py +111 -0
- wap/server/observability.py +102 -0
- wap/server/rate_limiter.py +90 -0
- wap/server/router.py +358 -0
- wap/server/state.py +117 -0
- wap/spec/__init__.py +75 -0
- wap/spec/conversation.py +231 -0
- wap/spec/crypto.py +216 -0
- wap/spec/jcs.py +132 -0
- wap/spec/models.py +453 -0
- wap/spec/pow.py +230 -0
- wap/storage/__init__.py +30 -0
- wap/storage/base.py +70 -0
- wap/storage/memory.py +115 -0
- wap/storage/meters.py +204 -0
- wap/storage/redis.py +202 -0
- webagent_protocol-0.2.0.dist-info/METADATA +401 -0
- webagent_protocol-0.2.0.dist-info/RECORD +38 -0
- webagent_protocol-0.2.0.dist-info/WHEEL +4 -0
- webagent_protocol-0.2.0.dist-info/entry_points.txt +3 -0
- webagent_protocol-0.2.0.dist-info/licenses/LICENSE +21 -0
wap/storage/__init__.py
ADDED
|
@@ -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"]
|