Mercurial > p > roundup > code
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
