comparison roundup/rest.py @ 7605:5b3ecdfd77f7

refactor(api): extract api rate limit handling; add default val Add handle_apiRateLimitExceeded to simplify dispatch() method. Add default value for pretty_print=True to format_dispatch_output.
author John Rouillard <rouilj@ieee.org>
date Wed, 16 Aug 2023 13:35:25 -0400
parents 86ad72b3a258
children b04e222501b8
comparison
equal deleted inserted replaced
7604:d117ddcb0ed1 7605:5b3ecdfd77f7
2035 return RateLimit(calls, timedelta(seconds=interval)) 2035 return RateLimit(calls, timedelta(seconds=interval))
2036 else: 2036 else:
2037 # disable rate limiting if either parameter is 0 2037 # disable rate limiting if either parameter is 0
2038 return None 2038 return None
2039 2039
2040 def handle_apiRateLimitExceeded(self, apiRateLimit):
2041 """Determine if the rate limit is exceeded.
2042
2043 If not exceeded, return False and the rate limit header values.
2044 If exceeded, return error message and None
2045 """
2046 gcra = Gcra()
2047 # unique key is an "ApiLimit-" prefix and the uid)
2048 apiLimitKey = "ApiLimit-%s" % self.db.getuid()
2049 otk = self.db.Otk
2050 try:
2051 val = otk.getall(apiLimitKey)
2052 gcra.set_tat_as_string(apiLimitKey, val['tat'])
2053 except KeyError:
2054 # ignore if tat not set, it's 1970-1-1 by default.
2055 pass
2056 # see if rate limit exceeded and we need to reject the attempt
2057 reject = gcra.update(apiLimitKey, apiRateLimit)
2058
2059 # Calculate a timestamp that will make OTK expire the
2060 # unused entry 1 hour in the future
2061 ts = otk.lifetime(3600)
2062 otk.set(apiLimitKey,
2063 tat=gcra.get_tat_as_string(apiLimitKey),
2064 __timestamp=ts)
2065 otk.commit()
2066
2067 limitStatus = gcra.status(apiLimitKey, apiRateLimit)
2068 if not reject:
2069 return (False, limitStatus)
2070
2071 for header, value in limitStatus.items():
2072 self.client.setHeader(header, value)
2073
2074 # User exceeded limits: tell humans how long to wait
2075 # Headers above will do the right thing for api
2076 # aware clients.
2077 try:
2078 retry_after = limitStatus['Retry-After']
2079 except KeyError:
2080 # handle race condition. If the time between
2081 # the call to grca.update and grca.status
2082 # is sufficient to reload the bucket by 1
2083 # item, Retry-After will be missing from
2084 # limitStatus. So report a 1 second delay back
2085 # to the client. We treat update as sole
2086 # source of truth for exceeded rate limits.
2087 retry_after = '1'
2088 self.client.setHeader('Retry-After', retry_after)
2089
2090 msg = _("Api rate limits exceeded. Please wait: %s seconds.") % retry_after
2091 output = self.error_obj(429, msg, source="ApiRateLimiter")
2092
2093 # expose these headers to rest clients. Otherwise they can't
2094 # respond to:
2095 # rate limiting (*RateLimit*, Retry-After)
2096 # obsolete API endpoint (Sunset)
2097 # options request to discover supported methods (Allow)
2098 self.client.setHeader(
2099 "Access-Control-Expose-Headers",
2100 ", ".join([
2101 "X-RateLimit-Limit",
2102 "X-RateLimit-Remaining",
2103 "X-RateLimit-Reset",
2104 "X-RateLimit-Limit-Period",
2105 "Retry-After",
2106 "Sunset",
2107 "Allow",
2108 ])
2109 )
2110
2111 return (self.format_dispatch_output(
2112 self.__default_accept_type,
2113 output,
2114 True # pretty print for this error case as a
2115 # human may read it
2116 ), None)
2117
2040 def dispatch(self, method, uri, input): 2118 def dispatch(self, method, uri, input):
2041 """format and process the request""" 2119 """format and process the request"""
2042 output = None 2120 output = None
2043 2121
2044 # Before we do anything has the user hit the rate limit. 2122 # Before we do anything has the user hit the rate limit.
2046 # Get the limit here and not in the init() routine to allow 2124 # Get the limit here and not in the init() routine to allow
2047 # for a different rate limit per user. 2125 # for a different rate limit per user.
2048 apiRateLimit = self.getRateLimit() 2126 apiRateLimit = self.getRateLimit()
2049 2127
2050 if apiRateLimit: # if None, disable rate limiting 2128 if apiRateLimit: # if None, disable rate limiting
2051 gcra = Gcra() 2129 LimitExceeded, limitStatus = self.handle_apiRateLimitExceeded(
2052 # unique key is an "ApiLimit-" prefix and the uid) 2130 apiRateLimit)
2053 apiLimitKey = "ApiLimit-%s" % self.db.getuid() 2131 if LimitExceeded:
2054 otk = self.db.Otk 2132 return LimitExceeded # error message
2055 try:
2056 val = otk.getall(apiLimitKey)
2057 gcra.set_tat_as_string(apiLimitKey, val['tat'])
2058 except KeyError:
2059 # ignore if tat not set, it's 1970-1-1 by default.
2060 pass
2061 # see if rate limit exceeded and we need to reject the attempt
2062 reject = gcra.update(apiLimitKey, apiRateLimit)
2063
2064 # Calculate a timestamp that will make OTK expire the
2065 # unused entry 1 hour in the future
2066 ts = otk.lifetime(3600)
2067 otk.set(apiLimitKey,
2068 tat=gcra.get_tat_as_string(apiLimitKey),
2069 __timestamp=ts)
2070 otk.commit()
2071
2072 limitStatus = gcra.status(apiLimitKey, apiRateLimit)
2073 if reject:
2074 for header, value in limitStatus.items():
2075 self.client.setHeader(header, value)
2076 # User exceeded limits: tell humans how long to wait
2077 # Headers above will do the right thing for api
2078 # aware clients.
2079 try:
2080 retry_after = limitStatus['Retry-After']
2081 except KeyError:
2082 # handle race condition. If the time between
2083 # the call to grca.update and grca.status
2084 # is sufficient to reload the bucket by 1
2085 # item, Retry-After will be missing from
2086 # limitStatus. So report a 1 second delay back
2087 # to the client. We treat update as sole
2088 # source of truth for exceeded rate limits.
2089 retry_after = '1'
2090 self.client.setHeader('Retry-After', retry_after)
2091
2092 msg = _("Api rate limits exceeded. Please wait: %s seconds.") % retry_after
2093 output = self.error_obj(429, msg, source="ApiRateLimiter")
2094
2095 # expose these headers to rest clients. Otherwise they can't
2096 # respond to:
2097 # rate limiting (*RateLimit*, Retry-After)
2098 # obsolete API endpoint (Sunset)
2099 # options request to discover supported methods (Allow)
2100 self.client.setHeader(
2101 "Access-Control-Expose-Headers",
2102 ", ".join([
2103 "X-RateLimit-Limit",
2104 "X-RateLimit-Remaining",
2105 "X-RateLimit-Reset",
2106 "X-RateLimit-Limit-Period",
2107 "Retry-After",
2108 "Sunset",
2109 "Allow",
2110 ])
2111 )
2112
2113 return self.format_dispatch_output(
2114 self.__default_accept_type,
2115 output,
2116 True # pretty print for this error case as a
2117 # human may read it
2118 )
2119
2120 2133
2121 for header, value in limitStatus.items(): 2134 for header, value in limitStatus.items():
2122 # Retry-After will be 0 because 2135 # Retry-After will be 0 because
2123 # user still has quota available. 2136 # user still has quota available.
2124 # Don't put out the header. 2137 # Don't put out the header.
2371 output = self.error_obj(405, msg.args[0]) 2384 output = self.error_obj(405, msg.args[0])
2372 self.client.setHeader("Allow", msg.args[1]) 2385 self.client.setHeader("Allow", msg.args[1])
2373 2386
2374 return self.format_dispatch_output(data_type, output, pretty_output) 2387 return self.format_dispatch_output(data_type, output, pretty_output)
2375 2388
2376 def format_dispatch_output(self, accept_mime_type, output, pretty_print): 2389 def format_dispatch_output(self, accept_mime_type, output,
2390 pretty_print=True):
2377 # Format the content type 2391 # Format the content type
2378 if accept_mime_type.lower() == "json": 2392 if accept_mime_type.lower() == "json":
2379 self.client.setHeader("Content-Type", "application/json") 2393 self.client.setHeader("Content-Type", "application/json")
2380 if pretty_print: 2394 if pretty_print:
2381 indent = 4 2395 indent = 4

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