ratelimiter-keshav 0.1.0__tar.gz

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.
Files changed (36) hide show
  1. ratelimiter_keshav-0.1.0/.coverage +0 -0
  2. ratelimiter_keshav-0.1.0/.github/workflows/tests.yml +48 -0
  3. ratelimiter_keshav-0.1.0/CHANGELOG.md +16 -0
  4. ratelimiter_keshav-0.1.0/PKG-INFO +35 -0
  5. ratelimiter_keshav-0.1.0/README.md +17 -0
  6. ratelimiter_keshav-0.1.0/pyproject.toml +38 -0
  7. ratelimiter_keshav-0.1.0/rate_limiter/DESIGN.md +31 -0
  8. ratelimiter_keshav-0.1.0/rate_limiter/__init__.py +4 -0
  9. ratelimiter_keshav-0.1.0/rate_limiter/algorithms/__init__.py +4 -0
  10. ratelimiter_keshav-0.1.0/rate_limiter/algorithms/base.py +13 -0
  11. ratelimiter_keshav-0.1.0/rate_limiter/algorithms/fixed_window.py +31 -0
  12. ratelimiter_keshav-0.1.0/rate_limiter/algorithms/sliding_window_counter.py +90 -0
  13. ratelimiter_keshav-0.1.0/rate_limiter/algorithms/sliding_window_log.py +67 -0
  14. ratelimiter_keshav-0.1.0/rate_limiter/algorithms/token_bucket.py +71 -0
  15. ratelimiter_keshav-0.1.0/rate_limiter/core/exceptions.py +5 -0
  16. ratelimiter_keshav-0.1.0/rate_limiter/core/limiter.py +39 -0
  17. ratelimiter_keshav-0.1.0/rate_limiter/core/models.py +9 -0
  18. ratelimiter_keshav-0.1.0/rate_limiter/examples/__init__.py +0 -0
  19. ratelimiter_keshav-0.1.0/rate_limiter/examples/fastapi_demo.py +34 -0
  20. ratelimiter_keshav-0.1.0/rate_limiter/examples/flask_demo.py +38 -0
  21. ratelimiter_keshav-0.1.0/rate_limiter/middleware/__init__.py +2 -0
  22. ratelimiter_keshav-0.1.0/rate_limiter/middleware/fastapi_middleware.py +131 -0
  23. ratelimiter_keshav-0.1.0/rate_limiter/middleware/flask_middleware.py +125 -0
  24. ratelimiter_keshav-0.1.0/rate_limiter/storage/__init__.py +6 -0
  25. ratelimiter_keshav-0.1.0/rate_limiter/storage/base.py +30 -0
  26. ratelimiter_keshav-0.1.0/rate_limiter/storage/in_memory.py +62 -0
  27. ratelimiter_keshav-0.1.0/rate_limiter/storage/redis_backend.py +55 -0
  28. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_algorithms.py +134 -0
  29. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_concurrency.py +99 -0
  30. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_fastapi_middleware.py +61 -0
  31. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_flask_middleware.py +60 -0
  32. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_integration.py +38 -0
  33. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_limiter.py +18 -0
  34. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_sliding_window_counter.py +91 -0
  35. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_sliding_window_log.py +62 -0
  36. ratelimiter_keshav-0.1.0/rate_limiter/tests/test_storage.py +72 -0
