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 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,4 @@
1
+ from .models import AnalyticsConfig, UsageSummary
2
+ from .tracker import UsageTracker
3
+
4
+ __all__ = ["AnalyticsConfig", "UsageSummary", "UsageTracker"]
@@ -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
+ }
@@ -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
+ ...