moderato 0.3.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.
moderato/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Moderato - Async Redis-backed rate limiting for Python
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ A high-performance, Redis-backed rate limiting library with async support.
6
+
7
+ Basic usage:
8
+ >>> from moderato import RateLimiter
9
+ >>> limiter = RateLimiter(redis_url="redis://localhost:6379")
10
+ >>> await limiter.connect()
11
+ >>> await limiter.check(key="user:123", rate="100/minute")
12
+
13
+ FastAPI integration:
14
+ >>> from fastapi import FastAPI, Request
15
+ >>> from moderato import RateLimiter
16
+ >>>
17
+ >>> app = FastAPI()
18
+ >>> limiter = RateLimiter()
19
+ >>>
20
+ >>> @app.get("/api/data")
21
+ >>> @limiter.limit("100/minute")
22
+ >>> async def get_data(request: Request):
23
+ >>> return {"data": "..."}
24
+ """
25
+
26
+ from .exceptions import BackendError, RateLimitConfigError, RateLimitExceeded
27
+ from .limiter import RateLimiter
28
+ from .models import CheckResult, RateLimitConfig
29
+
30
+ # Framework integration (Starlette middleware) is optional - only import
31
+ # if starlette is available. Install with: pip install 'moderato[fastapi]'
32
+ try:
33
+ from .middleware import RateLimitHeadersMiddleware
34
+
35
+ _FRAMEWORK_AVAILABLE = True
36
+ except ModuleNotFoundError as exc:
37
+ # Hide only a missing starlette; a defect inside middleware.py or its
38
+ # other dependencies must surface instead of being swallowed.
39
+ if exc.name is None or not (exc.name == "starlette" or exc.name.startswith("starlette.")):
40
+ raise
41
+ _FRAMEWORK_AVAILABLE = False
42
+ RateLimitHeadersMiddleware = None # type: ignore[misc, assignment]
43
+
44
+ # Metrics are optional - only import if prometheus_client is available
45
+ try:
46
+ from .metrics import RateLimitMetrics as RateLimitMetrics
47
+ from .metrics import init_metrics as init_metrics
48
+
49
+ _METRICS_AVAILABLE = True
50
+ except ModuleNotFoundError as exc:
51
+ if exc.name is None or not (
52
+ exc.name == "prometheus_client" or exc.name.startswith("prometheus_client.")
53
+ ):
54
+ raise
55
+ _METRICS_AVAILABLE = False
56
+
57
+ __version__ = "0.3.0"
58
+ __author__ = "Arjun Aravind"
59
+ __email__ = "arjunaravind748@gmail.com"
60
+
61
+ __all__ = [
62
+ "RateLimiter",
63
+ "RateLimitExceeded",
64
+ "RateLimitConfigError",
65
+ "BackendError",
66
+ "RateLimitConfig",
67
+ "CheckResult",
68
+ ]
69
+
70
+ if _FRAMEWORK_AVAILABLE:
71
+ __all__.append("RateLimitHeadersMiddleware")
72
+
73
+ # Add metrics to exports if available
74
+ if _METRICS_AVAILABLE:
75
+ __all__.extend(["RateLimitMetrics", "init_metrics"])
@@ -0,0 +1,49 @@
1
+ """
2
+ Rate limiting algorithms module.
3
+
4
+ DEPRECATION NOTICE:
5
+ These algorithm classes are provided for reference and educational purposes.
6
+ The main RateLimiter class uses the Redis backend directly and does not use
7
+ these classes. For production use, always use the RateLimiter class:
8
+
9
+ from moderato import RateLimiter
10
+
11
+ limiter = RateLimiter(redis_url="redis://localhost:6379")
12
+ await limiter.check(key="user:123", rate="100/minute", algorithm="sliding_window")
13
+
14
+ The algorithm parameter in RateLimiter.check() accepts:
15
+ - "fixed_window" (default) - Simple fixed time windows
16
+ - "token_bucket" - Token bucket with smooth refill
17
+ - "sliding_window" - Weighted sliding window (most accurate)
18
+ """
19
+
20
+ import warnings
21
+ from typing import Any
22
+
23
+ from .base import RateLimitAlgorithm
24
+
25
+
26
+ def _deprecated_import(name: str) -> None:
27
+ warnings.warn(
28
+ f"{name} algorithm class is deprecated and may be removed in a future version. "
29
+ f"Use RateLimiter.check(algorithm='{name.lower()}') instead.",
30
+ DeprecationWarning,
31
+ stacklevel=3,
32
+ )
33
+
34
+
35
+ def __getattr__(name: str) -> Any:
36
+ if name == "TokenBucket":
37
+ _deprecated_import("TokenBucket")
38
+ from .token_bucket import TokenBucket
39
+
40
+ return TokenBucket
41
+ elif name == "SlidingWindow":
42
+ _deprecated_import("SlidingWindow")
43
+ from .sliding_window import SlidingWindow
44
+
45
+ return SlidingWindow
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
47
+
48
+
49
+ __all__ = ["RateLimitAlgorithm", "TokenBucket", "SlidingWindow"]
@@ -0,0 +1,62 @@
1
+ """
2
+ Base class for rate limiting algorithms.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, NamedTuple, Optional
7
+
8
+
9
+ class RateLimitResult(NamedTuple):
10
+ """Result of a rate limit check."""
11
+
12
+ allowed: bool # Whether the request is allowed
13
+ remaining: int # Number of requests remaining (with multiplier)
14
+ retry_after: int # Milliseconds until rate limit resets
15
+ reset_at: Optional[int] = None # Unix timestamp when limit resets
16
+
17
+
18
+ class RateLimitAlgorithm(ABC):
19
+ """Abstract base class for rate limiting algorithms."""
20
+
21
+ @abstractmethod
22
+ async def check(self, key: str, max_requests: int, window_seconds: int) -> RateLimitResult:
23
+ """
24
+ Check if a request is allowed under the rate limit.
25
+
26
+ Args:
27
+ key: Unique identifier for the rate limit
28
+ max_requests: Maximum allowed requests (with multiplier)
29
+ window_seconds: Time window in seconds
30
+
31
+ Returns:
32
+ RateLimitResult with status and metadata
33
+ """
34
+ pass
35
+
36
+ @abstractmethod
37
+ async def reset(self, key: str) -> bool:
38
+ """
39
+ Reset the rate limit for a specific key.
40
+
41
+ Args:
42
+ key: Unique identifier for the rate limit
43
+
44
+ Returns:
45
+ True if reset was successful
46
+ """
47
+ pass
48
+
49
+ @abstractmethod
50
+ async def get_usage(self, key: str, *args: Any, **kwargs: Any) -> dict[str, Any]:
51
+ """
52
+ Get current usage statistics for a key.
53
+
54
+ Args:
55
+ key: Unique identifier for the rate limit
56
+ *args: Additional positional arguments (algorithm-specific)
57
+ **kwargs: Additional keyword arguments (algorithm-specific)
58
+
59
+ Returns:
60
+ Dictionary with usage statistics
61
+ """
62
+ pass
@@ -0,0 +1,292 @@
1
+ """
2
+ Sliding Window rate limiting algorithm implementation.
3
+
4
+ The sliding window algorithm provides the most accurate rate limiting
5
+ by combining the current window with a weighted portion of the previous window.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from ..backends.redis import RedisBackend
14
+
15
+ from .base import RateLimitAlgorithm, RateLimitResult
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SlidingWindow(RateLimitAlgorithm):
21
+ """
22
+ Sliding Window algorithm implementation.
23
+
24
+ How it works:
25
+ - Maintains counts for current and previous time windows
26
+ - Calculates weighted average based on position in current window
27
+ - Weight = (1 - progress_through_window)
28
+ - Example: 30s into 60s window = 50% from previous + 50% from current
29
+
30
+ Mathematical Formula:
31
+ weighted_count = previous_count * (1 - t/T) + current_count
32
+ where:
33
+ - t = time elapsed in current window
34
+ - T = total window duration
35
+ - previous_count = requests in previous window
36
+ - current_count = requests in current window
37
+
38
+ Example (100 requests/minute):
39
+ Time: 14:35:30 (30 seconds into minute)
40
+ Previous window (14:34): 80 requests
41
+ Current window (14:35): 40 requests
42
+
43
+ Weight for previous = 1 - (30/60) = 0.5
44
+ Weighted count = 80 * 0.5 + 40 = 40 + 40 = 80 requests
45
+
46
+ Can accept: 100 - 80 = 20 more requests
47
+
48
+ Advantages over Fixed Window:
49
+ - No boundary bursts (smooths across window edges)
50
+ - More accurate distribution
51
+ - Better user experience
52
+ - Fairer rate limiting
53
+
54
+ Advantages over Token Bucket:
55
+ - Simpler to understand
56
+ - More predictable
57
+ - Lower memory usage
58
+ - Easier to debug
59
+
60
+ Disadvantages:
61
+ - Requires two Redis keys (current + previous)
62
+ - Slightly more complex than Fixed Window
63
+ - Not as smooth as Token Bucket for bursts
64
+ """
65
+
66
+ def __init__(self, backend: "RedisBackend") -> None:
67
+ """
68
+ Initialize Sliding Window algorithm.
69
+
70
+ Args:
71
+ backend: Redis backend for executing Lua scripts
72
+ """
73
+ self.backend = backend
74
+ logger.debug("Initialized SlidingWindow algorithm")
75
+
76
+ async def check(
77
+ self,
78
+ key: str,
79
+ max_requests: int,
80
+ window_seconds: int,
81
+ cost: int = 1000,
82
+ ) -> RateLimitResult:
83
+ """
84
+ Check if a request is allowed under sliding window rate limit.
85
+
86
+ Args:
87
+ key: Base identifier for the rate limit (without time suffix)
88
+ max_requests: Maximum requests (with multiplier)
89
+ window_seconds: Time window in seconds
90
+ cost: Number of requests to consume (with multiplier)
91
+
92
+ Returns:
93
+ RateLimitResult with allowed status and metadata
94
+
95
+ Example:
96
+ For "100/minute":
97
+ - max_requests = 100000 (100 * 1000)
98
+ - window_seconds = 60
99
+ - At 14:35:30:
100
+ - Current window: ratelimit:user:default:14:35
101
+ - Previous window: ratelimit:user:default:14:34
102
+ - Weight: 0.5 (30 seconds into current window)
103
+ """
104
+ # Get current timestamp
105
+ current_time = int(time.time())
106
+
107
+ # Calculate current window start time
108
+ window_start = current_time - (current_time % window_seconds)
109
+
110
+ # Calculate previous window start time
111
+ previous_window_start = window_start - window_seconds
112
+
113
+ # Generate keys for current and previous windows
114
+ # We'll append the window start timestamp to ensure uniqueness
115
+ current_key = f"{key}:{window_start}"
116
+ previous_key = f"{key}:{previous_window_start}"
117
+
118
+ # Execute sliding window Lua script
119
+ result = await self.backend.check_sliding_window(
120
+ current_key=current_key,
121
+ previous_key=previous_key,
122
+ max_requests=max_requests,
123
+ window_seconds=window_seconds,
124
+ current_time=current_time,
125
+ cost=cost,
126
+ )
127
+
128
+ # Calculate reset timestamp (start of next window)
129
+ reset_at = window_start + window_seconds
130
+
131
+ return RateLimitResult(
132
+ allowed=result.allowed,
133
+ remaining=result.remaining,
134
+ retry_after=result.retry_after,
135
+ reset_at=reset_at,
136
+ )
137
+
138
+ async def reset(self, key: str) -> bool:
139
+ """
140
+ Reset sliding window for a specific key.
141
+
142
+ This removes both current and previous window data.
143
+
144
+ Args:
145
+ key: Base identifier for the rate limit
146
+
147
+ Returns:
148
+ True if reset was successful
149
+ """
150
+ # For sliding window, we need to reset multiple keys
151
+ # We'll try to delete keys for recent windows
152
+ current_time = int(time.time())
153
+
154
+ # Try to delete current and recent windows
155
+ success = False
156
+ for offset in [0, 60, 3600, 86400]: # Now, minute, hour, day ago
157
+ window_start = current_time - (current_time % offset) if offset > 0 else current_time
158
+ test_key = f"{key}:{window_start}"
159
+ if await self.backend.reset(test_key):
160
+ success = True
161
+
162
+ # Also try previous window
163
+ if offset > 0:
164
+ prev_key = f"{key}:{window_start - offset}"
165
+ if await self.backend.reset(prev_key):
166
+ success = True
167
+
168
+ return success
169
+
170
+ async def get_usage(self, key: str, *args: Any, **kwargs: Any) -> dict[str, Any]:
171
+ """
172
+ Get current usage statistics for sliding window.
173
+
174
+ Args:
175
+ key: Base identifier for the rate limit
176
+ *args: Expected to contain max_requests and window_seconds
177
+ **kwargs: Not used
178
+
179
+ Returns:
180
+ Dictionary with:
181
+ - current: Weighted current usage (without multiplier)
182
+ - limit: Maximum requests (without multiplier)
183
+ - remaining: Requests remaining (without multiplier)
184
+ - current_window: Requests in current window
185
+ - previous_window: Requests in previous window
186
+ - weight: Weight applied to previous window (0.0 to 1.0)
187
+ """
188
+ max_requests: int = args[0] if len(args) > 0 else kwargs.get("max_requests", 0)
189
+ window_seconds: int = args[1] if len(args) > 1 else kwargs.get("window_seconds", 60)
190
+ current_time = int(time.time())
191
+ window_start = current_time - (current_time % window_seconds)
192
+ previous_window_start = window_start - window_seconds
193
+
194
+ current_key = f"{key}:{window_start}"
195
+ previous_key = f"{key}:{previous_window_start}"
196
+
197
+ # Get counts from Redis
198
+ current_usage = await self.backend.get_usage(current_key)
199
+ previous_usage = await self.backend.get_usage(previous_key)
200
+
201
+ current_count = current_usage.get("current", 0)
202
+ previous_count = previous_usage.get("current", 0)
203
+
204
+ # Calculate weight using integer math (consistent with Lua script)
205
+ elapsed_in_window = current_time - window_start
206
+ remaining_in_window = window_seconds - elapsed_in_window
207
+
208
+ # Use fixed-point weight (0-1000 scale) for consistency with Lua
209
+ prev_weight_fp = (remaining_in_window * 1000) // window_seconds if window_seconds > 0 else 0
210
+
211
+ # Calculate weighted count using integer math
212
+ # Formula: weighted = current + (previous * weight)
213
+ # Note: counts already have 1000x multiplier, weight is 0-1000
214
+ weighted_previous = (previous_count * prev_weight_fp) // 1000
215
+ weighted_count = (current_count + weighted_previous) // 1000 # Divide by 1000 for display
216
+
217
+ max_requests_display = max_requests // 1000
218
+ remaining = max(0, max_requests_display - weighted_count)
219
+
220
+ return {
221
+ "current": weighted_count,
222
+ "limit": max_requests_display,
223
+ "remaining": remaining,
224
+ "current_window": current_count // 1000,
225
+ "previous_window": previous_count // 1000,
226
+ "weight": prev_weight_fp / 1000, # Convert fixed-point to float for display
227
+ "window_seconds": window_seconds,
228
+ }
229
+
230
+
231
+ def calculate_sliding_window_count(
232
+ current_count: int,
233
+ previous_count: int,
234
+ window_seconds: int,
235
+ elapsed_seconds: int,
236
+ ) -> float:
237
+ """
238
+ Calculate weighted count for sliding window.
239
+
240
+ This is the core formula used by the sliding window algorithm.
241
+
242
+ Args:
243
+ current_count: Requests in current window
244
+ previous_count: Requests in previous window
245
+ window_seconds: Total window duration
246
+ elapsed_seconds: Time elapsed in current window
247
+
248
+ Returns:
249
+ Weighted count as float
250
+
251
+ Examples:
252
+ >>> # 30 seconds into 60-second window
253
+ >>> calculate_sliding_window_count(
254
+ ... current_count=40,
255
+ ... previous_count=80,
256
+ ... window_seconds=60,
257
+ ... elapsed_seconds=30
258
+ ... )
259
+ 80.0 # 40 + (80 * 0.5)
260
+
261
+ >>> # Start of window (0 seconds elapsed)
262
+ >>> calculate_sliding_window_count(
263
+ ... current_count=10,
264
+ ... previous_count=90,
265
+ ... window_seconds=60,
266
+ ... elapsed_seconds=0
267
+ ... )
268
+ 100.0 # 10 + (90 * 1.0)
269
+
270
+ >>> # End of window (60 seconds elapsed)
271
+ >>> calculate_sliding_window_count(
272
+ ... current_count=50,
273
+ ... previous_count=100,
274
+ ... window_seconds=60,
275
+ ... elapsed_seconds=60
276
+ ... )
277
+ 50.0 # 50 + (100 * 0.0)
278
+ """
279
+ if window_seconds <= 0:
280
+ raise ValueError("Window seconds must be positive")
281
+
282
+ if elapsed_seconds < 0 or elapsed_seconds > window_seconds:
283
+ raise ValueError(f"Elapsed seconds must be between 0 and {window_seconds}")
284
+
285
+ # Calculate weight for previous window
286
+ progress = elapsed_seconds / window_seconds
287
+ previous_weight = 1 - progress
288
+
289
+ # Apply weighted formula
290
+ weighted_count = current_count + (previous_count * previous_weight)
291
+
292
+ return weighted_count
@@ -0,0 +1,226 @@
1
+ """
2
+ Token Bucket rate limiting algorithm implementation.
3
+
4
+ The token bucket algorithm provides smoother rate limiting compared to
5
+ fixed window, with better handling of bursty traffic.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from ..backends.redis import RedisBackend
14
+
15
+ from .base import RateLimitAlgorithm, RateLimitResult
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class TokenBucket(RateLimitAlgorithm):
21
+ """
22
+ Token Bucket algorithm implementation.
23
+
24
+ How it works:
25
+ - A bucket holds tokens up to a maximum capacity
26
+ - Tokens are continuously added at a fixed refill rate
27
+ - Each request consumes one or more tokens
28
+ - If not enough tokens, request is denied
29
+ - Provides smooth rate limiting without boundary bursts
30
+
31
+ Example:
32
+ 100 requests/minute = 100 max tokens, ~1.67 refill rate/second
33
+ Allows bursts up to 100 requests, then sustained 1.67 req/sec
34
+
35
+ Advantages over Fixed Window:
36
+ - No burst at window boundaries
37
+ - Smoother traffic distribution
38
+ - Better for bursty workloads
39
+ - Allows controlled bursts within capacity
40
+
41
+ Disadvantages:
42
+ - Slightly more complex
43
+ - Uses more Redis memory (stores tokens + timestamp)
44
+ - Can allow more requests in first window
45
+ """
46
+
47
+ def __init__(self, backend: "RedisBackend") -> None:
48
+ """
49
+ Initialize Token Bucket algorithm.
50
+
51
+ Args:
52
+ backend: Redis backend for executing Lua scripts
53
+ """
54
+ self.backend = backend
55
+ logger.debug("Initialized TokenBucket algorithm")
56
+
57
+ async def check(
58
+ self,
59
+ key: str,
60
+ max_requests: int,
61
+ window_seconds: int,
62
+ cost: int = 1000,
63
+ ) -> RateLimitResult:
64
+ """
65
+ Check if a request is allowed under token bucket rate limit.
66
+
67
+ Args:
68
+ key: Unique identifier for the rate limit
69
+ max_requests: Maximum tokens (bucket capacity, with multiplier)
70
+ window_seconds: Time window to spread requests over
71
+ cost: Number of tokens to consume (with multiplier)
72
+
73
+ Returns:
74
+ RateLimitResult with allowed status and metadata
75
+
76
+ Example:
77
+ For "100/minute":
78
+ - max_requests = 100000 (100 * 1000)
79
+ - window_seconds = 60
80
+ - refill_rate = 100000 / 60 = 1666.67 tokens/sec
81
+ - Bucket starts full (100000 tokens)
82
+ - Refills at 1666.67 tokens/sec
83
+ - Max capacity: 100000 tokens
84
+ """
85
+ # Preserve fractional refill rates so limits such as 1/hour can recover.
86
+ refill_rate_per_second = max_requests / window_seconds
87
+
88
+ # Get current timestamp in milliseconds
89
+ current_time_ms = int(time.time() * 1000)
90
+
91
+ # Execute token bucket Lua script
92
+ result = await self.backend.check_token_bucket(
93
+ key=key,
94
+ max_tokens=max_requests,
95
+ refill_rate_per_second=refill_rate_per_second,
96
+ window_seconds=window_seconds,
97
+ current_time_ms=current_time_ms,
98
+ cost=cost,
99
+ )
100
+
101
+ # Calculate reset timestamp (when bucket would be full)
102
+ # If tokens remaining, no reset needed
103
+ # If denied, reset_at = current_time + retry_after
104
+ reset_at = None
105
+ if not result.allowed:
106
+ reset_at = (current_time_ms // 1000) + (result.retry_after // 1000)
107
+
108
+ return RateLimitResult(
109
+ allowed=result.allowed,
110
+ remaining=result.remaining,
111
+ retry_after=result.retry_after,
112
+ reset_at=reset_at,
113
+ )
114
+
115
+ async def reset(self, key: str) -> bool:
116
+ """
117
+ Reset token bucket for a specific key.
118
+
119
+ This removes the bucket data, causing the next request
120
+ to start with a full bucket.
121
+
122
+ Args:
123
+ key: Unique identifier for the rate limit
124
+
125
+ Returns:
126
+ True if reset was successful
127
+ """
128
+ return await self.backend.reset(key)
129
+
130
+ async def get_usage(self, key: str, *args: Any, **kwargs: Any) -> dict[str, Any]:
131
+ """
132
+ Get current usage statistics for a token bucket.
133
+
134
+ Args:
135
+ key: Unique identifier for the rate limit
136
+ *args: Expected to contain max_requests as first argument
137
+ **kwargs: Not used
138
+
139
+ Returns:
140
+ Dictionary with:
141
+ - current: Current token count (without multiplier)
142
+ - limit: Maximum tokens (without multiplier)
143
+ - remaining: Tokens remaining (without multiplier)
144
+ - last_refill: Unix timestamp of last refill
145
+
146
+ Note: For token bucket, "current" means tokens available,
147
+ not requests consumed (inverse of fixed window).
148
+ """
149
+ max_requests: int = args[0] if args else kwargs.get("max_requests", 0)
150
+ usage = await self.backend.get_token_bucket_usage(key)
151
+
152
+ # Convert from integer math (divide by 1000)
153
+ current_tokens = usage.get("tokens", max_requests) // 1000
154
+ max_tokens_display = max_requests // 1000
155
+
156
+ return {
157
+ "current": current_tokens,
158
+ "limit": max_tokens_display,
159
+ "remaining": current_tokens,
160
+ "last_refill": usage.get("last_refill", 0),
161
+ }
162
+
163
+
164
+ def calculate_refill_rate(requests: int, window_seconds: int) -> float:
165
+ """
166
+ Calculate token refill rate for token bucket.
167
+
168
+ Args:
169
+ requests: Number of requests allowed in window
170
+ window_seconds: Time window in seconds
171
+
172
+ Returns:
173
+ Refill rate in tokens per second
174
+
175
+ Examples:
176
+ >>> calculate_refill_rate(100, 60) # 100/minute
177
+ 1.6666666666666667
178
+
179
+ >>> calculate_refill_rate(1000, 3600) # 1000/hour
180
+ 0.2777777777777778
181
+
182
+ >>> calculate_refill_rate(10, 1) # 10/second
183
+ 10.0
184
+ """
185
+ if window_seconds <= 0:
186
+ raise ValueError("Window seconds must be positive")
187
+
188
+ return requests / window_seconds
189
+
190
+
191
+ def calculate_bucket_capacity(requests: int, burst_factor: float = 1.0) -> int:
192
+ """
193
+ Calculate bucket capacity with optional burst allowance.
194
+
195
+ By default, bucket capacity equals the rate limit. You can
196
+ increase capacity to allow larger bursts.
197
+
198
+ Args:
199
+ requests: Base number of requests allowed
200
+ burst_factor: Multiplier for bucket capacity (>= 1.0)
201
+ 1.0 = no extra burst (default)
202
+ 2.0 = allow 2x burst
203
+ 0.5 = not recommended (less than rate)
204
+
205
+ Returns:
206
+ Bucket capacity in tokens
207
+
208
+ Examples:
209
+ >>> calculate_bucket_capacity(100, 1.0) # Standard
210
+ 100
211
+
212
+ >>> calculate_bucket_capacity(100, 1.5) # Allow 50% burst
213
+ 150
214
+
215
+ >>> calculate_bucket_capacity(100, 2.0) # Allow 2x burst
216
+ 200
217
+
218
+ Note: Burst factor is not currently exposed in the main API,
219
+ but is available for advanced use cases.
220
+ """
221
+ if burst_factor < 1.0:
222
+ logger.warning(
223
+ f"Burst factor {burst_factor} < 1.0 may cause issues. " "Consider using >= 1.0"
224
+ )
225
+
226
+ return int(requests * burst_factor)