Binary file
@@ -0,0 +1,48 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+
9
+ pull_request:
10
+ branches:
11
+ - main
12
+ - master
13
+
14
+ jobs:
15
+ test:
16
+
17
+ runs-on: ubuntu-latest
18
+
19
+ strategy:
20
+ matrix:
21
+ python-version: ["3.11", "3.12", "3.13"]
22
+
23
+ steps:
24
+
25
+ - name: Checkout Repository
26
+ uses: actions/checkout@v4
27
+
28
+ - name: Setup Python
29
+ uses: actions/setup-python@v5
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+
33
+ - name: Upgrade pip
34
+ run: |
35
+ python -m pip install --upgrade pip
36
+
37
+ - name: Install Dependencies
38
+ run: |
39
+ pip install pytest pytest-cov
40
+ pip install flask fastapi uvicorn redis httpx
41
+
42
+ - name: Run Tests
43
+ run: |
44
+ pytest
45
+
46
+ - name: Run Coverage
47
+ run: |
48
+ pytest --cov=rate_limiter
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## v0.1.0
4
+
5
+ ### Added
6
+
7
+ - Fixed Window Algorithm
8
+ - Sliding Window Log
9
+ - Sliding Window Counter
10
+ - Token Bucket
11
+ - InMemoryStorage
12
+ - RedisStorage
13
+ - Flask Middleware
14
+ - FastAPI Middleware
15
+ - Concurrency Tests
16
+ - Integration Tests
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: ratelimiter-keshav
3
+ Version: 0.1.0
4
+ Summary: A pluggable rate limiting library for Python web frameworks
5
+ Author: Keshav Sharma
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: redis>=4.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: httpx; extra == 'dev'
10
+ Requires-Dist: pytest; extra == 'dev'
11
+ Requires-Dist: pytest-cov; extra == 'dev'
12
+ Provides-Extra: fastapi
13
+ Requires-Dist: fastapi>=0.95; extra == 'fastapi'
14
+ Requires-Dist: starlette; extra == 'fastapi'
15
+ Provides-Extra: flask
16
+ Requires-Dist: flask>=2.0; extra == 'flask'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # API Rate Limiter
20
+
21
+ A pluggable rate limiting library for Python web frameworks.
22
+
23
+ ## Features
24
+
25
+ - Fixed Window
26
+ - Sliding Window Log
27
+ - Sliding Window Counter
28
+ - Token Bucket
29
+ - Flask Middleware
30
+ - FastAPI Middleware
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install ratelimiter-keshav
@@ -0,0 +1,17 @@
1
+ # API Rate Limiter
2
+
3
+ A pluggable rate limiting library for Python web frameworks.
4
+
5
+ ## Features
6
+
7
+ - Fixed Window
8
+ - Sliding Window Log
9
+ - Sliding Window Counter
10
+ - Token Bucket
11
+ - Flask Middleware
12
+ - FastAPI Middleware
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install ratelimiter-keshav
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ratelimiter-keshav"
7
+ version = "0.1.0"
8
+ description = "A pluggable rate limiting library for Python web frameworks"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+
12
+ dependencies = [
13
+ "redis>=4.0"
14
+ ]
15
+
16
+ authors = [
17
+ {name = "Keshav Sharma"}
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+
22
+ flask = [
23
+ "flask>=2.0"
24
+ ]
25
+
26
+ fastapi = [
27
+ "fastapi>=0.95",
28
+ "starlette"
29
+ ]
30
+
31
+ dev = [
32
+ "pytest",
33
+ "pytest-cov",
34
+ "httpx"
35
+ ]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["rate_limiter"]
@@ -0,0 +1,31 @@
1
+ # Design Decisions
2
+
3
+ ## Storage
4
+
5
+ Supported:
6
+ - InMemoryStorage
7
+ - RedisStorage
8
+
9
+ Future:
10
+ - PostgreSQL
11
+ - DynamoDB
12
+
13
+ ## Thread Safety
14
+
15
+ InMemoryStorage will use threading.Lock.
16
+
17
+ Redis relies on atomic operations.
18
+
19
+ ## Clock Source
20
+
21
+ Server time is authoritative.
22
+
23
+ Client timestamps are ignored.
24
+
25
+ ## Rate Limits
26
+
27
+ Supports:
28
+ - Global limits
29
+ - Route-specific limits
30
+
31
+ Route-specific limits override global limits.
@@ -0,0 +1,4 @@
1
+ from .core.limiter import RateLimiter
2
+ from .algorithms.token_bucket import TokenBucket
3
+
4
+ from .algorithms.fixed_window import FixedWindow
@@ -0,0 +1,4 @@
1
+ from .fixed_window import FixedWindow
2
+ from .token_bucket import TokenBucket
3
+ from .sliding_window_log import SlidingWindowLog
4
+ from .sliding_window_counter import SlidingWindowCounter
@@ -0,0 +1,13 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+
5
+ class RateLimitAlgorithm(ABC):
6
+
7
+ @abstractmethod
8
+ def is_allowed(
9
+ self,
10
+ key: str,
11
+ store: Any
12
+ ) -> tuple[bool, dict[str, Any]]:
13
+ pass
@@ -0,0 +1,31 @@
1
+ import time
2
+
3
+ from .base import RateLimitAlgorithm
4
+
5
+
6
+ class FixedWindow(RateLimitAlgorithm):
7
+
8
+ def __init__(
9
+ self,
10
+ limit: int,
11
+ window_seconds: int
12
+ ):
13
+ self.limit = limit
14
+ self.window = window_seconds
15
+
16
+ def is_allowed(
17
+ self,
18
+ key: str,
19
+ store
20
+ ) -> tuple[bool, dict]:
21
+ now = int(time.time())
22
+ window_key = f"{key}:{now // self.window}"
23
+ count = store.increment(window_key, ttl=self.window)
24
+ remaining = max(0, self.limit - count)
25
+ return count <= self.limit, {
26
+ "limit": self.limit,
27
+ "remaining": remaining,
28
+ "reset": (now // self.window + 1) * self.window
29
+ }
30
+
31
+
@@ -0,0 +1,90 @@
1
+ import time
2
+
3
+ from .base import RateLimitAlgorithm
4
+
5
+
6
+ class SlidingWindowCounter(RateLimitAlgorithm):
7
+
8
+ def __init__(
9
+ self,
10
+ limit: int,
11
+ window_seconds: int
12
+ ):
13
+ self.limit = limit
14
+ self.window = window_seconds
15
+
16
+ def is_allowed(
17
+ self,
18
+ key: str,
19
+ store
20
+ ) -> tuple[bool, dict]:
21
+
22
+ now = time.time()
23
+
24
+ current_window = int(
25
+ now // self.window
26
+ )
27
+
28
+ current_key = (
29
+ f"{key}:{current_window}"
30
+ )
31
+
32
+ previous_key = (
33
+ f"{key}:{current_window - 1}"
34
+ )
35
+
36
+ current_count = (
37
+ store.get(current_key)
38
+ or 0
39
+ )
40
+
41
+ previous_count = (
42
+ store.get(previous_key)
43
+ or 0
44
+ )
45
+
46
+ elapsed = (
47
+ now % self.window
48
+ )
49
+
50
+ weight = (
51
+ self.window - elapsed
52
+ ) / self.window
53
+
54
+ estimated_count = (
55
+ current_count +
56
+ previous_count * weight
57
+ )
58
+
59
+ if estimated_count >= self.limit:
60
+
61
+ return False, {
62
+ "limit": self.limit,
63
+ "remaining": 0,
64
+ "reset":
65
+ (
66
+ current_window + 1
67
+ ) * self.window
68
+ }
69
+
70
+ current_count = store.increment(
71
+ current_key,
72
+ ttl=self.window * 2
73
+ )
74
+
75
+ return True, {
76
+ "limit": self.limit,
77
+ "remaining":
78
+ max(
79
+ 0,
80
+ int(
81
+ self.limit -
82
+ estimated_count -
83
+ 1
84
+ )
85
+ ),
86
+ "reset":
87
+ (
88
+ current_window + 1
89
+ ) * self.window
90
+ }
@@ -0,0 +1,67 @@
1
+ import time
2
+
3
+ from .base import RateLimitAlgorithm
4
+
5
+
6
+ class SlidingWindowLog(RateLimitAlgorithm):
7
+
8
+ def __init__(
9
+ self,
10
+ limit: int,
11
+ window_seconds: int
12
+ ):
13
+ self.limit = limit
14
+ self.window = window_seconds
15
+
16
+ def is_allowed(
17
+ self,
18
+ key: str,
19
+ store
20
+ ) -> tuple[bool, dict]:
21
+
22
+ now = time.time()
23
+
24
+ timestamps = store.get(key)
25
+
26
+ if timestamps is None:
27
+ timestamps = []
28
+
29
+ timestamps = [
30
+ ts
31
+ for ts in timestamps
32
+ if now - ts < self.window
33
+ ]
34
+
35
+ if len(timestamps) >= self.limit:
36
+
37
+ store.set(
38
+ key,
39
+ timestamps,
40
+ ttl=self.window
41
+ )
42
+
43
+ return False, {
44
+ "limit": self.limit,
45
+ "remaining": 0,
46
+ "reset": int(
47
+ timestamps[0] +
48
+ self.window
49
+ )
50
+ }
51
+
52
+ timestamps.append(now)
53
+
54
+ store.set(
55
+ key,
56
+ timestamps,
57
+ ttl=self.window
58
+ )
59
+
60
+ return True, {
61
+ "limit": self.limit,
62
+ "remaining":
63
+ self.limit -
64
+ len(timestamps),
65
+ "reset":
66
+ int(now + self.window)
67
+ }
@@ -0,0 +1,71 @@
1
+ import time
2
+
3
+
4
+ class TokenBucket:
5
+
6
+ def __init__(
7
+ self,
8
+ capacity: int,
9
+ refill_rate: float
10
+ ):
11
+ self.capacity = capacity
12
+ self.refill_rate = refill_rate
13
+
14
+ def is_allowed(
15
+ self,
16
+ key: str,
17
+ store
18
+ ) -> tuple[bool, dict]:
19
+
20
+ now = time.time()
21
+
22
+ bucket = store.get(key)
23
+
24
+ if bucket is None:
25
+ bucket = {
26
+ "tokens": self.capacity,
27
+ "last": now
28
+ }
29
+
30
+ elapsed = now - bucket["last"]
31
+
32
+ bucket["tokens"] = min(
33
+ self.capacity,
34
+ bucket["tokens"] +
35
+ elapsed * self.refill_rate
36
+ )
37
+
38
+ bucket["last"] = now
39
+
40
+ if bucket["tokens"] >= 1:
41
+
42
+ bucket["tokens"] -= 1
43
+
44
+ store.set(
45
+ key,
46
+ bucket
47
+ )
48
+
49
+ return True, {
50
+ "remaining":
51
+ int(bucket["tokens"])
52
+ }
53
+
54
+ store.set(
55
+ key,
56
+ bucket
57
+ )
58
+
59
+ retry_after = None
60
+
61
+ if self.refill_rate > 0:
62
+
63
+ retry_after = (
64
+ (1 - bucket["tokens"])
65
+ / self.refill_rate
66
+ )
67
+
68
+ return False, {
69
+ "retry_after":
70
+ retry_after
71
+ }
@@ -0,0 +1,5 @@
1
+ class RateLimitExceeded(Exception):
2
+
3
+ def __init__(self, info):
4
+ self.info = info
5
+ super().__init__("Rate limit exceeded")
@@ -0,0 +1,39 @@
1
+ from functools import wraps
2
+ class RateLimiter:
3
+
4
+ def __init__(
5
+ self,
6
+ algorithm,
7
+ storage,
8
+ key_func=None
9
+ ):
10
+ self.algorithm = algorithm
11
+ self.storage = storage
12
+ self.key_func = key_func
13
+
14
+ def check(self, key):
15
+ return self.algorithm.is_allowed(
16
+ key,
17
+ self.storage
18
+ )
19
+
20
+
21
+ def limit(self):
22
+
23
+ def decorator(func):
24
+
25
+ @wraps(func)
26
+ def wrapper(*args, **kwargs):
27
+
28
+ allowed, meta = self.check("global")
29
+
30
+ if not allowed:
31
+ raise Exception(
32
+ "Rate limit exceeded"
33
+ )
34
+
35
+ return func(*args, **kwargs)
36
+
37
+ return wrapper
38
+
39
+ return decorator
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class RateLimitInfo:
5
+ allowed: bool
6
+ limit: int
7
+ remaining: int
8
+ reset: int | None = None
9
+ retry_after: float | None = None
@@ -0,0 +1,34 @@
1
+ from fastapi import FastAPI
2
+
3
+ from rate_limiter.storage import (
4
+ InMemoryStorage
5
+ )
6
+
7
+ from rate_limiter.algorithms import (
8
+ FixedWindow
9
+ )
10
+
11
+ from rate_limiter.middleware import (
12
+ FastAPIRateLimiter
13
+ )
14
+
15
+
16
+ app = FastAPI()
17
+
18
+ app.add_middleware(
19
+ FastAPIRateLimiter,
20
+ algorithm=FixedWindow(
21
+ limit=3,
22
+ window_seconds=60
23
+ ),
24
+ storage=InMemoryStorage()
25
+ )
26
+
27
+
28
+ @app.get("/")
29
+ def home():
30
+
31
+ return {
32
+ "message":
33
+ "hello fastapi"
34
+ }
@@ -0,0 +1,38 @@
1
+ from flask import Flask
2
+
3
+ from rate_limiter.storage import (
4
+ InMemoryStorage
5
+ )
6
+
7
+ from rate_limiter.algorithms import (
8
+ FixedWindow
9
+ )
10
+
11
+ from rate_limiter.middleware import (
12
+ FlaskRateLimiter
13
+ )
14
+
15
+
16
+ app = Flask(__name__)
17
+
18
+ limiter = FlaskRateLimiter(
19
+ algorithm=FixedWindow(
20
+ limit=3,
21
+ window_seconds=60
22
+ ),
23
+ storage=InMemoryStorage()
24
+ )
25
+
26
+
27
+ @app.route("/")
28
+ @limiter.limit()
29
+ def home():
30
+
31
+ return {
32
+ "message":
33
+ "hello world"
34
+ }
35
+
36
+
37
+ if __name__ == "__main__":
38
+ app.run(debug=True)
@@ -0,0 +1,2 @@
1
+ from .flask_middleware import FlaskRateLimiter
2
+ from .fastapi_middleware import FastAPIRateLimiter