diff test/rest_common.py @ 5732:0e6ed3d72f92

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.
author John Rouillard <rouilj@ieee.org>
date Sat, 25 May 2019 16:50:25 -0400
parents 9ea2ce9d10cf
children 62bdcb874433
line wrap: on
line diff
--- a/test/rest_common.py	Sat May 25 14:39:43 2019 -0400
+++ b/test/rest_common.py	Sat May 25 16:50:25 2019 -0400
@@ -3,6 +3,9 @@
 import shutil
 import errno
 
+from time import sleep
+from datetime import datetime
+
 from roundup.cgi.exceptions import *
 from roundup.hyperdb import HyperdbValueError
 from roundup.exceptions import *
@@ -621,6 +624,111 @@
         #   page_size < 0
         #   page_index < 0
 
+    def testRestRateLimit(self):
+
+        self.db.config['WEB_API_CALLS_PER_INTERVAL'] = 20
+        self.db.config['WEB_API_INTERVAL_IN_SEC'] = 60
+
+        print("Now realtime start:", datetime.utcnow())
+        # don't set an accept header; json should be the default
+        # use up all our allowed api calls
+        for i in range(20):
+            # i is 0 ... 19
+            self.client_error_message = []
+            results = self.server.dispatch('GET',
+                            "/rest/data/user/%s/realname"%self.joeid,
+                            self.empty_form)
+ 
+            # is successful
+            self.assertEqual(self.server.client.response_code, 200)
+            # does not have Retry-After header as we have
+            # suceeded with this query
+            self.assertFalse("Retry-After" in
+                             self.server.client.additional_headers) 
+            # remaining count is correct
+            self.assertEqual(
+                self.server.client.additional_headers["X-RateLimit-Remaining"],
+                self.db.config['WEB_API_CALLS_PER_INTERVAL'] -1 - i
+                )
+
+        # trip limit
+        self.server.client.additional_headers.clear()
+        results = self.server.dispatch('GET',
+                     "/rest/data/user/%s/realname"%self.joeid,
+                            self.empty_form)
+        print(results)
+        self.assertEqual(self.server.client.response_code, 429)
+
+        self.assertEqual(
+            self.server.client.additional_headers["X-RateLimit-Limit"],
+            self.db.config['WEB_API_CALLS_PER_INTERVAL'])
+        self.assertEqual(
+            self.server.client.additional_headers["X-RateLimit-Limit-Period"],
+            self.db.config['WEB_API_INTERVAL_IN_SEC'])
+        self.assertEqual(
+            self.server.client.additional_headers["X-RateLimit-Remaining"],
+            0)
+        # value will be almost 60. Allow 1-2 seconds for all 20 rounds.
+        self.assertAlmostEqual(
+            self.server.client.additional_headers["X-RateLimit-Reset"],
+            59, delta=1)
+        self.assertEqual(
+            str(self.server.client.additional_headers["Retry-After"]),
+            "3.0")  # check as string
+
+        print("Reset:", self.server.client.additional_headers["X-RateLimit-Reset"])
+        print("Now realtime pre-sleep:", datetime.utcnow())
+        sleep(3.1) # sleep as requested so we can do another login
+        print("Now realtime post-sleep:", datetime.utcnow())
+
+        # this should succeed
+        self.server.client.additional_headers.clear()
+        results = self.server.dispatch('GET',
+                     "/rest/data/user/%s/realname"%self.joeid,
+                            self.empty_form)
+        print(results)
+        print("Reset:", self.server.client.additional_headers["X-RateLimit-Reset-date"])
+        print("Now realtime:", datetime.utcnow())
+        print("Now ts header:", self.server.client.additional_headers["Now"])
+        print("Now date header:", self.server.client.additional_headers["Now-date"])
+
+        self.assertEqual(self.server.client.response_code, 200)
+
+        self.assertEqual(
+            self.server.client.additional_headers["X-RateLimit-Limit"],
+            self.db.config['WEB_API_CALLS_PER_INTERVAL'])
+        self.assertEqual(
+            self.server.client.additional_headers["X-RateLimit-Limit-Period"],
+            self.db.config['WEB_API_INTERVAL_IN_SEC'])
+        self.assertEqual(
+            self.server.client.additional_headers["X-RateLimit-Remaining"],
+            0)
+        self.assertFalse("Retry-After" in
+                         self.server.client.additional_headers) 
+        # we still need to wait a minute for everything to clear
+        self.assertAlmostEqual(
+            self.server.client.additional_headers["X-RateLimit-Reset"],
+            59, delta=1)
+
+        # and make sure we need to wait another three seconds
+        # as we consumed the last api call
+        results = self.server.dispatch('GET',
+                     "/rest/data/user/%s/realname"%self.joeid,
+                            self.empty_form)
+
+        self.assertEqual(self.server.client.response_code, 429)
+        self.assertEqual(
+            str(self.server.client.additional_headers["Retry-After"]),
+            "3.0")  # check as string
+
+        json_dict = json.loads(b2s(results))
+        self.assertEqual(json_dict['error']['msg'],
+                         "Api rate limits exceeded. Please wait: 3 seconds.")
+
+        # reset rest params
+        self.db.config['WEB_API_CALLS_PER_INTERVAL'] = 0
+        self.db.config['WEB_API_INTERVAL_IN_SEC'] = 3600
+            
     def testEtagGeneration(self):
         ''' Make sure etag generation is stable
         

Roundup Issue Tracker: http://roundup-tracker.org/