Skip to content

Commit e1a1731

Browse files
Merge pull request #330 from WinterFramework/redis-throttling
Use redis as a storage for throttling statistic
2 parents dba0576 + 0968f56 commit e1a1731

9 files changed

Lines changed: 178 additions & 16 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "winter"
3-
version = "30.0.1"
3+
version = "31.0.0"
44
homepage = "https://github.com/WinterFramework/winter"
55
description = "Web Framework with focus on python typing, dataclasses and modular design"
66
authors = ["Alexander Egorov <mofr@zond.org>"]
@@ -41,6 +41,7 @@ pydantic = ">=1.10, <2"
4141
openapi-spec-validator = ">=0.5.7, <1"
4242
uritemplate = "==4.2.0" # Lib doesn't follow semantic versioning
4343
httpx = ">=0.24.1, <0.28"
44+
redis = "^6.2.0"
4445

4546
[tool.poetry.dev-dependencies]
4647
flake8 = ">=3.7.7, <4"
@@ -61,6 +62,7 @@ pytz = ">=2020.5"
6162

6263
[tool.poetry.group.dev.dependencies]
6364
setuptools = "^71.1.0"
65+
testcontainers = "^4.10.0"
6466

6567
[build-system]
6668
requires = ["poetry-core>=1.3.1"]

tests/apps.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import atexit
2+
13
from django.apps import AppConfig
4+
from testcontainers.redis import RedisContainer
25

36
from tests.web.interceptors import HelloWorldInterceptor
7+
from winter.web import RedisThrottlingConfiguration
48
from winter.web import exception_handlers_registry
59
from winter.web import interceptor_registry
610
from winter.web.exceptions.handlers import DefaultExceptionHandler
@@ -9,6 +13,10 @@
913
class TestAppConfig(AppConfig):
1014
name = 'tests'
1115

16+
def __init__(self, *args, **kwargs):
17+
super().__init__(*args, **kwargs)
18+
self._redis_container: RedisContainer | None = None
19+
1220
def ready(self):
1321
# define this import for force initialization all modules and to register Exceptions
1422
from .urls import urlpatterns # noqa: F401
@@ -19,7 +27,26 @@ def ready(self):
1927
interceptor_registry.add_interceptor(HelloWorldInterceptor())
2028

2129
winter_openapi.setup()
30+
2231
winter.web.setup()
32+
33+
self._redis_container = RedisContainer()
34+
self._redis_container.start()
35+
self._redis_container.get_client().flushdb()
36+
atexit.register(self.cleanup_redis)
37+
38+
redis_throttling_configuration = RedisThrottlingConfiguration(
39+
host=self._redis_container.get_container_host_ip(),
40+
port=self._redis_container.get_exposed_port(self._redis_container.port),
41+
db=0,
42+
password=self._redis_container.password
43+
)
44+
winter.web.set_redis_throttling_configuration(redis_throttling_configuration)
45+
2346
winter_django.setup()
2447

2548
exception_handlers_registry.set_default_handler(DefaultExceptionHandler) # for 100% test coverage
49+
50+
def cleanup_redis(self): # pragma: no cover
51+
if self._redis_container:
52+
self._redis_container.stop()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33

44
import freezegun
55
import pytest
6+
from mock import patch
67

8+
from winter.web import RedisThrottlingConfiguration
9+
from winter.web import ThrottlingMisconfigurationException
10+
from winter.web import set_redis_throttling_configuration
11+
from winter.web.throttling.redis_throttling_client import get_redis_throttling_client
12+
from winter.web.throttling import redis_throttling_client
13+
from winter.web.throttling import redis_throttling_configuration
714

