pycurb 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.
Files changed (45) hide show
  1. pycurb/__init__.py +0 -0
  2. pycurb/adapters/django/__init__.py +11 -0
  3. pycurb/adapters/django/decorators.py +77 -0
  4. pycurb/adapters/django/extractors.py +25 -0
  5. pycurb/adapters/django/middleware.py +46 -0
  6. pycurb/adapters/fastapi/__init__.py +17 -0
  7. pycurb/adapters/fastapi/dependencies.py +30 -0
  8. pycurb/adapters/fastapi/extractors.py +24 -0
  9. pycurb/adapters/fastapi/middleware.py +53 -0
  10. pycurb/adapters/flask/__init__.py +17 -0
  11. pycurb/adapters/flask/decorators.py +44 -0
  12. pycurb/adapters/flask/extractors.py +35 -0
  13. pycurb/adapters/flask/middleware.py +54 -0
  14. pycurb/core/__init__.py +24 -0
  15. pycurb/core/algorithms/__init__.py +12 -0
  16. pycurb/core/algorithms/base.py +21 -0
  17. pycurb/core/algorithms/base_async.py +23 -0
  18. pycurb/core/algorithms/fixed_window.py +26 -0
  19. pycurb/core/algorithms/fixed_window_async.py +28 -0
  20. pycurb/core/algorithms/gcra.py +38 -0
  21. pycurb/core/algorithms/gcra_async.py +40 -0
  22. pycurb/core/algorithms/leaky_bucket.py +40 -0
  23. pycurb/core/algorithms/leaky_bucket_async.py +42 -0
  24. pycurb/core/algorithms/sliding_window.py +25 -0
  25. pycurb/core/algorithms/sliding_window_async.py +28 -0
  26. pycurb/core/algorithms/token_bucket.py +39 -0
  27. pycurb/core/algorithms/token_bucket_async.py +41 -0
  28. pycurb/core/decorators.py +155 -0
  29. pycurb/core/limiter.py +114 -0
  30. pycurb/core/limiter_async.py +123 -0
  31. pycurb/core/models.py +271 -0
  32. pycurb/core/resolver.py +97 -0
  33. pycurb/core/storage/__init__.py +8 -0
  34. pycurb/core/storage/base.py +123 -0
  35. pycurb/core/storage/base_async.py +123 -0
  36. pycurb/core/storage/memory.py +156 -0
  37. pycurb/core/storage/memory_async.py +156 -0
  38. pycurb/core/storage/redis.py +360 -0
  39. pycurb/core/storage/redis_async.py +371 -0
  40. pycurb/utils.py +111 -0
  41. pycurb-0.1.0.dist-info/METADATA +256 -0
  42. pycurb-0.1.0.dist-info/RECORD +45 -0
  43. pycurb-0.1.0.dist-info/WHEEL +5 -0
  44. pycurb-0.1.0.dist-info/licenses/LICENSE +201 -0
  45. pycurb-0.1.0.dist-info/top_level.txt +1 -0
