zae-limiter 0.1.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.
- zae_limiter/__init__.py +130 -0
- zae_limiter/aggregator/__init__.py +11 -0
- zae_limiter/aggregator/handler.py +54 -0
- zae_limiter/aggregator/processor.py +270 -0
- zae_limiter/bucket.py +291 -0
- zae_limiter/cli.py +608 -0
- zae_limiter/exceptions.py +214 -0
- zae_limiter/infra/__init__.py +10 -0
- zae_limiter/infra/cfn_template.yaml +255 -0
- zae_limiter/infra/lambda_builder.py +85 -0
- zae_limiter/infra/stack_manager.py +536 -0
- zae_limiter/lease.py +196 -0
- zae_limiter/limiter.py +925 -0
- zae_limiter/migrations/__init__.py +114 -0
- zae_limiter/migrations/v1_0_0.py +55 -0
- zae_limiter/models.py +302 -0
- zae_limiter/repository.py +656 -0
- zae_limiter/schema.py +163 -0
- zae_limiter/version.py +214 -0
- zae_limiter-0.1.0.dist-info/METADATA +470 -0
- zae_limiter-0.1.0.dist-info/RECORD +24 -0
- zae_limiter-0.1.0.dist-info/WHEEL +4 -0
- zae_limiter-0.1.0.dist-info/entry_points.txt +2 -0
- zae_limiter-0.1.0.dist-info/licenses/LICENSE +21 -0
zae_limiter/bucket.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Token bucket algorithm implementation using integer arithmetic."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .models import BucketState, Limit, LimitStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RefillResult:
|
|
10
|
+
"""Result of a bucket refill calculation."""
|
|
11
|
+
|
|
12
|
+
new_tokens_milli: int
|
|
13
|
+
new_last_refill_ms: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ConsumeResult:
|
|
18
|
+
"""Result of attempting to consume from a bucket."""
|
|
19
|
+
|
|
20
|
+
success: bool
|
|
21
|
+
new_tokens_milli: int
|
|
22
|
+
new_last_refill_ms: int
|
|
23
|
+
available: int # tokens available before consume attempt
|
|
24
|
+
retry_after_seconds: float # 0 if success, time to wait if failed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def refill_bucket(
|
|
28
|
+
tokens_milli: int,
|
|
29
|
+
last_refill_ms: int,
|
|
30
|
+
now_ms: int,
|
|
31
|
+
burst_milli: int,
|
|
32
|
+
refill_amount_milli: int,
|
|
33
|
+
refill_period_ms: int,
|
|
34
|
+
) -> RefillResult:
|
|
35
|
+
"""
|
|
36
|
+
Calculate refilled tokens using integer arithmetic.
|
|
37
|
+
|
|
38
|
+
The refill is calculated as:
|
|
39
|
+
tokens_to_add = elapsed_ms * refill_amount_milli / refill_period_ms
|
|
40
|
+
|
|
41
|
+
We track how much time we "used" for the refill to avoid drift
|
|
42
|
+
from accumulated rounding errors.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
tokens_milli: Current tokens in millitokens
|
|
46
|
+
last_refill_ms: Last refill timestamp in epoch milliseconds
|
|
47
|
+
now_ms: Current timestamp in epoch milliseconds
|
|
48
|
+
burst_milli: Maximum bucket capacity in millitokens
|
|
49
|
+
refill_amount_milli: Refill amount numerator in millitokens
|
|
50
|
+
refill_period_ms: Refill period denominator in milliseconds
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
RefillResult with new token count and timestamp
|
|
54
|
+
"""
|
|
55
|
+
elapsed_ms = now_ms - last_refill_ms
|
|
56
|
+
|
|
57
|
+
if elapsed_ms <= 0:
|
|
58
|
+
return RefillResult(tokens_milli, last_refill_ms)
|
|
59
|
+
|
|
60
|
+
# Integer division for tokens to add
|
|
61
|
+
tokens_to_add = (elapsed_ms * refill_amount_milli) // refill_period_ms
|
|
62
|
+
|
|
63
|
+
if tokens_to_add == 0:
|
|
64
|
+
# Not enough time has passed for even 1 millitoken
|
|
65
|
+
return RefillResult(tokens_milli, last_refill_ms)
|
|
66
|
+
|
|
67
|
+
# Track how much time we "consumed" for this refill to avoid drift
|
|
68
|
+
# This is the inverse: time_used = tokens_added * period / amount
|
|
69
|
+
time_used_ms = (tokens_to_add * refill_period_ms) // refill_amount_milli
|
|
70
|
+
|
|
71
|
+
# Cap at burst and update timestamp
|
|
72
|
+
new_tokens = min(burst_milli, tokens_milli + tokens_to_add)
|
|
73
|
+
new_last_refill = last_refill_ms + time_used_ms
|
|
74
|
+
|
|
75
|
+
return RefillResult(new_tokens, new_last_refill)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def try_consume(
|
|
79
|
+
state: BucketState,
|
|
80
|
+
requested: int,
|
|
81
|
+
now_ms: int,
|
|
82
|
+
) -> ConsumeResult:
|
|
83
|
+
"""
|
|
84
|
+
Attempt to consume tokens from a bucket.
|
|
85
|
+
|
|
86
|
+
First refills the bucket based on elapsed time, then checks if
|
|
87
|
+
there's enough capacity for the request.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
state: Current bucket state
|
|
91
|
+
requested: Number of tokens to consume
|
|
92
|
+
now_ms: Current timestamp in epoch milliseconds
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
ConsumeResult indicating success/failure and new state
|
|
96
|
+
"""
|
|
97
|
+
# First, refill the bucket
|
|
98
|
+
refill = refill_bucket(
|
|
99
|
+
tokens_milli=state.tokens_milli,
|
|
100
|
+
last_refill_ms=state.last_refill_ms,
|
|
101
|
+
now_ms=now_ms,
|
|
102
|
+
burst_milli=state.burst_milli,
|
|
103
|
+
refill_amount_milli=state.refill_amount_milli,
|
|
104
|
+
refill_period_ms=state.refill_period_ms,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
current_tokens_milli = refill.new_tokens_milli
|
|
108
|
+
requested_milli = requested * 1000
|
|
109
|
+
available = current_tokens_milli // 1000
|
|
110
|
+
|
|
111
|
+
if current_tokens_milli >= requested_milli:
|
|
112
|
+
# Success - consume the tokens
|
|
113
|
+
return ConsumeResult(
|
|
114
|
+
success=True,
|
|
115
|
+
new_tokens_milli=current_tokens_milli - requested_milli,
|
|
116
|
+
new_last_refill_ms=refill.new_last_refill_ms,
|
|
117
|
+
available=available,
|
|
118
|
+
retry_after_seconds=0.0,
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
# Failure - calculate retry time
|
|
122
|
+
deficit_milli = requested_milli - current_tokens_milli
|
|
123
|
+
retry_after = calculate_retry_after(
|
|
124
|
+
deficit_milli=deficit_milli,
|
|
125
|
+
refill_amount_milli=state.refill_amount_milli,
|
|
126
|
+
refill_period_ms=state.refill_period_ms,
|
|
127
|
+
)
|
|
128
|
+
return ConsumeResult(
|
|
129
|
+
success=False,
|
|
130
|
+
new_tokens_milli=current_tokens_milli,
|
|
131
|
+
new_last_refill_ms=refill.new_last_refill_ms,
|
|
132
|
+
available=available,
|
|
133
|
+
retry_after_seconds=retry_after,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def calculate_retry_after(
|
|
138
|
+
deficit_milli: int,
|
|
139
|
+
refill_amount_milli: int,
|
|
140
|
+
refill_period_ms: int,
|
|
141
|
+
) -> float:
|
|
142
|
+
"""
|
|
143
|
+
Calculate seconds until deficit is refilled.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
deficit_milli: How many millitokens we're short
|
|
147
|
+
refill_amount_milli: Refill rate numerator
|
|
148
|
+
refill_period_ms: Refill rate denominator
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Seconds until deficit is recovered (float)
|
|
152
|
+
"""
|
|
153
|
+
if deficit_milli <= 0:
|
|
154
|
+
return 0.0
|
|
155
|
+
|
|
156
|
+
# time_ms = deficit * period / amount
|
|
157
|
+
time_ms = (deficit_milli * refill_period_ms) // refill_amount_milli
|
|
158
|
+
# Add 1ms to ensure we've fully refilled (rounding)
|
|
159
|
+
return (time_ms + 1) / 1000.0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def calculate_available(
|
|
163
|
+
state: BucketState,
|
|
164
|
+
now_ms: int,
|
|
165
|
+
) -> int:
|
|
166
|
+
"""
|
|
167
|
+
Calculate currently available tokens (can be negative).
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
state: Current bucket state
|
|
171
|
+
now_ms: Current timestamp in epoch milliseconds
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Available tokens (may be negative if bucket is in debt)
|
|
175
|
+
"""
|
|
176
|
+
refill = refill_bucket(
|
|
177
|
+
tokens_milli=state.tokens_milli,
|
|
178
|
+
last_refill_ms=state.last_refill_ms,
|
|
179
|
+
now_ms=now_ms,
|
|
180
|
+
burst_milli=state.burst_milli,
|
|
181
|
+
refill_amount_milli=state.refill_amount_milli,
|
|
182
|
+
refill_period_ms=state.refill_period_ms,
|
|
183
|
+
)
|
|
184
|
+
return refill.new_tokens_milli // 1000
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def calculate_time_until_available(
|
|
188
|
+
state: BucketState,
|
|
189
|
+
needed: int,
|
|
190
|
+
now_ms: int,
|
|
191
|
+
) -> float:
|
|
192
|
+
"""
|
|
193
|
+
Calculate seconds until `needed` tokens are available.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
state: Current bucket state
|
|
197
|
+
needed: Number of tokens needed
|
|
198
|
+
now_ms: Current timestamp in epoch milliseconds
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Seconds until available (0.0 if already available)
|
|
202
|
+
"""
|
|
203
|
+
refill = refill_bucket(
|
|
204
|
+
tokens_milli=state.tokens_milli,
|
|
205
|
+
last_refill_ms=state.last_refill_ms,
|
|
206
|
+
now_ms=now_ms,
|
|
207
|
+
burst_milli=state.burst_milli,
|
|
208
|
+
refill_amount_milli=state.refill_amount_milli,
|
|
209
|
+
refill_period_ms=state.refill_period_ms,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
needed_milli = needed * 1000
|
|
213
|
+
if refill.new_tokens_milli >= needed_milli:
|
|
214
|
+
return 0.0
|
|
215
|
+
|
|
216
|
+
deficit_milli = needed_milli - refill.new_tokens_milli
|
|
217
|
+
return calculate_retry_after(
|
|
218
|
+
deficit_milli=deficit_milli,
|
|
219
|
+
refill_amount_milli=state.refill_amount_milli,
|
|
220
|
+
refill_period_ms=state.refill_period_ms,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def force_consume(
|
|
225
|
+
state: BucketState,
|
|
226
|
+
amount: int,
|
|
227
|
+
now_ms: int,
|
|
228
|
+
) -> tuple[int, int]:
|
|
229
|
+
"""
|
|
230
|
+
Force consume tokens without checking limits (can go negative).
|
|
231
|
+
|
|
232
|
+
Used for post-hoc adjustments like LLM token reconciliation.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
state: Current bucket state
|
|
236
|
+
amount: Amount to consume (positive) or return (negative)
|
|
237
|
+
now_ms: Current timestamp in epoch milliseconds
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Tuple of (new_tokens_milli, new_last_refill_ms)
|
|
241
|
+
"""
|
|
242
|
+
# First refill
|
|
243
|
+
refill = refill_bucket(
|
|
244
|
+
tokens_milli=state.tokens_milli,
|
|
245
|
+
last_refill_ms=state.last_refill_ms,
|
|
246
|
+
now_ms=now_ms,
|
|
247
|
+
burst_milli=state.burst_milli,
|
|
248
|
+
refill_amount_milli=state.refill_amount_milli,
|
|
249
|
+
refill_period_ms=state.refill_period_ms,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Consume (can go negative)
|
|
253
|
+
new_tokens_milli = refill.new_tokens_milli - (amount * 1000)
|
|
254
|
+
|
|
255
|
+
return new_tokens_milli, refill.new_last_refill_ms
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_limit_status(
|
|
259
|
+
entity_id: str,
|
|
260
|
+
resource: str,
|
|
261
|
+
limit: Limit,
|
|
262
|
+
state: BucketState,
|
|
263
|
+
requested: int,
|
|
264
|
+
now_ms: int,
|
|
265
|
+
) -> LimitStatus:
|
|
266
|
+
"""
|
|
267
|
+
Build a LimitStatus for a bucket check.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
entity_id: Entity being checked
|
|
271
|
+
resource: Resource being accessed
|
|
272
|
+
limit: Limit configuration
|
|
273
|
+
state: Current bucket state
|
|
274
|
+
requested: Amount requested
|
|
275
|
+
now_ms: Current timestamp
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
LimitStatus with full details
|
|
279
|
+
"""
|
|
280
|
+
result = try_consume(state, requested, now_ms)
|
|
281
|
+
|
|
282
|
+
return LimitStatus(
|
|
283
|
+
entity_id=entity_id,
|
|
284
|
+
resource=resource,
|
|
285
|
+
limit_name=limit.name,
|
|
286
|
+
limit=limit,
|
|
287
|
+
available=result.available,
|
|
288
|
+
requested=requested,
|
|
289
|
+
exceeded=not result.success,
|
|
290
|
+
retry_after_seconds=result.retry_after_seconds,
|
|
291
|
+
)
|