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/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
+ )