reach_commons 0.18.36__py3-none-any.whl → 0.18.37__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,228 @@
1
+ import random
2
+ import time
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Tuple
5
+
6
+ from reach_commons.redis_manager import RedisManager
7
+
8
+ # Atomic fixed-window limiter (safe under high concurrency).
9
+ _LUA_WINDOW_LIMITER = """
10
+ -- KEYS[1] = window_counter_key
11
+ -- ARGV[1] = tokens_to_consume
12
+ -- ARGV[2] = ttl_seconds
13
+ -- ARGV[3] = limit_per_window
14
+
15
+ local tokens = tonumber(ARGV[1])
16
+ local ttl = tonumber(ARGV[2])
17
+ local limit = tonumber(ARGV[3])
18
+
19
+ local current = redis.call('INCRBY', KEYS[1], tokens)
20
+
21
+ -- If this is the first increment for this window, set TTL
22
+ if current == tokens then
23
+ redis.call('EXPIRE', KEYS[1], ttl)
24
+ end
25
+
26
+ if current <= limit then
27
+ return 1
28
+ else
29
+ return 0
30
+ end
31
+ """
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AcquireResult:
36
+ allowed: bool
37
+ retry_after_seconds: int
38
+
39
+
40
+ class ReachRateLimiter:
41
+ """
42
+ ReachRateLimiter (fixed-window limiter) backed by Redis.
43
+
44
+ Configurable live via Redis (no redeploy needed).
45
+ Atomic under heavy concurrency (Lua runs inside Redis).
46
+ Returns retry_after_seconds (use it to ChangeMessageVisibility / Delay).
47
+
48
+ Redis keys used:
49
+ - Config hash:
50
+ {key_prefix}:cfg:{bucket_key}
51
+ Fields (all optional):
52
+ - limit_per_window (int)
53
+ - interval_seconds (int)
54
+ - jitter_seconds (int)
55
+
56
+ - Per-window counter:
57
+ {key_prefix}:{bucket_key}:{window_start}
58
+
59
+ Suggested defaults:
60
+ interval_seconds=2
61
+ limit_per_window=2000 (=> ~1000/s)
62
+ jitter_seconds=2 or 3
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ redis_manager: RedisManager,
68
+ bucket_key: str,
69
+ key_prefix: str = "rate_limiter",
70
+ default_limit_per_window: int = 2000,
71
+ default_interval_seconds: int = 2,
72
+ default_jitter_seconds: Optional[int] = None,
73
+ # Cache config in-memory per Lambda container (to reduce Redis reads):
74
+ config_cache_seconds: int = 2,
75
+ # if Redis is down, deny by default to avoid stampede downstream
76
+ deny_on_redis_error: bool = True,
77
+ ):
78
+ self.redis = redis_manager
79
+ self.bucket_key = bucket_key
80
+ self.key_prefix = key_prefix
81
+
82
+ self.default_limit = int(default_limit_per_window)
83
+ self.default_interval = int(default_interval_seconds)
84
+ self.default_jitter = (
85
+ int(default_jitter_seconds)
86
+ if default_jitter_seconds is not None
87
+ else int(default_interval_seconds)
88
+ )
89
+
90
+ self.config_cache_seconds = max(0, int(config_cache_seconds))
91
+ self.deny_on_redis_error = bool(deny_on_redis_error)
92
+
93
+ self._lua = _LUA_WINDOW_LIMITER
94
+
95
+ # Per-container cache (each Lambda container caches for a short time)
96
+ self._cached_cfg: Optional[Tuple[int, int, int]] = None
97
+ self._cached_cfg_ts: float = 0.0
98
+
99
+ # -------------------------
100
+ # Redis key helpers
101
+ # -------------------------
102
+ def _cfg_key(self) -> str:
103
+ return f"{self.key_prefix}:cfg:{self.bucket_key}"
104
+
105
+ def _counter_key(self, window_start: int) -> str:
106
+ return f"{self.key_prefix}:{self.bucket_key}:{window_start}"
107
+
108
+ # -------------------------
109
+ # Time helpers
110
+ # -------------------------
111
+ def _now(self) -> float:
112
+ return time.time()
113
+
114
+ def _window_start(self, now: float, interval_seconds: int) -> int:
115
+ return int(now // interval_seconds) * interval_seconds
116
+
117
+ # -------------------------
118
+ # Config loading (from Redis hash)
119
+ # -------------------------
120
+ @staticmethod
121
+ def _parse_int(value, fallback: int) -> int:
122
+ try:
123
+ if value is None:
124
+ return fallback
125
+ if isinstance(value, (bytes, bytearray)):
126
+ value = value.decode("utf-8", errors="ignore")
127
+ return int(value)
128
+ except Exception:
129
+ return fallback
130
+
131
+ def _load_config(self) -> Tuple[int, int, int]:
132
+ """
133
+ Loads config from Redis hash:
134
+ limit_per_window, interval_seconds, jitter_seconds
135
+
136
+ Uses short in-memory cache to avoid hammering Redis under high throughput.
137
+ """
138
+ now = self._now()
139
+
140
+ if (
141
+ self._cached_cfg is not None
142
+ and self.config_cache_seconds > 0
143
+ and (now - self._cached_cfg_ts) < self.config_cache_seconds
144
+ ):
145
+ return self._cached_cfg
146
+
147
+ limit = self.default_limit
148
+ interval = self.default_interval
149
+ jitter = self.default_jitter
150
+
151
+ try:
152
+ raw = self.redis.hgetall(self._cfg_key()) or {}
153
+
154
+ # raw typically has bytes keys/values
155
+ limit = self._parse_int(
156
+ raw.get(b"limit_per_window") or raw.get("limit_per_window"), limit
157
+ )
158
+ interval = self._parse_int(
159
+ raw.get(b"interval_seconds") or raw.get("interval_seconds"), interval
160
+ )
161
+ jitter = self._parse_int(
162
+ raw.get(b"jitter_seconds") or raw.get("jitter_seconds"), jitter
163
+ )
164
+
165
+ # Guard rails
166
+ if limit <= 0:
167
+ limit = self.default_limit
168
+ if interval <= 0:
169
+ interval = self.default_interval
170
+ if jitter < 0:
171
+ jitter = 0
172
+
173
+ except Exception:
174
+ # Redis issue: keep defaults
175
+ limit = self.default_limit
176
+ interval = self.default_interval
177
+ jitter = self.default_jitter
178
+
179
+ cfg = (int(limit), int(interval), int(jitter))
180
+ self._cached_cfg = cfg
181
+ self._cached_cfg_ts = now
182
+ return cfg
183
+
184
+ # -------------------------
185
+ # Public API
186
+ # -------------------------
187
+ def acquire(self, tokens: int = 1) -> AcquireResult:
188
+ """
189
+ Attempt to acquire tokens (default 1).
190
+ If denied, returns retry_after_seconds.
191
+ """
192
+ tokens = int(tokens)
193
+ if tokens <= 0:
194
+ return AcquireResult(allowed=True, retry_after_seconds=0)
195
+
196
+ now = self._now()
197
+ limit, interval, jitter_max = self._load_config()
198
+
199
+ window_start = self._window_start(now, interval)
200
+ window_end = window_start + interval
201
+ counter_key = self._counter_key(window_start)
202
+
203
+ # TTL slightly larger than interval so old window keys expire
204
+ ttl_seconds = max(interval * 2, 5)
205
+
206
+ try:
207
+ allowed = self.redis.eval(
208
+ self._lua,
209
+ numkeys=1,
210
+ keys=[counter_key],
211
+ args=[str(tokens), str(ttl_seconds), str(limit)],
212
+ )
213
+ except Exception:
214
+ if self.deny_on_redis_error:
215
+ # safest for protecting downstream (Mongo/API)
216
+ retry_after = int(max(1.0, float(interval)))
217
+ return AcquireResult(allowed=False, retry_after_seconds=retry_after)
218
+ return AcquireResult(allowed=True, retry_after_seconds=0)
219
+
220
+ if allowed == 1:
221
+ return AcquireResult(allowed=True, retry_after_seconds=0)
222
+
223
+ # Denied: retry after next window + jitter to avoid waves
224
+ base = max(0.0, window_end - now)
225
+ jitter = random.uniform(0.0, float(jitter_max))
226
+ retry_after = int(max(1.0, base + jitter))
227
+
228
+ return AcquireResult(allowed=False, retry_after_seconds=retry_after)
@@ -91,3 +91,12 @@ class RedisManager:
91
91
  keys = keys or []
92
92
  args = args or []
93
93
  return self.redis_connection.eval(script, numkeys, *(keys + args))
94
+
95
+ def hgetall(self, key):
96
+ return self.redis_connection.hgetall(key)
97
+
98
+ def hget(self, key, field):
99
+ return self.redis_connection.hget(key, field)
100
+
101
+ def hset(self, key, field, value):
102
+ return self.redis_connection.hset(key, field, value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: reach_commons
3
- Version: 0.18.36
3
+ Version: 0.18.37
4
4
  Summary: Reach Commons is a versatile utility library designed to streamline and enhance development workflows within the Reach ecosystem.
5
5
  License: MIT
6
6
  Author: Engineering
@@ -14,7 +14,6 @@ reach_commons/clients/reach_ops_api.py,sha256=1kTY4cF2eBCl_kqs1r5lbzxt4_7EFRmltb
14
14
  reach_commons/mongo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  reach_commons/mongo/customer_persistence.py,sha256=acrtpyCWr9vLVq61saJ3_Vp4DYHFBTM9XqoYC72J84w,3735
16
16
  reach_commons/mongo/customer_persistence_async.py,sha256=BmcP8TXyyQah-GYM3wcKi1baqSCycjw7UadlxGywyQM,3892
17
- reach_commons/mongo/mongo_token_bucket.py,sha256=Q98EGoetrNhtAqlfvGk2tXex4EEl0d-npGXygld0gSQ,3272
18
17
  reach_commons/mongo/validation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
18
  reach_commons/reach_aws/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
19
  reach_commons/reach_aws/commons.py,sha256=qQba0li75BIpmyVc0sDVrrxbtYvDCedF6RmFD-V4MYQ,259
@@ -22,13 +21,14 @@ reach_commons/reach_aws/dynamo_db.py,sha256=BL3QcKzx4uZic-Ui12tln_GMSKe297FdfyIz
22
21
  reach_commons/reach_aws/exceptions.py,sha256=x0RL5ktNtzxg0KykhEVWReBq_dEtciK6B2vMs_s4C9k,915
23
22
  reach_commons/reach_aws/firehose.py,sha256=1xFKLWMv3bNo3PPW5gtaL6NqzUDyVil6B768slj2wbY,5674
24
23
  reach_commons/reach_aws/kms.py,sha256=ZOfyJMQUgxJEojRoB7-aCxtATpNx1Ig522IUYH11NZ4,4678
24
+ reach_commons/reach_aws/reach_rate_limiter.py,sha256=Z3gldy_EkkgVVl9rmc3t5xciccNPcRAiBY1stvr7YLw,7448
25
25
  reach_commons/reach_aws/s3.py,sha256=2MLlDNFx0SROJBpE_KjJefyrB7lMqTlrYuRhSZx4iKs,3945
26
26
  reach_commons/reach_aws/sqs.py,sha256=IKKWrd-qbhMMVYUvGbaq1ouVRdx-0u-SqwYaTcp0tWY,21645
27
27
  reach_commons/reach_base_model.py,sha256=vgdGDcZr3iXMmyRhmBhOf_LKWB_6QzT3r_Yiyo6OmEk,3009
28
- reach_commons/redis_manager.py,sha256=RXtEksz13_uXwJAxfDvT8sCNwNIPCdqhsLUlewRq3Jg,3392
28
+ reach_commons/redis_manager.py,sha256=yRed53ZKlbIb6rZnL53D1F_aB-xWT3nbeUP2cqYzhoc,3668
29
29
  reach_commons/sms_smart_encoding.py,sha256=92y0RmZ0l4ONHpC9qeO5KfViSNq64yE2rc7lhNDSZqE,1241
30
30
  reach_commons/utils.py,sha256=dMgKIGqTgoSItuBI8oz81gKtW3qi21Jkljv9leS_V88,8475
31
31
  reach_commons/validations.py,sha256=x_lkrtlrCAJC_f7mZb19JjfKFbYlPFv-P84K_lbZyYs,1056
32
- reach_commons-0.18.36.dist-info/METADATA,sha256=cCN7jv1Iyn2GMKoYAkt15QalHcoN4pVWVwmXqKBESXg,1863
33
- reach_commons-0.18.36.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
34
- reach_commons-0.18.36.dist-info/RECORD,,
32
+ reach_commons-0.18.37.dist-info/METADATA,sha256=k2xckMxe857fXHo3p-lUKT0eWFwHmXTumKIqcI5M2GM,1863
33
+ reach_commons-0.18.37.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
34
+ reach_commons-0.18.37.dist-info/RECORD,,
@@ -1,108 +0,0 @@
1
- # mongo_token_bucket.py
2
- import random
3
- import time
4
- from dataclasses import dataclass
5
- from typing import Optional, Tuple
6
-
7
- _LUA_WINDOW_LIMITER = """
8
- -- KEYS[1] = window key
9
- -- ARGV[1] = tokens_to_consume
10
- -- ARGV[2] = ttl_seconds
11
- -- ARGV[3] = limit
12
-
13
- local tokens = tonumber(ARGV[1])
14
- local ttl = tonumber(ARGV[2])
15
- local limit = tonumber(ARGV[3])
16
-
17
- local current = redis.call('INCRBY', KEYS[1], tokens)
18
-
19
- -- If this was the first increment (key was 0 / nonexistent), set TTL
20
- if current == tokens then
21
- redis.call('EXPIRE', KEYS[1], ttl)
22
- end
23
-
24
- if current <= limit then
25
- return 1
26
- else
27
- return 0
28
- end
29
- """
30
-
31
-
32
- @dataclass(frozen=True)
33
- class AcquireResult:
34
- allowed: bool
35
- retry_after_seconds: int # for visibility timeout / delay
36
-
37
-
38
- class MongoTokenBucketManager:
39
- """
40
- Window-based limiter:
41
- - limit tokens per interval_seconds
42
- - atomic via Redis Lua
43
- """
44
-
45
- def __init__(
46
- self,
47
- redis_manager,
48
- limit_per_window: int,
49
- interval_seconds: int = 2,
50
- bucket_key: str = "global",
51
- jitter_seconds: Optional[int] = None,
52
- key_prefix: str = "mongo_write_budget",
53
- ):
54
- if interval_seconds <= 0:
55
- raise ValueError("interval_seconds must be > 0")
56
- if limit_per_window <= 0:
57
- raise ValueError("limit_per_window must be > 0")
58
-
59
- self.redis = redis_manager
60
- self.limit = int(limit_per_window)
61
- self.interval = int(interval_seconds)
62
- self.bucket_key = bucket_key
63
- self.key_prefix = key_prefix
64
- self.jitter = (
65
- int(jitter_seconds) if jitter_seconds is not None else self.interval
66
- ) # default: 0..interval
67
-
68
- # Cache the script SHA if you want; eval is fine for now (2-day fix).
69
- self._lua = _LUA_WINDOW_LIMITER
70
-
71
- def _now(self) -> float:
72
- return time.time()
73
-
74
- def _window_start(self, now: float) -> int:
75
- return int(now // self.interval) * self.interval
76
-
77
- def _redis_key(self, window_start: int) -> str:
78
- return f"{self.key_prefix}:{self.bucket_key}:{window_start}"
79
-
80
- def acquire(self, tokens: int = 1) -> AcquireResult:
81
- """
82
- Try to consume tokens. If denied, returns retry_after_seconds to push visibility timeout.
83
- """
84
- now = self._now()
85
- window_start = self._window_start(now)
86
- window_end = window_start + self.interval
87
-
88
- key = self._redis_key(window_start)
89
-
90
- # TTL a bit bigger than the window so old keys go away safely
91
- ttl_seconds = max(self.interval * 2, 5)
92
-
93
- allowed = self.redis.eval(
94
- self._lua,
95
- numkeys=1,
96
- keys=[key],
97
- args=[str(int(tokens)), str(int(ttl_seconds)), str(int(self.limit))],
98
- )
99
-
100
- if allowed == 1:
101
- return AcquireResult(allowed=True, retry_after_seconds=0)
102
-
103
- # Denied: retry after next window, plus jitter to avoid waves
104
- base = max(0.0, window_end - now) # seconds until next window
105
- jitter = random.uniform(0.0, float(self.jitter))
106
- retry_after = int(max(1.0, base + jitter)) # at least 1s
107
-
108
- return AcquireResult(allowed=False, retry_after_seconds=retry_after)