pycurb/__init__.py ADDED
File without changes
@@ -0,0 +1,11 @@
1
+ from .extractors import ip_extractor, user_id_extractor, api_key_extractor
2
+ from .decorators import rate_limit
3
+ from .middleware import create_rate_limit_middleware
4
+
5
+ __all__ = [
6
+ "ip_extractor",
7
+ "user_id_extractor",
8
+ "api_key_extractor",
9
+ "rate_limit",
10
+ "create_rate_limit_middleware",
11
+ ]
@@ -0,0 +1,77 @@
1
+ import functools
2
+ import inspect
3
+ from typing import Union, List, Callable, Optional, cast
4
+ from django.http import JsonResponse, HttpRequest
5
+ from pycurb.core import RateLimiter, AsyncRateLimiter
6
+ from pycurb.core.models import RateLimitHeaders
7
+ from .extractors import ip_extractor
8
+
9
+
10
+ def rate_limit(
11
+ limiter: Union[RateLimiter, AsyncRateLimiter],
12
+ rule_name: Union[str, List[str]],
13
+ key_extractor: Callable[[HttpRequest], str] = ip_extractor,
14
+ on_limit: Optional[Callable] = None,
15
+ ):
16
+ """
17
+ Django view decorator for rate limiting.
18
+ Works for sync and async views.
19
+ Returns 429 JSON response when limit exceeded.
20
+ """
21
+
22
+ def decorator(view_func):
23
+ is_async = inspect.iscoroutinefunction(view_func)
24
+ limiter_is_async = isinstance(limiter, AsyncRateLimiter) # RateLimiter is async
25
+
26
+ if is_async and not limiter_is_async:
27
+ raise TypeError(
28
+ "Async view requires an async RateLimiter (use AsyncRateLimiter, not RateLimiter)"
29
+ )
30
+ if not is_async and limiter_is_async:
31
+ raise TypeError(
32
+ "Sync view requires a sync RateLimiter (use RateLimiter, not AsyncRateLimiter)"
33
+ )
34
+
35
+ @functools.wraps(view_func)
36
+ def sync_wrapper(request, *args, **kwargs):
37
+ key = key_extractor(request)
38
+ limiter_sync = cast(RateLimiter, limiter)
39
+ result = limiter_sync.check(key, rule_name)
40
+ if not result.allowed:
41
+ if on_limit:
42
+ return on_limit(request, result)
43
+ headers = RateLimitHeaders.from_result(result)
44
+ response = JsonResponse({"detail": "Rate limit exceeded"}, status=429)
45
+ for name, value in headers.to_dict().items():
46
+ response[name] = value
47
+ return response
48
+
49
+ response = view_func(request, *args, **kwargs)
50
+ headers = RateLimitHeaders.from_result(result)
51
+ for name, value in headers.to_dict().items():
52
+ response[name] = value
53
+ return response
54
+
55
+ @functools.wraps(view_func)
56
+ async def async_wrapper(request, *args, **kwargs):
57
+ key = key_extractor(request)
58
+ limiter_async = cast(AsyncRateLimiter, limiter)
59
+ result = await limiter_async.check(key, rule_name)
60
+ if not result.allowed:
61
+ if on_limit:
62
+ return on_limit(request, result)
63
+ headers = RateLimitHeaders.from_result(result)
64
+ response = JsonResponse({"detail": "Rate limit exceeded"}, status=429)
65
+ for name, value in headers.to_dict().items():
66
+ response[name] = value
67
+ return response
68
+
69
+ response = await view_func(request, *args, **kwargs)
70
+ headers = RateLimitHeaders.from_result(result)
71
+ for name, value in headers.to_dict().items():
72
+ response[name] = value
73
+ return response
74
+
75
+ return async_wrapper if is_async else sync_wrapper
76
+
77
+ return decorator
@@ -0,0 +1,25 @@
1
+ from django.http import HttpRequest
2
+
3
+
4
+ def ip_extractor(request: HttpRequest) -> str:
5
+ """Extract client IP from request, respecting X-Forwarded-For."""
6
+ forwarded = request.headers.get("X-Forwarded-For")
7
+ if forwarded:
8
+ return forwarded.split(",")[0].strip()
9
+ return request.META.get("REMOTE_ADDR", "unknown")
10
+
11
+
12
+ def user_id_extractor(request: HttpRequest) -> str:
13
+ """Extract authenticated user ID (or 'anon')."""
14
+ if hasattr(request, "user") and request.user.is_authenticated:
15
+ return str(request.user.pk)
16
+ return "anon"
17
+
18
+
19
+ def api_key_extractor(request: HttpRequest, header: str = "X-API-Key") -> str:
20
+ """Extract API key from a custom header."""
21
+ return request.headers.get(header, "")
22
+
23
+ def custom_extractor(extractor_func):
24
+ """Decorator to register custom extractors (optional)."""
25
+ return extractor_func
@@ -0,0 +1,46 @@
1
+ from django.http import JsonResponse
2
+ from typing import Callable, Union
3
+ from pycurb.core import RateLimiter, AsyncRateLimiter
4
+ from pycurb.core.models import RateLimitHeaders
5
+ from .extractors import ip_extractor
6
+
7
+
8
+ def create_rate_limit_middleware(
9
+ limiter: Union[RateLimiter, AsyncRateLimiter],
10
+ rule_name: str,
11
+ key_extractor: Callable = ip_extractor,
12
+ ):
13
+ """
14
+ Factory that returns a Django middleware class for global rate limiting.
15
+ Works with sync limiter for now.
16
+ If you use async limiter, you must ensure it is compatible with sync calls
17
+ (e.g., by using asyncio.run). For simplicity, use RateLimiter.
18
+ """
19
+ if isinstance(limiter, AsyncRateLimiter):
20
+ raise TypeError(
21
+ "Out-of-box Middleware only supports a sync limiter for now. Use RateLimiter."
22
+ )
23
+
24
+ class RateLimitMiddleware:
25
+ def __init__(self, get_response):
26
+ self.get_response = get_response
27
+ self.limiter = limiter
28
+ self.rule_name = rule_name
29
+ self.key_extractor = key_extractor
30
+
31
+ def __call__(self, request):
32
+ key = self.key_extractor(request)
33
+ result = self.limiter.check(key, self.rule_name)
34
+ if not result.allowed:
35
+ headers = RateLimitHeaders.from_result(result)
36
+ response = JsonResponse({"detail": "Rate limit exceeded"}, status=429)
37
+ for name, value in headers.to_dict().items():
38
+ response[name] = value
39
+ return response
40
+ response = self.get_response(request)
41
+ headers = RateLimitHeaders.from_result(result)
42
+ for name, value in headers.to_dict().items():
43
+ response[name] = value
44
+ return response
45
+
46
+ return RateLimitMiddleware
@@ -0,0 +1,17 @@
1
+ from .extractors import (
2
+ ip_extractor,
3
+ api_key_extractor,
4
+ user_id_extractor,
5
+ custom_extractor,
6
+ )
7
+ from .dependencies import rate_limiter
8
+ from .middleware import RateLimitMiddleware
9
+
10
+ __all__ = [
11
+ "ip_extractor",
12
+ "api_key_extractor",
13
+ "user_id_extractor",
14
+ "custom_extractor",
15
+ "rate_limiter",
16
+ "RateLimitMiddleware",
17
+ ]
@@ -0,0 +1,30 @@
1
+ from typing import Union, List, Callable
2
+ from fastapi import Request, HTTPException
3
+ from pycurb.core import AsyncRateLimiter, RateLimitResult
4
+ from .extractors import ip_extractor
5
+ import time
6
+
7
+
8
+ def rate_limiter(
9
+ limiter: AsyncRateLimiter,
10
+ rule_name: Union[str, List[str]],
11
+ key_extractor: Callable[[Request], str] = ip_extractor,
12
+ ):
13
+ """
14
+ Factory that creates a FastAPI dependency for rate limiting.
15
+ """
16
+
17
+ async def dependency(request: Request) -> RateLimitResult:
18
+ key = key_extractor(request)
19
+ result = await limiter.check(key, rule_name)
20
+ if not result.allowed:
21
+ retry_after = max(1, int(result.reset_at - time.time()))
22
+ raise HTTPException(
23
+ status_code=429,
24
+ detail="Rate limit exceeded",
25
+ headers={"Retry-After": str(retry_after)},
26
+ )
27
+ request.state.rate_limit_result = result
28
+ return result
29
+
30
+ return dependency
@@ -0,0 +1,24 @@
1
+ from fastapi import Request
2
+
3
+
4
+ def ip_extractor(request: Request) -> str:
5
+ """Extract client IP address, respecting X-Forwarded-For."""
6
+ forwarded = request.headers.get("X-Forwarded-For")
7
+ if forwarded:
8
+ return forwarded.split(",")[0].strip()
9
+ return request.client.host if request.client else "unknown"
10
+
11
+
12
+ def api_key_extractor(request: Request, header_name: str = "X-API-Key") -> str:
13
+ """Extract API key from a header."""
14
+ return request.headers.get(header_name, "")
15
+
16
+
17
+ def user_id_extractor(request: Request) -> str:
18
+ """Extract user ID from request state (set by auth middleware)."""
19
+ return getattr(request.state, "user_id", "anon")
20
+
21
+
22
+ def custom_extractor(extractor_func):
23
+ """Decorator to register custom extractors (optional)."""
24
+ return extractor_func
@@ -0,0 +1,53 @@
1
+ from fastapi import Request
2
+ from fastapi.responses import JSONResponse
3
+ from starlette.middleware.base import BaseHTTPMiddleware
4
+ from starlette.types import ASGIApp
5
+ from typing import Callable, Optional
6
+ from pycurb.core import AsyncRateLimiter
7
+ from pycurb.core.models import RateLimitHeaders
8
+ from .extractors import ip_extractor
9
+
10
+
11
+ class RateLimitMiddleware(BaseHTTPMiddleware):
12
+ """
13
+ ASGI middleware to apply a global rate limit rule to all requests.
14
+ Optionally exclude certain paths.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ app: ASGIApp,
20
+ limiter: AsyncRateLimiter,
21
+ rule_name: str,
22
+ key_extractor: Callable[[Request], str] = ip_extractor,
23
+ exclude_paths: Optional[list[str]] = None,
24
+ ):
25
+ super().__init__(app)
26
+ self.limiter = limiter
27
+ self.rule_name = rule_name
28
+ self.key_extractor = key_extractor
29
+ self.exclude_paths = exclude_paths or []
30
+
31
+ async def dispatch(self, request: Request, call_next):
32
+ # Skip excluded paths
33
+ for path in self.exclude_paths:
34
+ if request.url.path.startswith(path):
35
+ return await call_next(request)
36
+
37
+ key = self.key_extractor(request)
38
+ result = await self.limiter.check(key, self.rule_name)
39
+
40
+ if not result.allowed:
41
+ headers = RateLimitHeaders.from_result(result)
42
+ return JSONResponse(
43
+ status_code=429,
44
+ content={"detail": "Rate limit exceeded"},
45
+ headers=headers.to_dict(),
46
+ )
47
+
48
+ # Add rate limit headers to response
49
+ response = await call_next(request)
50
+ headers = RateLimitHeaders.from_result(result)
51
+ for name, value in headers.to_dict().items():
52
+ response.headers[name] = value
53
+ return response
@@ -0,0 +1,17 @@
1
+ from .extractors import (
2
+ ip_extractor,
3
+ flask_login_user_extractor,
4
+ session_user_extractor,
5
+ api_key_extractor,
6
+ )
7
+ from .decorators import rate_limit
8
+ from .middleware import RateLimit
9
+
10
+ __all__ = [
11
+ "ip_extractor",
12
+ "flask_login_user_extractor",
13
+ "session_user_extractor",
14
+ "api_key_extractor",
15
+ "rate_limit",
16
+ "RateLimit",
17
+ ]
@@ -0,0 +1,44 @@
1
+ import functools
2
+ from flask import make_response, jsonify
3
+ from typing import Union, Callable, List, Optional
4
+ from pycurb.core import RateLimiter
5
+ from pycurb.core.models import RateLimitHeaders
6
+ from .extractors import ip_extractor
7
+
8
+
9
+ def rate_limit(
10
+ limiter: RateLimiter,
11
+ rule_name: Union[str, List[str]],
12
+ key_extractor: Callable[..., str] = ip_extractor,
13
+ on_limit: Optional[Callable] = None,
14
+ ):
15
+ """
16
+ Flask view decorator for rate limiting.
17
+ Works with sync views.
18
+ """
19
+
20
+ def decorator(f):
21
+ @functools.wraps(f)
22
+ def wrapper(*args, **kwargs):
23
+ key = key_extractor()
24
+ result = limiter.check(key, rule_name)
25
+ if not result.allowed:
26
+ if on_limit:
27
+ response = on_limit(result)
28
+ if response is not None:
29
+ return response
30
+ headers = RateLimitHeaders.from_result(result)
31
+ resp = make_response(jsonify({"detail": "Rate limit exceeded"}), 429)
32
+ for name, value in headers.to_dict().items():
33
+ resp.headers[name] = value
34
+ return resp
35
+ rv = f(*args, **kwargs)
36
+ resp = make_response(rv)
37
+ headers = RateLimitHeaders.from_result(result)
38
+ for name, value in headers.to_dict().items():
39
+ resp.headers[name] = value
40
+ return resp
41
+
42
+ return wrapper
43
+
44
+ return decorator
@@ -0,0 +1,35 @@
1
+ from flask import request, session
2
+
3
+
4
+ def ip_extractor():
5
+ """Extract client IP from request, respecting X-Forwarded-For."""
6
+ forwarded = request.headers.get("X-Forwarded-For")
7
+ if forwarded:
8
+ return forwarded.split(",")[0].strip()
9
+ return request.remote_addr or "unknown"
10
+
11
+
12
+ def flask_login_user_extractor():
13
+ """Extract authenticated user ID (using flask_login)."""
14
+ try:
15
+ from flask_login import current_user
16
+
17
+ if current_user.is_authenticated:
18
+ return str(current_user.get_id())
19
+ except (ImportError, AttributeError):
20
+ pass
21
+ return "anon"
22
+
23
+
24
+ def session_user_extractor():
25
+ """Extract authenticated user ID directly from session."""
26
+ return session.get("user_id", "anon")
27
+
28
+
29
+ def api_key_extractor(header="X-API-Key"):
30
+ """Extract API key from a custom header."""
31
+ return request.headers.get(header, "")
32
+
33
+ def custom_extractor(extractor_func):
34
+ """Decorator to register custom extractors (optional)."""
35
+ return extractor_func
@@ -0,0 +1,54 @@
1
+ from flask import make_response, jsonify, request
2
+ from typing import Optional, Union, List, Callable
3
+ from pycurb.core import RateLimiter
4
+ from pycurb.core.models import RateLimitHeaders
5
+ from .extractors import ip_extractor
6
+
7
+
8
+ class RateLimit:
9
+ """
10
+ Flask extension for global rate limiting.
11
+ Usage:
12
+ app = Flask(__name__)
13
+ limiter = RateLimiterSync(...)
14
+ RateLimit(app, limiter, rule_name="global")
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ app,
20
+ limiter: RateLimiter,
21
+ rule_name: Union[str, List[str]],
22
+ key_extractor: Callable[..., str],
23
+ ):
24
+ self.app = app
25
+ self.limiter = limiter
26
+ self.rule_name = rule_name
27
+ self.key_extractor = key_extractor or ip_extractor
28
+ if app is not None:
29
+ self.init_app(app)
30
+
31
+ def init_app(self, app):
32
+ app.before_request(self.before_request)
33
+ app.after_request(self.after_request)
34
+ app.extensions["ratelimit"] = self
35
+
36
+ def before_request(self):
37
+ key = self.key_extractor()
38
+ result = self.limiter.check(key, self.rule_name)
39
+ if not result.allowed:
40
+ headers = RateLimitHeaders.from_result(result)
41
+ resp = make_response(jsonify({"detail": "Rate limit exceeded"}), 429)
42
+ for name, value in headers.to_dict().items():
43
+ resp.headers[name] = value
44
+ return resp
45
+ # Store result in request context for after_request
46
+ request.environ["_rate_limit_result"] = result
47
+
48
+ def after_request(self, response):
49
+ result = request.environ.get("_rate_limit_result", None)
50
+ if result:
51
+ headers = RateLimitHeaders.from_result(result)
52
+ for name, value in headers.to_dict().items():
53
+ response.headers[name] = value
54
+ return response
@@ -0,0 +1,24 @@
1
+ from .models import LimitRule, RateLimitResult, RateLimitHeaders, RateLimitExceeded
2
+ from .limiter_async import AsyncRateLimiter
3
+ from .limiter import RateLimiter
4
+ from .resolver import RuleResolver, AsyncRuleResolver
5
+ from .decorators import rate_limit, arg_extractor
6
+ from .storage import MemoryStorage, AsyncMemoryStorage, RedisStorage, AsyncRedisStorage
7
+
8
+
9
+ __all__ = [
10
+ "LimitRule",
11
+ "RateLimitResult",
12
+ "RateLimitHeaders",
13
+ "RateLimiter",
14
+ "AsyncRateLimiter",
15
+ "RuleResolver",
16
+ "AsyncRuleResolver",
17
+ "MemoryStorage",
18
+ "AsyncMemoryStorage",
19
+ "RedisStorage",
20
+ "AsyncRedisStorage",
21
+ "rate_limit",
22
+ "RateLimitExceeded",
23
+ "arg_extractor",
24
+ ]
@@ -0,0 +1,12 @@
1
+ from .fixed_window_async import AsyncFixedWindowAlgorithm
2
+ from .fixed_window import FixedWindowAlgorithm
3
+ from .sliding_window_async import AsyncSlidingWindowAlgorithm
4
+ from .sliding_window import SlidingWindowAlgorithm
5
+ from .token_bucket_async import AsyncTokenBucketAlgorithm
6
+ from .token_bucket import TokenBucketAlgorithm
7
+ from .leaky_bucket_async import AsyncLeakyBucketAlgorithm
8
+ from .leaky_bucket import LeakyBucketAlgorithm
9
+ from .gcra import GcraAlgorithm
10
+ from .gcra_async import AsyncGcraAlgorithm
11
+ from .base_async import AsyncRateLimiterAlgorithm
12
+ from .base import RateLimiterAlgorithm
@@ -0,0 +1,21 @@
1
+ from abc import ABC, abstractmethod
2
+ from ..models import LimitRule, RateLimitResult
3
+ from ..storage import Storage
4
+
5
+
6
+ class RateLimiterAlgorithm(ABC):
7
+ """Abstract base for all rate limiting algorithms."""
8
+
9
+ @abstractmethod
10
+ def check(self, key: str, rule: LimitRule, storage: Storage) -> RateLimitResult:
11
+ """
12
+ Evaluate rate limit for a given key and rule.
13
+ Args:
14
+ key: Unique client identifier (extracted by adapter) prefixed with rule name
15
+ rule: The limit rule applied
16
+ storage: Storage backend for atomic counters
17
+
18
+ Returns:
19
+ RateLimitResult: Decision and metadata.
20
+ """
21
+ pass
@@ -0,0 +1,23 @@
1
+ from abc import ABC, abstractmethod
2
+ from ..models import LimitRule, RateLimitResult
3
+ from ..storage import AsyncStorage
4
+
5
+
6
+ class AsyncRateLimiterAlgorithm(ABC):
7
+ """Async abstract base for all rate limiting algorithms."""
8
+
9
+ @abstractmethod
10
+ async def check(
11
+ self, key: str, rule: LimitRule, storage: AsyncStorage
12
+ ) -> RateLimitResult:
13
+ """
14
+ Evaluate rate limit for a given key and rule.
15
+ Args:
16
+ key: Unique client identifier (extracted by adapter) prefixed with rule name
17
+ rule: The limit rule applied
18
+ storage: Storage backend for atomic counters
19
+
20
+ Returns:
21
+ RateLimitResult: Decision and metadata.
22
+ """
23
+ pass
@@ -0,0 +1,26 @@
1
+ import time
2
+ import math
3
+ from .base import RateLimiterAlgorithm
4
+ from ..models import LimitRule, RateLimitResult
5
+ from ..storage import Storage
6
+
7
+
8
+ class FixedWindowAlgorithm(RateLimiterAlgorithm):
9
+ def check(self, key: str, rule: LimitRule, storage: Storage) -> RateLimitResult:
10
+ if rule.limit is None or rule.window is None:
11
+ raise ValueError("Fixed window algorithm requires 'limit' and 'window'.")
12
+
13
+ now = time.time()
14
+ storage_key = f"{rule.name}:{key}"
15
+ allowed, remaining, reset_at = storage.fixed_window(
16
+ key=storage_key, window=rule.window, limit=rule.limit, now=now
17
+ )
18
+ retry_after = max(0, math.ceil(reset_at - now))
19
+ return RateLimitResult(
20
+ allowed=allowed,
21
+ remaining=remaining,
22
+ reset_at=reset_at,
23
+ limit=rule.limit,
24
+ retry_after=retry_after if not allowed else None,
25
+ rule_name=rule.name,
26
+ )
@@ -0,0 +1,28 @@
1
+ import time
2
+ import math
3
+ from .base_async import AsyncRateLimiterAlgorithm
4
+ from ..models import LimitRule, RateLimitResult
5
+ from ..storage import AsyncStorage
6
+
7
+
8
+ class AsyncFixedWindowAlgorithm(AsyncRateLimiterAlgorithm):
9
+ async def check(
10
+ self, key: str, rule: LimitRule, storage: AsyncStorage
11
+ ) -> RateLimitResult:
12
+ if rule.limit is None or rule.window is None:
13
+ raise ValueError("Fixed window algorithm requires 'limit' and 'window'.")
14
+
15
+ now = time.time()
16
+ storage_key = f"{rule.name}:{key}"
17
+ allowed, remaining, reset_at = await storage.fixed_window(
18
+ key=storage_key, window=rule.window, limit=rule.limit, now=now
19
+ )
20
+ retry_after = max(0, math.ceil(reset_at - now))
21
+ return RateLimitResult(
22
+ allowed=allowed,
23
+ remaining=remaining,
24
+ reset_at=reset_at,
25
+ limit=rule.limit,
26
+ retry_after=retry_after if not allowed else None,
27
+ rule_name=rule.name,
28
+ )
@@ -0,0 +1,38 @@
1
+ import time
2
+ import math
3
+ from .base import RateLimiterAlgorithm
4
+ from ..models import LimitRule, RateLimitResult
5
+ from ..storage import Storage
6
+
7
+
8
+ class GcraAlgorithm(RateLimiterAlgorithm):
9
+ def check(self, key: str, rule: LimitRule, storage: Storage) -> RateLimitResult:
10
+ capacity = rule.capacity if rule.capacity is not None else rule.limit
11
+ if capacity is None:
12
+ raise ValueError("Gcra algorithm requires 'capacity' or 'limit'.")
13
+
14
+ if rule.refill_rate is not None:
15
+ rate = rule.refill_rate
16
+ else:
17
+ if rule.window is None:
18
+ raise ValueError("Gcra algorithm requires rate equal to 'refill_rate'")
19
+ rate = capacity / rule.window
20
+
21
+ if rate <= 0:
22
+ raise ValueError(f"rate must be positive, got {rate}")
23
+
24
+ now = time.time()
25
+ storage_key = f"{rule.name}:{key}"
26
+ allowed, remaining, reset_at = storage.gcra(
27
+ key=storage_key, capacity=capacity, rate=rate, now=now
28
+ )
29
+ retry_after = max(0, math.ceil(reset_at - now))
30
+
31
+ return RateLimitResult(
32
+ allowed=allowed,
33
+ remaining=remaining,
34
+ reset_at=reset_at,
35
+ limit=capacity,
36
+ retry_after=retry_after if not allowed else None,
37
+ rule_name=rule.name,
38
+ )
@@ -0,0 +1,40 @@
1
+ import time
2
+ import math
3
+ from .base_async import AsyncRateLimiterAlgorithm
4
+ from ..models import LimitRule, RateLimitResult
5
+ from ..storage import AsyncStorage
6
+
7
+
8
+ class AsyncGcraAlgorithm(AsyncRateLimiterAlgorithm):
9
+ async def check(
10
+ self, key: str, rule: LimitRule, storage: AsyncStorage
11
+ ) -> RateLimitResult:
12
+ capacity = rule.capacity if rule.capacity is not None else rule.limit
13
+ if capacity is None:
14
+ raise ValueError("Gcra algorithm requires 'capacity' or 'limit'.")
15
+
16
+ if rule.refill_rate is not None:
17
+ rate = rule.refill_rate
18
+ else:
19
+ if rule.window is None:
20
+ raise ValueError("Gcra algorithm requires rate equal to 'refill_rate'")
21
+ rate = capacity / rule.window
22
+
23
+ if rate <= 0:
24
+ raise ValueError(f"rate must be positive, got {rate}")
25
+
26
+ now = time.time()
27
+ storage_key = f"{rule.name}:{key}"
28
+ allowed, remaining, reset_at = await storage.gcra(
29
+ key=storage_key, capacity=capacity, rate=rate, now=now
30
+ )
31
+ retry_after = max(0, math.ceil(reset_at - now))
32
+
33
+ return RateLimitResult(
34
+ allowed=allowed,
35
+ remaining=remaining,
36
+ reset_at=reset_at,
37
+ limit=capacity,
38
+ retry_after=retry_after if not allowed else None,
39
+ rule_name=rule.name,
40
+ )