Skip to content

Commit 98c80ca

Browse files
committed
Rest rate limiting code first commit. It is a bit rough and turned off
by default. The current code is lossy. If client connections are fast enough, the rate limiting code doesn't count every connection. So the client can get more connections than configured if they are fast enough. 5-20% of the connections are not recorded.
1 parent ffc55a9 commit 98c80ca

File tree

4 files changed

+212
-2
lines changed

4 files changed

+212
-2
lines changed

roundup/backends/sessions_dbm.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,26 @@ def opendb(self, mode):
129129

130130
# open the database with the correct module
131131
dbm = __import__(db_type)
132-
return dbm.open(path, mode)
132+
133+
retries_left=15
134+
while True:
135+
try:
136+
handle = dbm.open(path, mode)
137+
break
138+
except OSError as e:
139+
# Primarily we want to catch and retry:
140+
# [Errno 11] Resource temporarily unavailable retry
141+
# FIXME: make this more specific
142+
if retries_left < 0:
143+
# We have used up the retries. Reraise the exception
144+
# that got us here.
145+
raise
146+
else:
147+
# delay retry a bit
148+
time.sleep(0.01)
149+
retries_left = retries_left -1
150+
continue # the while loop
151+
return handle
133152

134153
def commit(self):
135154
pass

roundup/configuration.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,20 @@ def str2value(self, value):
760760
additional REST-API parameters after the roundup web url configured in
761761
the tracker section. If this variable is set to 'no', the rest path has
762762
no special meaning and will yield an error message."""),
763+
(IntegerNumberOption, 'api_calls_per_interval', "0",
764+
"Limit API calls per api_interval_in_sec seconds to\n"
765+
"this number.\n"
766+
"Determines the burst rate and the rate that new api\n"
767+
"calls will be made available. If set to 360 and\n"
768+
"api_intervals_in_sec is set to 3600, the 361st call in\n"
769+
"10 seconds results in a 429 error to the caller. It\n"
770+
"tells them to wait 10 seconds (360/3600) before making\n"
771+
"another api request. A value of 0 turns off rate\n"
772+
"limiting in the API. Tune this as needed. See rest\n"
773+
"documentation for more info.\n"),
774+
(IntegerNumberOption, 'api_interval_in_sec', "3600",
775+
"Defines the interval in seconds over which an api client can\n"
776+
"make api_calls_per_interval api calls. Tune this as needed.\n"),
763777
(CsrfSettingOption, 'csrf_enforce_token', "yes",
764778
"""How do we deal with @csrf fields in posted forms.
765779
Set this to 'required' to block the post and notify

roundup/rest.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@
3333
from roundup import hyperdb
3434
from roundup import date
3535
from roundup import actions
36+
from roundup.i18n import _
3637
from roundup.anypy.strings import bs2b, b2s, u2s, is_us
38+
from roundup.rate_limit import RateLimit, Gcra
3739
from roundup.exceptions import *
3840
from roundup.cgi.exceptions import *
3941

4042
import hmac
43+
from datetime import timedelta
4144

4245
# Py3 compatible basestring
4346
try:
@@ -1635,14 +1638,80 @@ def summary(self, input):
16351638

16361639
return 200, result
16371640

