Skip to content

Commit 53ce4c9

Browse files
committed
Added Sliding window counter rate limiter class
0 parents  commit 53ce4c9

File tree

4 files changed

+458
-0
lines changed

4 files changed

+458
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
env/
2+
extra/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) [2020] [Muhammad Faizan Fareed]
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

ratelimiter.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import datetime
2+
import time
3+
4+
# For logs or debugging. Uncomment when required and inherit.
5+
# from utility import Utility
6+
7+
8+
class SlidingWindowCounterRateLimiter:
9+
10+
'''Sliding Window Counter Rate Limiter.'''
11+
12+
def __init__(self, clientid, redispipeline, rate=10, time_window_unit='minute', is_ratelimit_reset_header_allowed=True, is_2nd_RTT_allowed=False, max_no_time_window_for_deletion=5, is_log_enabled=True, per_request_increement=1):
13+
'''Initilize all properties of rate limiter.'''
14+
15+
# redis pipeline
16+
self.pipeline = redispipeline
17+
18+
# Increement counter by 1. Set if needed
19+
self.per_request_increement = per_request_increement
20+
21+
self.is_ratelimit_reset_header_allowed = is_ratelimit_reset_header_allowed
22+
23+
# client id
24+
self.clientid = clientid
25+
26+
# rate limit
27+
self.rate = rate
28+
29+
# Time Window ('per')
30+
self.time_window = 1
31+
32+
# Unit of Time Window
33+
self.time_window_unit = time_window_unit
34+
35+
# Total requert served
36+
self.total_request_served = 0
37+
38+
'''
39+
By default disabled.
40+
Logs usage : For specific user and used for debuging.
41+
Uncomment when required.
42+
43+
'''
44+
# self.is_log_enabled = is_log_enabled
45+
46+
self.request_recieved_timestamp_in_TW_format = None
47+
self.request_recieved_at = None
48+
49+
# For performance purposes.
50+
self.is_2nd_RTT_allowed = is_2nd_RTT_allowed
51+
52+
# Reduce memeory footprint when old/expired fixed time window reached at some number.
53+
self.max_no_time_window_for_deletion = max_no_time_window_for_deletion
54+
55+
# Set dynamically rate limit time window
56+
self.windowTimeKwargs = {self.time_window_unit + 's': self.time_window}
57+
58+
# Set time window time format
59+
self.__setFixedTimeWindowFormat()
60+
61+
def __setFixedTimeWindowFormat(self):
62+
'''Set Fixed Time Window time in different format as a string.
63+
64+
Set Fixed Time Window format for insertion as a string.
65+
Set Fixed Time Window format for ratelimit reset a as string.
66+
Set expiration time for user keys.
67+
'''
68+
69+
self.fixed_time_window_format_for_insertion = ''
70+
71+
if self.time_window_unit == 'second':
72+
73+
self.expiration_time_of_client_keys = self.time_window + self.time_window
74+
75+
self.fixed_time_window_format_for_insertion = "%Y:%m:%d:%H:%M:%S"
76+
self.fixed_time_window_format_for_ratelimit_reset = "%Y-%m-%d-%H-%M-%S"
77+
78+
elif self.time_window_unit == 'minute':
79+
80+
self.expiration_time_of_client_keys = self.time_window * \
81+
60 + (self.time_window)
82+
83+
self.fixed_time_window_format_for_insertion = "%Y:%m:%d:%H:%M:%S"
84+
self.fixed_time_window_format_for_ratelimit_reset = "%Y-%m-%d-%H-%M"
85+
86+
elif self.time_window_unit == 'hour':
87+
88+
self.expiration_time_of_client_keys = self.time_window * \
89+
3600 + (self.time_window)
90+
self.fixed_time_window_format_for_insertion = "%Y:%m:%d:%H:%M"
91+
self.fixed_time_window_format_for_ratelimit_reset = "%Y-%m-%d-%H"
92+
93+
def __setRequest_Recieved_Timestamp(self):
94+
'''Set request recieved time in different formats.
95+
96+
Set request recieved at datetime format.
97+
Set request recieved timestamp for rate limit reset time in rate limit unit format.
98+
Set request recieved timestamp for increement the Fixed Time Window counter in ftW insertion format.
99+
'''
100+
101+
self.request_recieved_at = datetime.datetime.now()
102+
103+
self.request_recieved_timestamp_for_ratelimit_reset = self.request_recieved_at.strftime(
104+
self.fixed_time_window_format_for_ratelimit_reset)
105+
106+
# Get current time based on window time interval format.
107+
currentTime = self.request_recieved_at.strftime(
108+
self.fixed_time_window_format_for_insertion)
109+
self.request_recieved_timestamp_in_TW_format = int(datetime.datetime.timestamp(datetime.datetime.strptime(
110+
currentTime, self.fixed_time_window_format_for_insertion))) # Converting into unix timestamp
111+
112+
def __setSlidingTimeWindowTimestamp(self):
113+
'''Set sliding time window time in timestamp.
114+
Subtracting rate limit time window from request recived time, foramt in FTW_format_for_insertion format and then into timestamp.
115+
'''
116+
117+
self.slidingTimeWindow = self.request_recieved_at - \
118+
datetime.timedelta(**self.windowTimeKwargs)
119+
self.slidingTimeWindow = datetime.datetime.strftime(
120+
self.slidingTimeWindow, self.fixed_time_window_format_for_insertion)
121+
self.slidingTimeWindow = datetime.datetime.strptime(
122+
self.slidingTimeWindow, self.fixed_time_window_format_for_insertion)
123+
self.slidingTimeWindow = int(
124+
datetime.datetime.timestamp(self.slidingTimeWindow))
125+
126+
def isRequestAllowed(self):
127+
'''Returns True if request allowed otherwise False.'''
128+
129+
# Set current timestamp in time window format for increement counter
130+
self.__setRequest_Recieved_Timestamp()
131+
132+
# Increement counter into current time window
133+
self.pipeline.execute_command(
134+
'HINCRBY', self.clientid, self.request_recieved_timestamp_in_TW_format, self.per_request_increement)
135+
136+
# Get all windows
137+
self.pipeline.execute_command('HGETALL', self.clientid)
138+
139+
# Set expiration time
140+
self.pipeline.execute_command(
141+
'EXPIRE', self.clientid, self.expiration_time_of_client_keys)
142+
143+
if self.is_ratelimit_reset_header_allowed:
144+
145+
# Set each fixed time window timestamp when created first time.
146+
self.pipeline.execute_command('SET', self.clientid+'-'+str(self.request_recieved_timestamp_for_ratelimit_reset),
147+
self.__get_FTW_created_at_deltatime(), 'ex', self.expiration_time_of_client_keys, 'nx')
148+
# Get back fixed time window time
149+
self.pipeline.execute_command(
150+
'GET', self.clientid+'-'+str(self.request_recieved_timestamp_for_ratelimit_reset))
151+
152+
'''
153+
By default disabled.
154+
If log enabled then insert each request into log.
155+
Logs usage : For specific user and used for debuging.
156+
Uncomment when required.
157+
158+
'''
159+
# self.insertLogrequest()
160+
161+
# Execute Redus pipeline
162+
result = self.pipeline.execute()
163+
164+
# Get all time windows from result list
165+
time_window_list = result[1]
166+
167+
# Set sliding time window
168+
self.__setSlidingTimeWindowTimestamp()
169+
170+
# Trim and count requests served in rate limit time window
171+
self.__trim_old_and_count_requests_in_time_window(time_window_list)
172+
173+
if self.is_ratelimit_reset_header_allowed:
174+
175+
current_fixed_time_window_created_at = result[4]
176+
self.__set_ratelimit_reset_time(
177+
current_fixed_time_window_created_at)
178+
179+
'''
180+
By default disabled.
181+
Print each logs.
182+
Logs for specific user and used for debuging.
183+
Uncomment when required.
184+
185+
'''
186+
# self.print_logs(result)
187+
188+
# Delete expired FTW
189+
if self.__is_expired_time_windows_exists and (self.max_no_time_window_for_deletion > self.total_expired_time_windows or self.is_2nd_RTT_allowed):
190+
self.pipeline.execute()
191+
192+
# If request in window greater then rate then return False otherwise True
193+
if self.total_request_served > self.rate:
194+
195+
self.is_request_allowed = False
196+
return False
197+
else:
198+
self.is_request_allowed = True
199+
return True
200+
201+
def __get_FTW_created_at_deltatime(self):
202+
'''Returns FTW created at time with addition of rate limit time window for rate limit reset header.
203+
Instead of adding rate limit time window on each request i stored when FTW created first time.'''
204+
205+
resetTime = self.request_recieved_at + \
206+
datetime.timedelta(**self.windowTimeKwargs)
207+
return datetime.datetime.timestamp(resetTime)
208+
209+
def __set_ratelimit_reset_time(self, current_fixed_time_window_created_at):
210+
'''Set ratelimit reset time.
211+
Fixed Time Window first time created time passed as a parameter. Subtracing the request recived time from FTW time.
212+
'''
213+
214+
self.x_ratelimit_reset = None
215+
current_fixed_time_window_created_at = current_fixed_time_window_created_at.decode()
216+
self.x_ratelimit_reset = datetime.datetime.fromtimestamp(
217+
float(current_fixed_time_window_created_at)) - self.request_recieved_at
218+
219+
def get_x_ratelimit_reset(self, in_seconds=True):
220+
'''Returns x-ratelimit-reset in seconds or in str format.'''
221+
222+
if in_seconds:
223+
return self.x_ratelimit_reset.total_seconds()
224+
else:
225+
return self.x_ratelimit_reset
226+
227+
def __trim_old_and_count_requests_in_time_window(self, time_window_list):
228+
'''Counts no of reqeuest served in rate limit time window, insert time windows into pipeline which are less then
229+
sliding time window, set True if any expired fixed time window exists and count total expired windows.
230+
'''
231+
232+
self.__is_expired_time_windows_exists = False
233+
self.total_expired_time_windows = 0
234+
235+
for window, counts in time_window_list.items():
236+
237+
if int(window.decode()) < self.slidingTimeWindow: # Check each window expired or not
238+
239+
# Insert all expired windows into pipeline
240+
self.pipeline.execute_command(
241+
'HDEL', self.clientid, int(window.decode()))
242+
243+
# Set if some windows expired for delete expired windows
244+
self.__is_expired_time_windows_exists = True
245+
246+
# Count no of expired windows
247+
self.total_expired_time_windows = self.total_expired_time_windows + 1
248+
249+
else: # if windows not expired then count all requests
250+
251+
# Counts request in window size
252+
self.total_request_served = self.total_request_served + \
253+
int(counts.decode())
254+
255+
def get_retry_after(self):
256+
''' Returns retry after header.'''
257+
258+
retry_after = self.request_recieved_at + \
259+
datetime.timedelta(**self.windowTimeKwargs)
260+
return retry_after.strftime("%Y:%m:%d:%H:%M:%S:%M")
261+
262+
def get_x_ratelimit_remaining(self):
263+
'''Returns rate limit remaining requests.
264+
Subtracting total request served from max allowed requests.
265+
'''
266+
267+
remaining_request = self.getMaxRequestsAllowed() - self.total_request_served
268+
269+
if remaining_request <= 0:
270+
return 0
271+
else:
272+
return remaining_request
273+
274+
def getMaxRequestsAllowed(self):
275+
'''Returns max no of requests allowed.'''
276+
277+
return self.rate
278+
279+
def getTotalRequestServedInSlidingWindow(self):
280+
'''Returns total request served in rate limit time window'''
281+
282+
return self.total_request_served
283+
284+
def getHttpResponseHeaders(self):
285+
'''Returns Http reseponse headers.
286+
287+
X-RateLimit-Limit.
288+
X_RateLimit_Remaining.
289+
X-RateLimit-Reset.
290+
291+
'''
292+
293+
HttpResponseHeaders = {
294+
295+
'X-RateLimit-Limit': self.getMaxRequestsAllowed(), # Max requests allowed
296+
'X_RateLimit_Remaining': self.get_x_ratelimit_remaining(), # Remaing allowed requests
297+
298+
}
299+
300+
# Reset time in seconds if this header allowed
301+
if self.is_ratelimit_reset_header_allowed:
302+
HttpResponseHeaders['X-RateLimit-Reset'] = self.get_x_ratelimit_reset(
303+
in_seconds=True)
304+
305+
# If status code 429 then Retry after header
306+
if not self.is_request_allowed:
307+
HttpResponseHeaders['Retry-after'] = self.get_retry_after()
308+
309+
return HttpResponseHeaders

0 commit comments

Comments
 (0)