fluxlimit 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.
fluxlimit/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """fluxlimit - Distributed rate limiting with fairness for Python.
2
+
3
+ A production-ready rate limiting library supporting:
4
+ - Token bucket, leaky bucket, fixed window, sliding window algorithms
5
+ - Distributed fairness via Redis (Lua scripts) and PostgreSQL (advisory locks)
6
+ - Cost-based limiting for different endpoint weights
7
+ - FastAPI/Starlette and Flask middleware integrations
8
+ - Prometheus metrics export
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from .core import RateLimiter, LimitRule
14
+ from .exceptions import (
15
+ FluxlimitError,
16
+ RateLimitExceeded,
17
+ BackendError,
18
+ ConfigurationError,
19
+ )
20
+ from .algorithms import (
21
+ TokenBucket,
22
+ TokenBucketConfig,
23
+ LeakyBucket,
24
+ LeakyBucketConfig,
25
+ FixedWindow,
26
+ FixedWindowConfig,
27
+ SlidingWindow,
28
+ SlidingWindowConfig,
29
+ LimitResult,
30
+ )
31
+ from .backends import (
32
+ MemoryBackend,
33
+ RedisBackend,
34
+ PostgresBackend,
35
+ )
36
+
37
+
38
+ def __getattr__(name: str):
39
+ if name in ("RateLimitMiddleware", "rate_limit", "Fluxlimit", "init_fluxlimit"):
40
+ from . import middleware
41
+
42
+ return getattr(middleware, name)
43
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
44
+
45
+
46
+ __all__ = [
47
+ # Core
48
+ "RateLimiter",
49
+ "LimitRule",
50
+ # Exceptions
51
+ "FluxlimitError",
52
+ "RateLimitExceeded",
53
+ "BackendError",
54
+ "ConfigurationError",
55
+ # Algorithms
56
+ "TokenBucket",
57
+ "TokenBucketConfig",
58
+ "LeakyBucket",
59
+ "LeakyBucketConfig",
60
+ "FixedWindow",
61
+ "FixedWindowConfig",
62
+ "SlidingWindow",
63
+ "SlidingWindowConfig",
64
+ "LimitResult",
65
+ # Backends
66
+ "MemoryBackend",
67
+ "RedisBackend",
68
+ "PostgresBackend",
69
+ # Middleware
70
+ "RateLimitMiddleware",
71
+ "rate_limit",
72
+ "Fluxlimit",
73
+ "init_fluxlimit",
74
+ ]
@@ -0,0 +1,22 @@
1
+ """Rate limiting algorithms."""
2
+
3
+ from .token_bucket import TokenBucket, TokenBucketConfig
4
+ from .leaky_bucket import LeakyBucket, LeakyBucketConfig
5
+ from .fixed_window import FixedWindow, FixedWindowConfig
6
+ from .sliding_window import SlidingWindow, SlidingWindowConfig
7
+ from .base import BaseAlgorithm, LimitResult, AlgorithmConfig
8
+
9
+ __all__ = [
10
+ "TokenBucket",
11
+ "TokenBucketConfig",
12
+ "LeakyBucket",
13
+ "LeakyBucketConfig",
14
+ "FixedWindow",
15
+ "FixedWindowConfig",
16
+ "SlidingWindow",
17
+ "SlidingWindowConfig",
18
+
19
+ "BaseAlgorithm",
20
+ "LimitResult",
21
+ "AlgorithmConfig",
22
+ ]
@@ -0,0 +1,64 @@
1
+ """Base class for rate limiting algorithms."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class LimitResult:
10
+ """Result of a rate limit check.
11
+
12
+ Attributes:
13
+ allowed: Whether the request is allowed.
14
+ remaining: Number of requests remaining in the current window.
15
+ reset_after: Seconds until the limit resets.
16
+ retry_after: Seconds until the request can be retried (only if not allowed).
17
+ """
18
+ allowed: bool
19
+ remaining: int
20
+ reset_after: float
21
+ retry_after: float = 0.0
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AlgorithmConfig:
26
+ """Configuration for a rate limiting algorithm."""
27
+ key: str
28
+
29
+
30
+ class BaseAlgorithm(ABC):
31
+ """Abstract base class for rate limiting algorithms."""
32
+
33
+ def __init__(self, key_prefix: str = "fluxlimit"):
34
+ self.key_prefix = key_prefix
35
+
36
+ def _make_key(self, identifier: str, config: AlgorithmConfig) -> str:
37
+ """Generate a storage key for the given identifier and config."""
38
+ return f"{self.key_prefix}:{config.key}:{identifier}"
39
+
40
+ @abstractmethod
41
+ async def check(
42
+ self,
43
+ backend,
44
+ identifier: str,
45
+ config: AlgorithmConfig,
46
+ cost: int = 1
47
+ ) -> LimitResult:
48
+ """Check if a request is allowed under the rate limit.
49
+
50
+ Args:
51
+ backend: The storage backend to use.
52
+ identifier: Unique identifier for the client (IP, API key, user ID).
53
+ config: Algorithm-specific configuration.
54
+ cost: How many tokens/credits this request consumes.
55
+
56
+ Returns:
57
+ LimitResult indicating whether the request is allowed.
58
+ """
59
+ pass
60
+
61
+ @abstractmethod
62
+ def get_config_class(self) -> type:
63
+ """Return the configuration dataclass for this algorithm."""
64
+ pass
@@ -0,0 +1,64 @@
1
+ """Fixed window rate limiting algorithm."""
2
+
3
+ from dataclasses import dataclass
4
+ import time
5
+ import math
6
+
7
+ from .base import BaseAlgorithm, LimitResult, AlgorithmConfig
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class FixedWindowConfig(AlgorithmConfig):
12
+ """Configuration for fixed window algorithm.
13
+
14
+ Attributes:
15
+ key: Unique key for this limit configuration.
16
+ max_requests: Maximum requests allowed per window.
17
+ window_seconds: Duration of each window in seconds.
18
+ """
19
+ max_requests: int = 100
20
+ window_seconds: int = 60
21
+
22
+
23
+ class FixedWindow(BaseAlgorithm):
24
+ """Fixed window rate limiter.
25
+
26
+ Counts requests in fixed time windows. Simple but can allow
27
+ bursts at window boundaries (2x the limit in a short period).
28
+
29
+ Use for simple cases where boundary bursts are acceptable.
30
+ """
31
+
32
+ def __init__(self, key_prefix: str = "fluxlimit:fixed_window"):
33
+ super().__init__(key_prefix)
34
+
35
+ def get_config_class(self) -> type:
36
+ return FixedWindowConfig
37
+
38
+ async def check(
39
+ self,
40
+ backend,
41
+ identifier: str,
42
+ config: FixedWindowConfig,
43
+ cost: int = 1
44
+ ) -> LimitResult:
45
+ """Check if request is allowed using fixed window algorithm."""
46
+ key = self._make_key(identifier, config)
47
+ now = time.time()
48
+ window_start = math.floor(now / config.window_seconds) * config.window_seconds
49
+ window_key = f"{key}:{int(window_start)}"
50
+
51
+ result = await backend.fixed_window_increment(
52
+ key=window_key,
53
+ max_requests=config.max_requests,
54
+ window_seconds=config.window_seconds,
55
+ cost=cost,
56
+ now=now
57
+ )
58
+
59
+ return LimitResult(
60
+ allowed=result["allowed"],
61
+ remaining=result["remaining"],
62
+ reset_after=result["reset_after"],
63
+ retry_after=result.get("retry_after", 0.0)
64
+ )
@@ -0,0 +1,64 @@
1
+ """Leaky bucket rate limiting algorithm."""
2
+
3
+ from dataclasses import dataclass
4
+ import time
5
+
6
+ from .base import BaseAlgorithm, LimitResult, AlgorithmConfig
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class LeakyBucketConfig(AlgorithmConfig):
11
+ """Configuration for leaky bucket algorithm.
12
+
13
+ Attributes:
14
+ key: Unique key for this limit configuration.
15
+ rate: Requests processed per second (leak rate).
16
+ capacity: Maximum queue size.
17
+ """
18
+ rate: float = 10.0
19
+ capacity: int = 100
20
+
21
+
22
+ class LeakyBucket(BaseAlgorithm):
23
+ """Leaky bucket rate limiter.
24
+
25
+ Requests are queued and processed at a constant rate.
26
+ If the queue is full, requests are rejected.
27
+
28
+ This provides:
29
+ - Strict rate enforcement (no bursts allowed)
30
+ - Smooth output traffic
31
+ - Protection against traffic spikes
32
+ """
33
+
34
+ def __init__(self, key_prefix: str = "fluxlimit:leaky_bucket"):
35
+ super().__init__(key_prefix)
36
+
37
+ def get_config_class(self) -> type:
38
+ return LeakyBucketConfig
39
+
40
+ async def check(
41
+ self,
42
+ backend,
43
+ identifier: str,
44
+ config: LeakyBucketConfig,
45
+ cost: int = 1
46
+ ) -> LimitResult:
47
+ """Check if request is allowed using leaky bucket algorithm."""
48
+ key = self._make_key(identifier, config)
49
+ now = time.time()
50
+
51
+ result = await backend.leaky_bucket_request(
52
+ key=key,
53
+ rate=config.rate,
54
+ capacity=config.capacity,
55
+ cost=cost,
56
+ now=now
57
+ )
58
+
59
+ return LimitResult(
60
+ allowed=result["allowed"],
61
+ remaining=result["remaining"],
62
+ reset_after=result["reset_after"],
63
+ retry_after=result.get("retry_after", 0.0)
64
+ )
@@ -0,0 +1,64 @@
1
+ """Sliding window rate limiting algorithm."""
2
+
3
+ from dataclasses import dataclass
4
+ import time
5
+
6
+ from .base import BaseAlgorithm, LimitResult, AlgorithmConfig
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SlidingWindowConfig(AlgorithmConfig):
11
+ """Configuration for sliding window algorithm.
12
+
13
+ Attributes:
14
+ key: Unique key for this limit configuration.
15
+ max_requests: Maximum requests allowed in the window.
16
+ window_seconds: Duration of the sliding window in seconds.
17
+ """
18
+ max_requests: int = 100
19
+ window_seconds: int = 60
20
+
21
+
22
+ class SlidingWindow(BaseAlgorithm):
23
+ """Sliding window rate limiter.
24
+
25
+ Uses a sliding time window to count requests. More accurate
26
+ than fixed window but requires more storage (tracking timestamps).
27
+
28
+ This provides:
29
+ - No boundary burst issues
30
+ - Smooth rate limiting over time
31
+ - Higher accuracy at the cost of storage
32
+ """
33
+
34
+ def __init__(self, key_prefix: str = "fluxlimit:sliding_window"):
35
+ super().__init__(key_prefix)
36
+
37
+ def get_config_class(self) -> type:
38
+ return SlidingWindowConfig
39
+
40
+ async def check(
41
+ self,
42
+ backend,
43
+ identifier: str,
44
+ config: SlidingWindowConfig,
45
+ cost: int = 1
46
+ ) -> LimitResult:
47
+ """Check if request is allowed using sliding window algorithm."""
48
+ key = self._make_key(identifier, config)
49
+ now = time.time()
50
+
51
+ result = await backend.sliding_window_check(
52
+ key=key,
53
+ max_requests=config.max_requests,
54
+ window_seconds=config.window_seconds,
55
+ cost=cost,
56
+ now=now
57
+ )
58
+
59
+ return LimitResult(
60
+ allowed=result["allowed"],
61
+ remaining=result["remaining"],
62
+ reset_after=result["reset_after"],
63
+ retry_after=result.get("retry_after", 0.0)
64
+ )
@@ -0,0 +1,67 @@
1
+ """Token bucket rate limiting algorithm."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+ import time
6
+ import math
7
+
8
+ from .base import BaseAlgorithm, LimitResult, AlgorithmConfig
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TokenBucketConfig(AlgorithmConfig):
13
+ """Configuration for token bucket algorithm.
14
+
15
+ Attributes:
16
+ key: Unique key for this limit configuration.
17
+ rate: Tokens added per second (sustained rate).
18
+ burst: Maximum bucket size (burst capacity).
19
+ """
20
+ rate: float = 10.0
21
+ burst: int = 20
22
+
23
+
24
+ class TokenBucket(BaseAlgorithm):
25
+ """Token bucket rate limiter.
26
+
27
+ The bucket starts full. Each request consumes tokens.
28
+ Tokens are replenished at a constant rate up to the burst capacity.
29
+
30
+ This provides:
31
+ - Smooth traffic shaping (requests are spread out)
32
+ - Bursty traffic tolerance (up to burst capacity)
33
+ - Distributed fairness when used with a shared backend
34
+ """
35
+
36
+ def __init__(self, key_prefix: str = "fluxlimit:token_bucket"):
37
+ super().__init__(key_prefix)
38
+
39
+ def get_config_class(self) -> type:
40
+ return TokenBucketConfig
41
+
42
+ async def check(
43
+ self,
44
+ backend,
45
+ identifier: str,
46
+ config: TokenBucketConfig,
47
+ cost: int = 1
48
+ ) -> LimitResult:
49
+ """Check if request is allowed using token bucket algorithm."""
50
+ key = self._make_key(identifier, config)
51
+ now = time.time()
52
+
53
+ # Use backend's atomic compare-and-swap or Lua script for distributed fairness
54
+ result = await backend.token_bucket_consume(
55
+ key=key,
56
+ rate=config.rate,
57
+ burst=config.burst,
58
+ cost=cost,
59
+ now=now
60
+ )
61
+
62
+ return LimitResult(
63
+ allowed=result["allowed"],
64
+ remaining=result["remaining"],
65
+ reset_after=result["reset_after"],
66
+ retry_after=result.get("retry_after", 0.0)
67
+ )
@@ -0,0 +1,13 @@
1
+ """Storage backends for fluxlimit."""
2
+
3
+ from .base import BaseBackend
4
+ from .memory import MemoryBackend
5
+ from .redis import RedisBackend
6
+ from .postgres import PostgresBackend
7
+
8
+ __all__ = [
9
+ "BaseBackend",
10
+ "MemoryBackend",
11
+ "RedisBackend",
12
+ "PostgresBackend",
13
+ ]
@@ -0,0 +1,79 @@
1
+ """Base class for rate limiter backends."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, Any, Optional
5
+
6
+
7
+ class BaseBackend(ABC):
8
+ """Abstract base class for rate limiter storage backends.
9
+
10
+ All backends must implement atomic operations for each algorithm
11
+ to ensure distributed fairness.
12
+ """
13
+
14
+ @abstractmethod
15
+ async def connect(self) -> None:
16
+ """Establish connection to the backend."""
17
+ pass
18
+
19
+ @abstractmethod
20
+ async def disconnect(self) -> None:
21
+ """Close connection to the backend."""
22
+ pass
23
+
24
+ @abstractmethod
25
+ async def token_bucket_consume(
26
+ self,
27
+ key: str,
28
+ rate: float,
29
+ burst: int,
30
+ cost: int,
31
+ now: float
32
+ ) -> Dict[str, Any]:
33
+ """Atomically consume tokens from a bucket.
34
+
35
+ Returns dict with keys: allowed (bool), remaining (int),
36
+ reset_after (float), retry_after (float).
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def leaky_bucket_request(
42
+ self,
43
+ key: str,
44
+ rate: float,
45
+ capacity: int,
46
+ cost: int,
47
+ now: float
48
+ ) -> Dict[str, Any]:
49
+ """Atomically request space in leaky bucket."""
50
+ pass
51
+
52
+ @abstractmethod
53
+ async def fixed_window_increment(
54
+ self,
55
+ key: str,
56
+ max_requests: int,
57
+ window_seconds: int,
58
+ cost: int,
59
+ now: float
60
+ ) -> Dict[str, Any]:
61
+ """Atomically increment counter for fixed window."""
62
+ pass
63
+
64
+ @abstractmethod
65
+ async def sliding_window_check(
66
+ self,
67
+ key: str,
68
+ max_requests: int,
69
+ window_seconds: int,
70
+ cost: int,
71
+ now: float
72
+ ) -> Dict[str, Any]:
73
+ """Atomically check sliding window."""
74
+ pass
75
+
76
+ @abstractmethod
77
+ async def health_check(self) -> bool:
78
+ """Check if backend is healthy."""
79
+ pass