metered 1.0.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.
- metered/__init__.py +17 -0
- metered/analytics/__init__.py +4 -0
- metered/analytics/models.py +15 -0
- metered/analytics/tracker.py +30 -0
- metered/backends/memory.py +174 -0
- metered/backends/redis.py +249 -0
- metered/core/backends.py +19 -0
- metered/core/limiter.py +188 -0
- metered/core/quota.py +171 -0
- metered/core/types.py +48 -0
- metered/hooks/events.py +162 -0
- metered/integrations/fastapi.py +38 -0
- metered/integrations/flask.py +25 -0
- metered-1.0.0.dist-info/METADATA +275 -0
- metered-1.0.0.dist-info/RECORD +17 -0
- metered-1.0.0.dist-info/WHEEL +4 -0
- metered-1.0.0.dist-info/licenses/LICENSE +21 -0
metered/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .core.limiter import Metered
|
|
2
|
+
from .core.types import Limit, QuotaConfig, QuotaRule, LimitResult, Strategy, IdentifierType
|
|
3
|
+
from .backends.memory import InMemoryBackend
|
|
4
|
+
from .backends.redis import RedisBackend
|
|
5
|
+
|
|
6
|
+
# Explicitly export the public API
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Metered",
|
|
9
|
+
"Limit",
|
|
10
|
+
"QuotaConfig",
|
|
11
|
+
"QuotaRule",
|
|
12
|
+
"LimitResult",
|
|
13
|
+
"Strategy",
|
|
14
|
+
"IdentifierType",
|
|
15
|
+
"InMemoryBackend",
|
|
16
|
+
"RedisBackend",
|
|
17
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class AnalyticsConfig:
|
|
6
|
+
retention_days: int = 30
|
|
7
|
+
anonymize_ip: bool = True
|
|
8
|
+
granularity: str = "hourly" # or "daily"
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class UsageSummary:
|
|
12
|
+
target: str
|
|
13
|
+
total_cost: int
|
|
14
|
+
period_start: float
|
|
15
|
+
period_end: float
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Optional, Any
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from .models import AnalyticsConfig
|
|
5
|
+
from ..core.backends import BaseBackend
|
|
6
|
+
|
|
7
|
+
class UsageTracker:
|
|
8
|
+
def __init__(self, backend: BaseBackend, config: Optional[AnalyticsConfig] = None):
|
|
9
|
+
self.backend = backend
|
|
10
|
+
self.config = config or AnalyticsConfig()
|
|
11
|
+
|
|
12
|
+
async def track(self, target: str, cost: int, timestamp: Optional[float] = None) -> None:
|
|
13
|
+
if timestamp is None:
|
|
14
|
+
timestamp = time.time()
|
|
15
|
+
|
|
16
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
17
|
+
|
|
18
|
+
if self.config.granularity == "hourly":
|
|
19
|
+
time_key = dt.strftime("%Y-%m-%d:%H")
|
|
20
|
+
else:
|
|
21
|
+
time_key = dt.strftime("%Y-%m-%d")
|
|
22
|
+
|
|
23
|
+
key = f"metered:usage:{target}:{time_key}"
|
|
24
|
+
|
|
25
|
+
if hasattr(self.backend, "increment_usage"):
|
|
26
|
+
await self.backend.increment_usage(key, cost, self.config.retention_days) # type: ignore
|
|
27
|
+
|
|
28
|
+
async def get_usage(self, target: str, start_ts: float, end_ts: float) -> int:
|
|
29
|
+
"""Helper to query usage if backend supports it"""
|
|
30
|
+
return 0
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Dict, List, Tuple, Optional
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from metered.core.backends import BaseBackend
|
|
6
|
+
from metered.core.types import LimitResult, Strategy
|
|
7
|
+
|
|
8
|
+
class InMemoryBackend(BaseBackend):
|
|
9
|
+
"""In-memory backend with per-key locking and auto-cleanup."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, cleanup_interval: float = 300.0) -> None:
|
|
12
|
+
self._fixed_window: Dict[str, Tuple[int, float]] = {}
|
|
13
|
+
self._token_bucket: Dict[str, Tuple[float, float]] = {}
|
|
14
|
+
self._sliding_window: Dict[str, List[float]] = {}
|
|
15
|
+
self._leaky_bucket: Dict[str, Tuple[float, float]] = {}
|
|
16
|
+
self._locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
|
17
|
+
self._plans_cache: Dict[str, Dict[str, dict]] = defaultdict(dict)
|
|
18
|
+
self._cleanup_interval = cleanup_interval
|
|
19
|
+
self._last_cleanup = time.time()
|
|
20
|
+
|
|
21
|
+
async def evaluate(
|
|
22
|
+
self,
|
|
23
|
+
key: str,
|
|
24
|
+
max_tokens: int,
|
|
25
|
+
period: float,
|
|
26
|
+
cost: int,
|
|
27
|
+
strategy: Strategy
|
|
28
|
+
) -> LimitResult:
|
|
29
|
+
if cost <= 0:
|
|
30
|
+
raise ValueError(f"Cost must be positive, got {cost}")
|
|
31
|
+
|
|
32
|
+
lock = self._locks[key]
|
|
33
|
+
async with lock:
|
|
34
|
+
try:
|
|
35
|
+
# Periodic cleanup
|
|
36
|
+
now = time.time()
|
|
37
|
+
if now - self._last_cleanup > self._cleanup_interval:
|
|
38
|
+
self._cleanup_expired(now)
|
|
39
|
+
self._last_cleanup = now
|
|
40
|
+
|
|
41
|
+
if strategy == Strategy.FIXED_WINDOW:
|
|
42
|
+
return self._eval_fixed(key, max_tokens, period, cost, now)
|
|
43
|
+
elif strategy == Strategy.TOKEN_BUCKET:
|
|
44
|
+
return self._eval_token(key, max_tokens, period, cost, now)
|
|
45
|
+
elif strategy == Strategy.SLIDING_WINDOW:
|
|
46
|
+
return self._eval_sliding(key, max_tokens, period, cost, now)
|
|
47
|
+
elif strategy == Strategy.LEAKY_BUCKET:
|
|
48
|
+
return self._eval_leaky(key, max_tokens, period, cost, now)
|
|
49
|
+
raise ValueError(f"Unknown strategy: {strategy}")
|
|
50
|
+
finally:
|
|
51
|
+
# Prevent lock buildup
|
|
52
|
+
if len(self._locks) > 10000:
|
|
53
|
+
self._locks = defaultdict(
|
|
54
|
+
asyncio.Lock,
|
|
55
|
+
{k: v for k, v in self._locks.items()
|
|
56
|
+
if k in self._fixed_window or k in self._token_bucket}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _eval_fixed(
|
|
60
|
+
self, key: str, max_tokens: int, period: float, cost: int, now: float
|
|
61
|
+
) -> LimitResult:
|
|
62
|
+
state = self._fixed_window.get(key)
|
|
63
|
+
|
|
64
|
+
if state is None or now - state[1] >= period:
|
|
65
|
+
if cost > max_tokens:
|
|
66
|
+
return LimitResult(False, 0, now + period, period)
|
|
67
|
+
self._fixed_window[key] = (cost, now)
|
|
68
|
+
return LimitResult(True, max_tokens - cost, now + period, 0.0)
|
|
69
|
+
|
|
70
|
+
used, window_start = state
|
|
71
|
+
reset_at = window_start + period
|
|
72
|
+
|
|
73
|
+
if used + cost > max_tokens:
|
|
74
|
+
return LimitResult(False, max_tokens - used, reset_at, max(0, reset_at - now))
|
|
75
|
+
|
|
76
|
+
self._fixed_window[key] = (used + cost, window_start)
|
|
77
|
+
return LimitResult(True, max_tokens - (used + cost), reset_at, 0.0)
|
|
78
|
+
|
|
79
|
+
def _eval_token(
|
|
80
|
+
self, key: str, max_tokens: int, period: float, cost: int, now: float
|
|
81
|
+
) -> LimitResult:
|
|
82
|
+
state = self._token_bucket.get(key)
|
|
83
|
+
rate = max_tokens / period
|
|
84
|
+
|
|
85
|
+
if state is None:
|
|
86
|
+
tokens, last_refill = float(max_tokens), now
|
|
87
|
+
else:
|
|
88
|
+
tokens, last_refill = state
|
|
89
|
+
elapsed = now - last_refill
|
|
90
|
+
if elapsed < 0:
|
|
91
|
+
elapsed = 0 # Clock skew protection
|
|
92
|
+
tokens = min(float(max_tokens), tokens + elapsed * rate)
|
|
93
|
+
|
|
94
|
+
if tokens >= cost:
|
|
95
|
+
tokens -= cost
|
|
96
|
+
self._token_bucket[key] = (tokens, now)
|
|
97
|
+
return LimitResult(True, int(tokens), now + period, 0.0)
|
|
98
|
+
|
|
99
|
+
retry_after = (cost - tokens) / rate
|
|
100
|
+
return LimitResult(False, int(tokens), now + period, retry_after)
|
|
101
|
+
|
|
102
|
+
def _eval_sliding(
|
|
103
|
+
self, key: str, max_tokens: int, period: float, cost: int, now: float
|
|
104
|
+
) -> LimitResult:
|
|
105
|
+
window_start = now - period
|
|
106
|
+
history = [ts for ts in self._sliding_window.get(key, []) if ts > window_start]
|
|
107
|
+
|
|
108
|
+
if len(history) + cost > max_tokens:
|
|
109
|
+
retry_after = max(0, (history[0] + period - now) if history else period)
|
|
110
|
+
return LimitResult(False, max_tokens - len(history), now + period, retry_after)
|
|
111
|
+
|
|
112
|
+
history.extend([now] * cost)
|
|
113
|
+
self._sliding_window[key] = history
|
|
114
|
+
return LimitResult(True, max_tokens - len(history), now + period, 0.0)
|
|
115
|
+
|
|
116
|
+
def _eval_leaky(
|
|
117
|
+
self, key: str, max_tokens: int, period: float, cost: int, now: float
|
|
118
|
+
) -> LimitResult:
|
|
119
|
+
state = self._leaky_bucket.get(key)
|
|
120
|
+
leak_rate = max_tokens / period
|
|
121
|
+
|
|
122
|
+
if state is None:
|
|
123
|
+
water, last_leak = 0.0, now
|
|
124
|
+
else:
|
|
125
|
+
water, last_leak = state
|
|
126
|
+
elapsed = now - last_leak
|
|
127
|
+
if elapsed < 0:
|
|
128
|
+
elapsed = 0 # Clock skew protection
|
|
129
|
+
water = max(0.0, water - elapsed * leak_rate)
|
|
130
|
+
|
|
131
|
+
if water + cost <= max_tokens:
|
|
132
|
+
water += cost
|
|
133
|
+
self._leaky_bucket[key] = (water, now)
|
|
134
|
+
return LimitResult(True, int(max_tokens - water), now + period, 0.0)
|
|
135
|
+
|
|
136
|
+
retry_after = (water + cost - max_tokens) / leak_rate
|
|
137
|
+
return LimitResult(False, int(max_tokens - water), now + period, retry_after)
|
|
138
|
+
|
|
139
|
+
def _cleanup_expired(self, now: float) -> None:
|
|
140
|
+
"""Remove stale data to prevent memory leaks."""
|
|
141
|
+
max_period = 3600.0
|
|
142
|
+
|
|
143
|
+
self._fixed_window = {
|
|
144
|
+
k: v for k, v in self._fixed_window.items()
|
|
145
|
+
if now - v[1] < max_period
|
|
146
|
+
}
|
|
147
|
+
self._token_bucket = {
|
|
148
|
+
k: v for k, v in self._token_bucket.items()
|
|
149
|
+
if now - v[1] < max_period
|
|
150
|
+
}
|
|
151
|
+
self._leaky_bucket = {
|
|
152
|
+
k: v for k, v in self._leaky_bucket.items()
|
|
153
|
+
if now - v[1] < max_period
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
window_start = now - max_period
|
|
157
|
+
for key in list(self._sliding_window.keys()):
|
|
158
|
+
self._sliding_window[key] = [
|
|
159
|
+
ts for ts in self._sliding_window[key] if ts > window_start
|
|
160
|
+
]
|
|
161
|
+
if not self._sliding_window[key]:
|
|
162
|
+
del self._sliding_window[key]
|
|
163
|
+
|
|
164
|
+
async def push_plan(self, target: str, plan_name: str, limit: int, reset_period: str, identifier_type: str, reset_day: int, timezone: str) -> None:
|
|
165
|
+
self._plans_cache[target][plan_name] = {
|
|
166
|
+
"limit": limit,
|
|
167
|
+
"reset_period": reset_period,
|
|
168
|
+
"identifier_type": identifier_type,
|
|
169
|
+
"reset_day": reset_day,
|
|
170
|
+
"timezone": timezone
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async def pull_plan(self, target: str, plan_name: str) -> Optional[dict]:
|
|
174
|
+
return self._plans_cache.get(target, {}).get(plan_name)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
import time
|
|
3
|
+
import math
|
|
4
|
+
import uuid
|
|
5
|
+
from metered.core.backends import BaseBackend
|
|
6
|
+
from metered.core.types import LimitResult, Strategy
|
|
7
|
+
|
|
8
|
+
FIXED_WINDOW_LUA = """
|
|
9
|
+
local current = redis.call("GET", KEYS[1])
|
|
10
|
+
if current then
|
|
11
|
+
if tonumber(current) + tonumber(ARGV[1]) > tonumber(ARGV[2]) then
|
|
12
|
+
return {0, tonumber(ARGV[2]) - tonumber(current), redis.call("PTTL", KEYS[1])}
|
|
13
|
+
end
|
|
14
|
+
redis.call("INCRBY", KEYS[1], tonumber(ARGV[1]))
|
|
15
|
+
return {1, tonumber(ARGV[2]) - tonumber(current) - tonumber(ARGV[1]), redis.call("PTTL", KEYS[1])}
|
|
16
|
+
else
|
|
17
|
+
if tonumber(ARGV[1]) > tonumber(ARGV[2]) then
|
|
18
|
+
return {0, tonumber(ARGV[2]), math.floor(tonumber(ARGV[3]) * 1000)}
|
|
19
|
+
end
|
|
20
|
+
redis.call("SET", KEYS[1], tonumber(ARGV[1]), "PX", math.floor(tonumber(ARGV[3]) * 1000))
|
|
21
|
+
return {1, tonumber(ARGV[2]) - tonumber(ARGV[1]), math.floor(tonumber(ARGV[3]) * 1000)}
|
|
22
|
+
end
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
TOKEN_BUCKET_LUA = """
|
|
26
|
+
local key = KEYS[1]
|
|
27
|
+
local cost = tonumber(ARGV[1])
|
|
28
|
+
local max_tokens = tonumber(ARGV[2])
|
|
29
|
+
local period_sec = tonumber(ARGV[3])
|
|
30
|
+
local now = tonumber(ARGV[4])
|
|
31
|
+
|
|
32
|
+
local tokens = redis.call('HGET', key, 'tokens')
|
|
33
|
+
local last_refill = redis.call('HGET', key, 'last_refill')
|
|
34
|
+
|
|
35
|
+
if not tokens then
|
|
36
|
+
tokens = max_tokens
|
|
37
|
+
last_refill = now
|
|
38
|
+
else
|
|
39
|
+
tokens = tonumber(tokens)
|
|
40
|
+
last_refill = tonumber(last_refill)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if last_refill > now then
|
|
44
|
+
last_refill = now
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
local elapsed = now - last_refill
|
|
48
|
+
local rate = max_tokens / period_sec
|
|
49
|
+
local new_tokens = math.min(max_tokens, tokens + (elapsed * rate))
|
|
50
|
+
|
|
51
|
+
if new_tokens >= cost then
|
|
52
|
+
new_tokens = new_tokens - cost
|
|
53
|
+
redis.call('HSET', key, 'tokens', tostring(new_tokens), 'last_refill', tostring(now))
|
|
54
|
+
redis.call('EXPIRE', key, math.ceil(period_sec))
|
|
55
|
+
return {1, math.floor(new_tokens), 0}
|
|
56
|
+
else
|
|
57
|
+
local wait = (cost - new_tokens) / rate
|
|
58
|
+
return {0, math.floor(new_tokens), math.ceil(wait * 1000)}
|
|
59
|
+
end
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
SLIDING_WINDOW_LUA = """
|
|
63
|
+
local key = KEYS[1]
|
|
64
|
+
local cost = tonumber(ARGV[1])
|
|
65
|
+
local max_tokens = tonumber(ARGV[2])
|
|
66
|
+
local period = tonumber(ARGV[3])
|
|
67
|
+
local now = tonumber(ARGV[4])
|
|
68
|
+
local random_id = ARGV[5]
|
|
69
|
+
|
|
70
|
+
local window_start = now - period
|
|
71
|
+
local dropped = redis.call('ZRANGEBYSCORE', key, '-inf', window_start)
|
|
72
|
+
local dropped_cost = 0
|
|
73
|
+
|
|
74
|
+
for i, member in ipairs(dropped) do
|
|
75
|
+
local c = string.match(member, ":(%d+):")
|
|
76
|
+
if c then
|
|
77
|
+
dropped_cost = dropped_cost + tonumber(c)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
local sum_exists = redis.call('EXISTS', key .. ':sum')
|
|
82
|
+
if sum_exists == 1 and dropped_cost > 0 then
|
|
83
|
+
local current_sum_val = tonumber(redis.call('GET', key .. ':sum') or "0")
|
|
84
|
+
if current_sum_val - dropped_cost < 0 then
|
|
85
|
+
redis.call('SET', key .. ':sum', 0)
|
|
86
|
+
else
|
|
87
|
+
redis.call('DECRBY', key .. ':sum', dropped_cost)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
|
|
91
|
+
|
|
92
|
+
local current_sum = tonumber(redis.call('GET', key .. ':sum') or "0")
|
|
93
|
+
if current_sum + cost <= max_tokens then
|
|
94
|
+
redis.call('ZADD', key, now, now .. ":" .. cost .. ":" .. random_id)
|
|
95
|
+
redis.call('INCRBY', key .. ':sum', cost)
|
|
96
|
+
redis.call('EXPIRE', key, math.ceil(period) + 1)
|
|
97
|
+
redis.call('EXPIRE', key .. ':sum', math.ceil(period) + 1)
|
|
98
|
+
return {1, max_tokens - (current_sum + cost), 0}
|
|
99
|
+
else
|
|
100
|
+
return {0, max_tokens - current_sum, math.ceil(period * 1000)}
|
|
101
|
+
end
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
LEAKY_BUCKET_LUA = """
|
|
105
|
+
local key = KEYS[1]
|
|
106
|
+
local cost = tonumber(ARGV[1])
|
|
107
|
+
local capacity = tonumber(ARGV[2])
|
|
108
|
+
local period_sec = tonumber(ARGV[3])
|
|
109
|
+
local now = tonumber(ARGV[4])
|
|
110
|
+
|
|
111
|
+
local water = redis.call('HGET', key, 'water')
|
|
112
|
+
local last_leak = redis.call('HGET', key, 'last_leak')
|
|
113
|
+
|
|
114
|
+
if not water then
|
|
115
|
+
water = 0
|
|
116
|
+
last_leak = now
|
|
117
|
+
else
|
|
118
|
+
water = tonumber(water)
|
|
119
|
+
last_leak = tonumber(last_leak)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if last_leak > now then
|
|
123
|
+
last_leak = now
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
local elapsed = now - last_leak
|
|
127
|
+
local leak_rate = capacity / period_sec
|
|
128
|
+
local leaked = elapsed * leak_rate
|
|
129
|
+
water = math.max(0, water - leaked)
|
|
130
|
+
|
|
131
|
+
if water + cost <= capacity then
|
|
132
|
+
water = water + cost
|
|
133
|
+
redis.call('HSET', key, 'water', tostring(water), 'last_leak', tostring(now))
|
|
134
|
+
redis.call('EXPIRE', key, math.ceil(period_sec))
|
|
135
|
+
return {1, math.floor(capacity - water), 0}
|
|
136
|
+
else
|
|
137
|
+
local wait = (water + cost - capacity) / leak_rate
|
|
138
|
+
return {0, math.floor(capacity - water), math.ceil(wait * 1000)}
|
|
139
|
+
end
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
class RedisBackend(BaseBackend):
|
|
143
|
+
def __init__(self, redis_client: Any) -> None:
|
|
144
|
+
self.client = redis_client
|
|
145
|
+
self._scripts = {
|
|
146
|
+
Strategy.FIXED_WINDOW: self.client.register_script(FIXED_WINDOW_LUA),
|
|
147
|
+
Strategy.TOKEN_BUCKET: self.client.register_script(TOKEN_BUCKET_LUA),
|
|
148
|
+
Strategy.SLIDING_WINDOW: self.client.register_script(SLIDING_WINDOW_LUA),
|
|
149
|
+
Strategy.LEAKY_BUCKET: self.client.register_script(LEAKY_BUCKET_LUA),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async def evaluate(self, key: str, max_tokens: int, period: float, cost: int, strategy: Strategy) -> LimitResult:
|
|
153
|
+
if cost <= 0:
|
|
154
|
+
raise ValueError(f"Cost must be positive, got {cost}")
|
|
155
|
+
|
|
156
|
+
now = time.time()
|
|
157
|
+
|
|
158
|
+
if strategy not in self._scripts:
|
|
159
|
+
raise NotImplementedError(f"Strategy {strategy} not supported")
|
|
160
|
+
|
|
161
|
+
script = self._scripts[strategy]
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
if strategy == Strategy.FIXED_WINDOW:
|
|
165
|
+
allowed, remaining, pttl = await script(keys=[key], args=[cost, max_tokens, period])
|
|
166
|
+
reset_at = now + (pttl / 1000.0)
|
|
167
|
+
retry_after = (pttl / 1000.0) if not allowed else 0.0
|
|
168
|
+
|
|
169
|
+
elif strategy in (Strategy.TOKEN_BUCKET, Strategy.LEAKY_BUCKET):
|
|
170
|
+
allowed, remaining, pttl = await script(keys=[key], args=[cost, max_tokens, period, now])
|
|
171
|
+
reset_at = now + period
|
|
172
|
+
retry_after = (pttl / 1000.0) if not allowed else 0.0
|
|
173
|
+
|
|
174
|
+
elif strategy == Strategy.SLIDING_WINDOW:
|
|
175
|
+
random_id = f"{uuid.uuid4()}:{time.time_ns()}"
|
|
176
|
+
allowed, remaining, pttl = await script(keys=[key], args=[cost, max_tokens, period, now, random_id])
|
|
177
|
+
reset_at = now + period
|
|
178
|
+
retry_after = (pttl / 1000.0) if not allowed else 0.0
|
|
179
|
+
|
|
180
|
+
return LimitResult(bool(allowed), int(remaining), reset_at, retry_after)
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
import logging
|
|
184
|
+
logging.getLogger("metered").error(f"Redis backend evaluation failed: {e}")
|
|
185
|
+
# Fail-safe mode: reject request on Redis failure to prevent abuse,
|
|
186
|
+
# or allow it if you prefer high-availability. Here we follow fail-safe:
|
|
187
|
+
return LimitResult(False, 0, now + period, period)
|
|
188
|
+
|
|
189
|
+
async def push_event(self, queue: str, event_name: str, payload: dict[str, Any]) -> None:
|
|
190
|
+
import json
|
|
191
|
+
data = {
|
|
192
|
+
"event": event_name,
|
|
193
|
+
"payload": payload,
|
|
194
|
+
"timestamp": time.time()
|
|
195
|
+
}
|
|
196
|
+
await self.client.lpush(queue, json.dumps(data))
|
|
197
|
+
|
|
198
|
+
async def pop_event(self, queue: str, timeout: int = 0) -> Any:
|
|
199
|
+
# BRPOP returns a tuple (queue_name, data)
|
|
200
|
+
result = await self.client.brpop(queue, timeout=timeout)
|
|
201
|
+
if result:
|
|
202
|
+
data = result[1]
|
|
203
|
+
unique_id = str(uuid.uuid4())
|
|
204
|
+
await self.client.setex(f"event:{unique_id}", 30, data)
|
|
205
|
+
return (unique_id, data)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
async def ack_event(self, unique_id: str) -> None:
|
|
209
|
+
await self.client.delete(f"event:{unique_id}")
|
|
210
|
+
|
|
211
|
+
async def nack_event(self, unique_id: str, requeue: bool = True) -> None:
|
|
212
|
+
data = await self.client.get(f"event:{unique_id}")
|
|
213
|
+
await self.client.delete(f"event:{unique_id}")
|
|
214
|
+
if requeue and data:
|
|
215
|
+
await self.client.lpush("metered_events", data)
|
|
216
|
+
async def increment_usage(self, key: str, cost: int, retention_days: int) -> None:
|
|
217
|
+
await self.client.incrby(key, cost)
|
|
218
|
+
# Only set expire if it's a new key, or we can just always set it
|
|
219
|
+
await self.client.expire(key, retention_days * 86400)
|
|
220
|
+
|
|
221
|
+
async def push_plan(self, target: str, plan_name: str, limit: int, reset_period: str, identifier_type: str, reset_day: int, timezone: str) -> None:
|
|
222
|
+
key = f"metered:plan:{target}:{plan_name}"
|
|
223
|
+
data = {
|
|
224
|
+
"limit": str(limit),
|
|
225
|
+
"reset_period": reset_period,
|
|
226
|
+
"identifier_type": identifier_type,
|
|
227
|
+
"reset_day": str(reset_day),
|
|
228
|
+
"timezone": timezone
|
|
229
|
+
}
|
|
230
|
+
await self.client.hset(key, mapping=data)
|
|
231
|
+
|
|
232
|
+
async def pull_plan(self, target: str, plan_name: str) -> Optional[dict]:
|
|
233
|
+
key = f"metered:plan:{target}:{plan_name}"
|
|
234
|
+
data = await self.client.hgetall(key)
|
|
235
|
+
if not data:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
# Redis hgetall returns bytes if not decode_responses=True, but we assume it might be strings or bytes
|
|
239
|
+
# Let's decode if needed
|
|
240
|
+
def _decode(val):
|
|
241
|
+
return val.decode('utf-8') if isinstance(val, bytes) else val
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"limit": int(_decode(data.get(b"limit", data.get("limit", 0)))),
|
|
245
|
+
"reset_period": _decode(data.get(b"reset_period", data.get("reset_period", ""))),
|
|
246
|
+
"identifier_type": _decode(data.get(b"identifier_type", data.get("identifier_type", ""))),
|
|
247
|
+
"reset_day": int(_decode(data.get(b"reset_day", data.get("reset_day", 1)))),
|
|
248
|
+
"timezone": _decode(data.get(b"timezone", data.get("timezone", "UTC")))
|
|
249
|
+
}
|
metered/core/backends.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Protocol, Optional
|
|
2
|
+
from .types import LimitResult, Strategy
|
|
3
|
+
|
|
4
|
+
class BaseBackend(Protocol):
|
|
5
|
+
async def evaluate(
|
|
6
|
+
self,
|
|
7
|
+
key: str,
|
|
8
|
+
max_tokens: int,
|
|
9
|
+
period: float,
|
|
10
|
+
cost: int,
|
|
11
|
+
strategy: Strategy
|
|
12
|
+
) -> LimitResult:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
async def push_plan(self, target: str, plan_name: str, limit: int, reset_period: str, identifier_type: str, reset_day: int, timezone: str) -> None:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
async def pull_plan(self, target: str, plan_name: str) -> Optional[dict]:
|
|
19
|
+
...
|