815
expected_error_response = {
916
'status': 429,
@@ -65,3 +72,35 @@ def test_get_throttling_with_conditional_reset(api_client):
6572
is_reset = True if i == 5 else False
6673
response = api_client.get(f'/with-throttling/with-reset/?is_reset={is_reset}')
6774
assert response.status_code == HTTPStatus.OK, i
75+
76+
77+
@patch.object(redis_throttling_client, 'get_redis_throttling_configuration', return_value=None)
78+
@patch.object(redis_throttling_client, '_redis_throttling_client', None)
79+
def test_get_redis_throttling_client_without_configuration(_):
80+
with pytest.raises(ThrottlingMisconfigurationException) as exc_info:
81+
get_redis_throttling_client()
82+
83+
assert 'Configuration for Redis must be set' in str(exc_info.value)
84+
85+
86+
@patch.object(
87+
redis_throttling_configuration,
88+
'_redis_throttling_configuration',
89+
RedisThrottlingConfiguration(
90+
host='localhost',
91+
port=1234,
92+
db=0,
93+
password=None
94+
)
95+
)
96+
def test_try_to_set_redis_configuration_twice():
97+
configuration = RedisThrottlingConfiguration(
98+
host='localhost',
99+
port=5678,
100+
db=0,
101+
password=None
102+
)
103+
with pytest.raises(ThrottlingMisconfigurationException) as exc_info:
104+
set_redis_throttling_configuration(configuration)
105+
106+
assert 'RedisThrottlingConfiguration is already initialized' in str(exc_info.value)

winter/web/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from .response_header_resolver import ResponseHeaderArgumentResolver
2121
from .response_header_serializer import response_headers_serializer
2222
from .response_status_annotation import response_status
23+
from .throttling import ThrottlingMisconfigurationException
24+
from .throttling import RedisThrottlingConfiguration
25+
from .throttling import set_redis_throttling_configuration
2326
from .throttling import throttling
2427
from .urls import register_url_regexp
2528

winter/web/throttling/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .exceptions import ThrottlingMisconfigurationException
2+
from .throttling import throttling
3+
from .throttling import reset
4+
from .throttling import create_throttle_class
5+
from .redis_throttling_configuration import set_redis_throttling_configuration
6+
from .redis_throttling_configuration import RedisThrottlingConfiguration
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class ThrottlingMisconfigurationException(Exception):
2+
pass
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import time
2+
3+
from redis import Redis
4+
5+
from .exceptions import ThrottlingMisconfigurationException
6+
from .redis_throttling_configuration import get_redis_throttling_configuration
7+
from .redis_throttling_configuration import RedisThrottlingConfiguration
8+
9+
10+
class RedisThrottlingClient:
11+
# Redis Lua scripts are atomic
12+
# Sliding window throttling.
13+
# Rejected requests aren't counted.
14+
THROTTLING_LUA = '''
15+
local key = KEYS[1]
16+
local now = tonumber(ARGV[1])
17+
local duration = tonumber(ARGV[2])
18+
local max_requests = tonumber(ARGV[3])
19+
20+
redis.call("ZREMRANGEBYSCORE", key, 0, now - duration)
21+
local count = redis.call("ZCARD", key)
22+
23+
if count >= max_requests then
24+
return 0
25+
end
26+
27+
redis.call("ZADD", key, now, now)
28+
redis.call("EXPIRE", key, duration)
29+
return 1
30+
'''
31+
32+
def __init__(self, configuration: RedisThrottlingConfiguration):
33+
self._redis_client = Redis(
34+
host=configuration.host,
35+
port=configuration.port,
36+
db=configuration.db,
37+
password=configuration.password,
38+
decode_responses=True,
39+
)
40+
self._throttling_script = self._redis_client.register_script(self.THROTTLING_LUA)
41+
42+
def is_request_allowed(self, key: str, duration: int, num_requests: int) -> bool:
43+
now = time.time()
44+
is_allowed = self._throttling_script(
45+
keys=[key],
46+
args=[now, duration, num_requests]
47+
)
48+
return is_allowed == 1
49+
50+
def delete(self, key: str):
51+
self._redis_client.delete(key)
52+
53+
54+
_redis_throttling_client: RedisThrottlingClient | None = None
55+
56+
def get_redis_throttling_client() -> RedisThrottlingClient:
57+
global _redis_throttling_client
58+
59+
if _redis_throttling_client is None:
60+
configuration = get_redis_throttling_configuration()
61+
62+
if configuration is None:
63+
raise ThrottlingMisconfigurationException('Configuration for Redis must be set before using the throttling')
64+
65+
_redis_throttling_client = RedisThrottlingClient(configuration)
66+
67+
return _redis_throttling_client
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from dataclasses import dataclass
2+
3+
from .exceptions import ThrottlingMisconfigurationException
4+
5+
6+
@dataclass
7+
class RedisThrottlingConfiguration:
8+
host: str
9+
port: int
10+
db: int
11+
password: str | None = None
12+
13+
14+
_redis_throttling_configuration: RedisThrottlingConfiguration | None = None
15+
16+
17+
def set_redis_throttling_configuration(configuration: RedisThrottlingConfiguration):
18+
global _redis_throttling_configuration
19+
if _redis_throttling_configuration is not None:
20+
raise ThrottlingMisconfigurationException(f'{RedisThrottlingConfiguration.__name__} is already initialized')
21+
_redis_throttling_configuration = configuration
22+
23+
24+
def get_redis_throttling_configuration() -> RedisThrottlingConfiguration | None:
25+
return _redis_throttling_configuration
Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
from typing import Tuple
66

77
import django.http
8-
from django.core.cache import cache as default_cache
98

109
from winter.core import annotate_method
10+
from .redis_throttling_client import get_redis_throttling_client
1111

1212
if TYPE_CHECKING:
13-
from .routing import Route # noqa: F401
13+
from winter.web.routing import Route # noqa: F401
1414

1515

1616
@dataclasses.dataclass
@@ -33,23 +33,13 @@ def throttling(rate: Optional[str], scope: Optional[str] = None):
3333
class BaseRateThrottle:
3434
def __init__(self, throttling_: Throttling):
3535
self._throttling = throttling_
36+
self._redis_client = get_redis_throttling_client()
3637

3738
def allow_request(self, request: django.http.HttpRequest) -> bool:
3839
ident = _get_ident(request)
3940
key = _get_cache_key(self._throttling.scope, ident)
4041

41-
history = default_cache.get(key, [])
42-
now = time.time()
43-
44-
while history and history[-1] <= now - self._throttling.duration:
45-
history.pop()
46-
47-
if len(history) >= self._throttling.num_requests:
48-
return False
49-
50-
history.insert(0, now)
51-
default_cache.set(key, history, self._throttling.duration)
52-
return True
42+
return self._redis_client.is_request_allowed(key, self._throttling.duration, self._throttling.num_requests)
5343

5444

5545
def reset(request: django.http.HttpRequest, scope: str):
@@ -59,7 +49,8 @@ def reset(request: django.http.HttpRequest, scope: str):
5949
"""
6050
ident = _get_ident(request)
6151
key = _get_cache_key(scope, ident)
62-
default_cache.delete(key)
52+
redis_client = get_redis_throttling_client()
53+
redis_client.delete(key)
6354

6455

6556
CACHE_KEY_FORMAT = 'throttle_{scope}_{ident}'

0 commit comments

Comments
 (0)