1641+
def getRateLimit(self):
1642+
''' By default set one rate limit for all users. Values
1643+
for period (in seconds) and count set in config.
1644+
However there is no reason these settings couldn't
1645+
be pulled from the user's entry in the database. So define
1646+
this method to allow a user to change it in the interfaces.py
1647+
to use a field in the user object.
1648+
'''
1649+
# FIXME verify can override from interfaces.py.
1650+
calls = self.db.config.WEB_API_CALLS_PER_INTERVAL
1651+
interval = self.db.config.WEB_API_INTERVAL_IN_SEC
1652+
if calls and interval:
1653+
return RateLimit(calls,timedelta(seconds=interval))
1654+
else:
1655+
# disable rate limiting if either parameter is 0
1656+
return None
1657+
16381658
def dispatch(self, method, uri, input):
16391659
"""format and process the request"""
1660+
output = None
1661+
1662+
# Before we do anything has the user hit the rate limit.
1663+
# This should (but doesn't at the moment) bypass
1664+
# all other processing to minimize load of badly
1665+
# behaving client.
1666+
1667+
# Get the limit here and not in the init() routine to allow
1668+
# for a different rate limit per user.
1669+
apiRateLimit = self.getRateLimit()
1670+
1671+
if apiRateLimit: # if None, disable rate limiting
1672+
gcra=Gcra()
1673+
# unique key is an "ApiLimit-" prefix and the uid)
1674+
apiLimitKey = "ApiLimit-%s"%self.db.getuid()
1675+
otk=self.db.Otk
1676+
try:
1677+
val=otk.getall(apiLimitKey)
1678+
gcra.set_tat_as_string(apiLimitKey, val['tat'])
1679+
except KeyError:
1680+
# ignore if tat not set, it's 1970-1-1 by default.
1681+
pass
1682+
# see if rate limit exceeded and we need to reject the attempt
1683+
reject=gcra.update(apiLimitKey, apiRateLimit)
1684+
1685+
# Calculate a timestamp that will make OTK expire the
1686+
# unused entry 1 hour in the future
1687+
ts = time.time() - (60 * 60 * 24 * 7) + 3600
1688+
otk.set(apiLimitKey, tat=gcra.get_tat_as_string(apiLimitKey),
1689+
__timestamp=ts)
1690+
otk.commit()
1691+
1692+
limitStatus=gcra.status(apiLimitKey, apiRateLimit)
1693+
if reject:
1694+
for header, value in limitStatus.items():
1695+
self.client.setHeader(header, value)
1696+
# User exceeded limits: tell humans how long to wait
1697+
# Headers above will do the right thing for api
1698+
# aware clients.
1699+
msg=_("Api rate limits exceeded. Please wait: %d seconds.")%limitStatus['Retry-After']
1700+
output = self.error_obj(429, msg, source="ApiRateLimiter")
1701+
else:
1702+
for header,value in limitStatus.items():
1703+
# Retry-After will be 0 because
1704+
# user still has quota available.
1705+
# Don't put out the header.
1706+
if header in ( 'Retry-After', ):
1707+
continue
1708+
self.client.setHeader(header, value)
1709+
16401710
# if X-HTTP-Method-Override is set, follow the override method
16411711
headers = self.client.request.headers
16421712
# Never allow GET to be an unsafe operation (i.e. data changing).
16431713
# User must use POST to "tunnel" DELETE, PUT, OPTIONS etc.
16441714
override = headers.get('X-HTTP-Method-Override')
1645-
output = None
16461715
if override:
16471716
if method.upper() == 'POST':
16481717
logger.debug(

test/rest_common.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import shutil
44
import errno
55

6+
from time import sleep
7+
from datetime import datetime
8+
69
from roundup.cgi.exceptions import *
710
from roundup.hyperdb import HyperdbValueError
811
from roundup.exceptions import *
@@ -621,6 +624,111 @@ def testPagination(self):
621624
# page_size < 0
622625
# page_index < 0
623626

627+
def testRestRateLimit(self):
628+
629+
self.db.config['WEB_API_CALLS_PER_INTERVAL'] = 20
630+
self.db.config['WEB_API_INTERVAL_IN_SEC'] = 60
631+
632+
print("Now realtime start:", datetime.utcnow())
633+
# don't set an accept header; json should be the default
634+
# use up all our allowed api calls
635+
for i in range(20):
636+
# i is 0 ... 19
637+
self.client_error_message = []
638+
results = self.server.dispatch('GET',
639+
"/rest/data/user/%s/realname"%self.joeid,
640+
self.empty_form)
641+
642+
# is successful
643+
self.assertEqual(self.server.client.response_code, 200)
644+
# does not have Retry-After header as we have
645+
# suceeded with this query
646+
self.assertFalse("Retry-After" in
647+
self.server.client.additional_headers)
648+
# remaining count is correct
649+
self.assertEqual(
650+
self.server.client.additional_headers["X-RateLimit-Remaining"],
651+
self.db.config['WEB_API_CALLS_PER_INTERVAL'] -1 - i
652+
)
653+
654+
# trip limit
655+
self.server.client.additional_headers.clear()
656+
results = self.server.dispatch('GET',
657+
"/rest/data/user/%s/realname"%self.joeid,
658+
self.empty_form)
659+
print(results)
660+
self.assertEqual(self.server.client.response_code, 429)
661+
662+
self.assertEqual(
663+
self.server.client.additional_headers["X-RateLimit-Limit"],
664+
self.db.config['WEB_API_CALLS_PER_INTERVAL'])
665+
self.assertEqual(
666+
self.server.client.additional_headers["X-RateLimit-Limit-Period"],
667+
self.db.config['WEB_API_INTERVAL_IN_SEC'])
668+
self.assertEqual(
669+
self.server.client.additional_headers["X-RateLimit-Remaining"],
670+
0)
671+
# value will be almost 60. Allow 1-2 seconds for all 20 rounds.
672+
self.assertAlmostEqual(
673+
self.server.client.additional_headers["X-RateLimit-Reset"],
674+
59, delta=1)
675+
self.assertEqual(
676+
str(self.server.client.additional_headers["Retry-After"]),
677+
"3.0") # check as string
678+
679+
print("Reset:", self.server.client.additional_headers["X-RateLimit-Reset"])
680+
print("Now realtime pre-sleep:", datetime.utcnow())
681+
sleep(3.1) # sleep as requested so we can do another login
682+
print("Now realtime post-sleep:", datetime.utcnow())
683+
684+
# this should succeed
685+
self.server.client.additional_headers.clear()
686+
results = self.server.dispatch('GET',
687+
"/rest/data/user/%s/realname"%self.joeid,
688+
self.empty_form)
689+
print(results)
690+
print("Reset:", self.server.client.additional_headers["X-RateLimit-Reset-date"])
691+
print("Now realtime:", datetime.utcnow())
692+
print("Now ts header:", self.server.client.additional_headers["Now"])
693+
print("Now date header:", self.server.client.additional_headers["Now-date"])
694+
695+
self.assertEqual(self.server.client.response_code, 200)
696+
697+
self.assertEqual(
698+
self.server.client.additional_headers["X-RateLimit-Limit"],
699+
self.db.config['WEB_API_CALLS_PER_INTERVAL'])
700+
self.assertEqual(
701+
self.server.client.additional_headers["X-RateLimit-Limit-Period"],
702+
self.db.config['WEB_API_INTERVAL_IN_SEC'])
703+
self.assertEqual(
704+
self.server.client.additional_headers["X-RateLimit-Remaining"],
705+
0)
706+
self.assertFalse("Retry-After" in
707+
self.server.client.additional_headers)
708+
# we still need to wait a minute for everything to clear
709+
self.assertAlmostEqual(
710+
self.server.client.additional_headers["X-RateLimit-Reset"],
711+
59, delta=1)
712+
713+
# and make sure we need to wait another three seconds
714+
# as we consumed the last api call
715+
results = self.server.dispatch('GET',
716+
"/rest/data/user/%s/realname"%self.joeid,
717+
self.empty_form)
718+
719+
self.assertEqual(self.server.client.response_code, 429)
720+
self.assertEqual(
721+
str(self.server.client.additional_headers["Retry-After"]),
722+
"3.0") # check as string
723+
724+
json_dict = json.loads(b2s(results))
725+
self.assertEqual(json_dict['error']['msg'],
726+
"Api rate limits exceeded. Please wait: 3 seconds.")
727+
728+
# reset rest params
729+
self.db.config['WEB_API_CALLS_PER_INTERVAL'] = 0
730+
self.db.config['WEB_API_INTERVAL_IN_SEC'] = 3600
731+
624732
def testEtagGeneration(self):
625733
''' Make sure etag generation is stable
626734

0 commit comments

Comments
 (0)