diff roundup/rest.py @ 7692:8fb42f41ef10 issue2550923_computed_property

merge in default branch to see if ti clears a travis-ci build error on 2.7 python; default branch builds fine
author John Rouillard <rouilj@ieee.org>
date Mon, 11 Sep 2023 00:10:29 -0400
parents 5b3ecdfd77f7
children b04e222501b8
line wrap: on
line diff
--- a/roundup/rest.py	Mon Jul 10 17:31:34 2023 -0400
+++ b/roundup/rest.py	Mon Sep 11 00:10:29 2023 -0400
@@ -567,7 +567,7 @@
                             'Multilink Traversal not allowed: %s' % p)
                     # Now we have the classname in cn and the prop name in pn.
                     if not self.db.security.hasPermission('View', uid, cn, pn):
-                        raise(Unauthorised
+                        raise (Unauthorised
                             ('User does not have permission on "%s.%s"'
                             % (cn, pn)))
                     try:
@@ -2037,70 +2037,107 @@
             # disable rate limiting if either parameter is 0
             return None
 
+    def handle_apiRateLimitExceeded(self, apiRateLimit):
+        """Determine if the rate limit is exceeded.
+
+           If not exceeded, return False and the rate limit header values.
+           If exceeded, return error message and None
+        """
+        gcra = Gcra()
+        # unique key is an "ApiLimit-" prefix and the uid)
+        apiLimitKey = "ApiLimit-%s" % self.db.getuid()
+        otk = self.db.Otk
+        try:
+            val = otk.getall(apiLimitKey)
+            gcra.set_tat_as_string(apiLimitKey, val['tat'])
+        except KeyError:
+            # ignore if tat not set, it's 1970-1-1 by default.
+            pass
+        # see if rate limit exceeded and we need to reject the attempt
+        reject = gcra.update(apiLimitKey, apiRateLimit)
+
+        # Calculate a timestamp that will make OTK expire the
+        # unused entry 1 hour in the future
+        ts = otk.lifetime(3600)
+        otk.set(apiLimitKey,
+                tat=gcra.get_tat_as_string(apiLimitKey),
+                __timestamp=ts)
+        otk.commit()
+
+        limitStatus = gcra.status(apiLimitKey, apiRateLimit)
+        if not reject:
+            return (False, limitStatus)
+
+        for header, value in limitStatus.items():
+            self.client.setHeader(header, value)
+
+        # User exceeded limits: tell humans how long to wait
+        # Headers above will do the right thing for api
+        # aware clients.
+        try:
+            retry_after = limitStatus['Retry-After']
+        except KeyError:
+            # handle race condition. If the time between
+            # the call to grca.update and grca.status
+            # is sufficient to reload the bucket by 1
+            # item, Retry-After will be missing from
+            # limitStatus. So report a 1 second delay back
+            # to the client. We treat update as sole
+            # source of truth for exceeded rate limits.
+            retry_after = '1'
+            self.client.setHeader('Retry-After', retry_after)
+
+        msg = _("Api rate limits exceeded. Please wait: %s seconds.") % retry_after
+        output = self.error_obj(429, msg, source="ApiRateLimiter")
+
+        # expose these headers to rest clients. Otherwise they can't
+        # respond to:
+        #   rate limiting (*RateLimit*, Retry-After)
+        #   obsolete API endpoint (Sunset)
+        #   options request to discover supported methods (Allow)
+        self.client.setHeader(
+            "Access-Control-Expose-Headers",
+            ", ".join([
+                "X-RateLimit-Limit",
+                "X-RateLimit-Remaining",
+                "X-RateLimit-Reset",
+                "X-RateLimit-Limit-Period",
+                "Retry-After",
+                "Sunset",
+                "Allow",
+            ])
+        )
+
+        return (self.format_dispatch_output(
+            self.__default_accept_type,
+            output,
+            True  # pretty print for this error case as a
+            # human may read it
+        ), None)
+
     def dispatch(self, method, uri, input):
         """format and process the request"""
         output = None
 
         # Before we do anything has the user hit the rate limit.
-        # This should (but doesn't at the moment) bypass
-        # all other processing to minimize load of badly
-        # behaving client.
 
         # Get the limit here and not in the init() routine to allow
         # for a different rate limit per user.
         apiRateLimit = self.getRateLimit()
 
         if apiRateLimit:  # if None, disable rate limiting
-            gcra = Gcra()
-            # unique key is an "ApiLimit-" prefix and the uid)
-            apiLimitKey = "ApiLimit-%s" % self.db.getuid()
-            otk = self.db.Otk
-            try:
-                val = otk.getall(apiLimitKey)
-                gcra.set_tat_as_string(apiLimitKey, val['tat'])
-            except KeyError:
-                # ignore if tat not set, it's 1970-1-1 by default.
-                pass
-            # see if rate limit exceeded and we need to reject the attempt
-            reject = gcra.update(apiLimitKey, apiRateLimit)
-
-            # Calculate a timestamp that will make OTK expire the
-            # unused entry 1 hour in the future
-            ts = otk.lifetime(3600)
-            otk.set(apiLimitKey,
-                    tat=gcra.get_tat_as_string(apiLimitKey),
-                    __timestamp=ts)
-            otk.commit()
+            LimitExceeded, limitStatus = self.handle_apiRateLimitExceeded(
+                apiRateLimit)
+            if LimitExceeded:
+                return LimitExceeded  # error message
 
-            limitStatus = gcra.status(apiLimitKey, apiRateLimit)
-            if reject:
-                for header, value in limitStatus.items():
-                    self.client.setHeader(header, value)
-                    # User exceeded limits: tell humans how long to wait
-                    # Headers above will do the right thing for api
-                    # aware clients.
-                    try:
-                        retry_after = limitStatus['Retry-After']
-                    except KeyError:
-                        # handle race condition. If the time between
-                        # the call to grca.update and grca.status
-                        # is sufficient to reload the bucket by 1
-                        # item, Retry-After will be missing from
-                        # limitStatus. So report a 1 second delay back
-                        # to the client. We treat update as sole
-                        # source of truth for exceeded rate limits.
-                        retry_after = 1
-
-                    msg = _("Api rate limits exceeded. Please wait: %s seconds.") % retry_after
-                    output = self.error_obj(429, msg, source="ApiRateLimiter")
-            else:
-                for header, value in limitStatus.items():
-                    # Retry-After will be 0 because
-                    # user still has quota available.
-                    # Don't put out the header.
-                    if header in ('Retry-After',):
-                        continue
-                    self.client.setHeader(header, value)
+            for header, value in limitStatus.items():
+                # Retry-After will be 0 because
+                # user still has quota available.
+                # Don't put out the header.
+                if header in ('Retry-After',):
+                    continue
+                self.client.setHeader(header, value)
 
         # if X-HTTP-Method-Override is set, follow the override method
         headers = self.client.request.headers
@@ -2189,7 +2226,6 @@
         # accept_type wil be empty only if there is an Accept header
         # with invalid values.
         data_type = ext_type or accept_type or headers.get('Accept') or "invalid"
-
         if method.upper() == 'OPTIONS':
             # add access-control-allow-* access-control-max-age to support
             # CORS preflight
@@ -2348,15 +2384,19 @@
             output = self.error_obj(405, msg.args[0])
             self.client.setHeader("Allow", msg.args[1])
 
+        return self.format_dispatch_output(data_type, output, pretty_output)
+
+    def format_dispatch_output(self, accept_mime_type, output,
+                               pretty_print=True):
         # Format the content type
-        if data_type.lower() == "json":
+        if accept_mime_type.lower() == "json":
             self.client.setHeader("Content-Type", "application/json")
-            if pretty_output:
+            if pretty_print:
                 indent = 4
             else:
                 indent = None
             output = RoundupJSONEncoder(indent=indent).encode(output)
-        elif data_type.lower() == "xml" and dicttoxml:
+        elif accept_mime_type.lower() == "xml" and dicttoxml:
             self.client.setHeader("Content-Type", "application/xml")
             if 'error' in output:
                 # capture values in error with types unsupported
@@ -2389,7 +2429,7 @@
             # display acceptable output.
             self.client.response_code = 406
             output = ("Requested content type '%s' is not available.\n"
-                      "Acceptable types: %s" % (data_type,
+                      "Acceptable types: %s" % (accept_mime_type,
                       ", ".join(sorted(self.__accepted_content_type.keys()))))
 
         # Make output json end in a newline to

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