comparison roundup/rest.py @ 7595:26ef5054e510

refactor(api): early return if REST rate limit is exceeded If the rate limit at the top of dispatch() is exceeded, return rather than running to final return at end of method. This does change a couple of things: 1) output format is always in json 2) json is alays pretty printed Previously, the output format requested by the Accept header or format extension in the URL was used for 1. Similarly value of @pretty was used to control 2. I am trying to reduce the complexities in this routine with the goal of fixing: issue2551289 to return 406 error if the Accept header/format extension is incorrect before executing the request.
author John Rouillard <rouilj@ieee.org>
date Thu, 03 Aug 2023 17:04:34 -0400
parents 940f06dac1b4
children e5fa31aad344
comparison
equal deleted inserted replaced
7594:c9180009a286 7595:26ef5054e510
2040 def dispatch(self, method, uri, input): 2040 def dispatch(self, method, uri, input):
2041 """format and process the request""" 2041 """format and process the request"""
2042 output = None 2042 output = None
2043 2043
2044 # Before we do anything has the user hit the rate limit. 2044 # Before we do anything has the user hit the rate limit.
2045 # This should (but doesn't at the moment) bypass
2046 # all other processing to minimize load of badly
2047 # behaving client.
2048 2045
2049 # Get the limit here and not in the init() routine to allow 2046 # Get the limit here and not in the init() routine to allow
2050 # for a different rate limit per user. 2047 # for a different rate limit per user.
2051 apiRateLimit = self.getRateLimit() 2048 apiRateLimit = self.getRateLimit()
2052 2049
2074 2071
2075 limitStatus = gcra.status(apiLimitKey, apiRateLimit) 2072 limitStatus = gcra.status(apiLimitKey, apiRateLimit)
2076 if reject: 2073 if reject:
2077 for header, value in limitStatus.items(): 2074 for header, value in limitStatus.items():
2078 self.client.setHeader(header, value) 2075 self.client.setHeader(header, value)
2079 # User exceeded limits: tell humans how long to wait 2076 # User exceeded limits: tell humans how long to wait
2080 # Headers above will do the right thing for api 2077 # Headers above will do the right thing for api
2081 # aware clients. 2078 # aware clients.
2082 try: 2079 try:
2083 retry_after = limitStatus['Retry-After'] 2080 retry_after = limitStatus['Retry-After']
2084 except KeyError: 2081 except KeyError:
2085 # handle race condition. If the time between 2082 # handle race condition. If the time between
2086 # the call to grca.update and grca.status 2083 # the call to grca.update and grca.status
2087 # is sufficient to reload the bucket by 1 2084 # is sufficient to reload the bucket by 1
2088 # item, Retry-After will be missing from 2085 # item, Retry-After will be missing from
2089 # limitStatus. So report a 1 second delay back 2086 # limitStatus. So report a 1 second delay back
2090 # to the client. We treat update as sole 2087 # to the client. We treat update as sole
2091 # source of truth for exceeded rate limits. 2088 # source of truth for exceeded rate limits.
2092 retry_after = 1 2089 retry_after = 1
2093 self.client.setHeader('Retry-After', '1') 2090 self.client.setHeader('Retry-After', '1')
2094 2091
2095 msg = _("Api rate limits exceeded. Please wait: %s seconds.") % retry_after 2092 msg = _("Api rate limits exceeded. Please wait: %s seconds.") % retry_after
2096 output = self.error_obj(429, msg, source="ApiRateLimiter") 2093 output = self.error_obj(429, msg, source="ApiRateLimiter")
2097 else: 2094
2098 for header, value in limitStatus.items(): 2095 return self.format_dispatch_output(
2099 # Retry-After will be 0 because 2096 self.__default_accept_type,
2100 # user still has quota available. 2097 output,
2101 # Don't put out the header. 2098 True # pretty print for this error case as a
2102 if header in ('Retry-After',): 2099 # human may read it
2103 continue 2100 )
2104 self.client.setHeader(header, value) 2101
2102
2103 for header, value in limitStatus.items():
2104 # Retry-After will be 0 because
2105 # user still has quota available.
2106 # Don't put out the header.
2107 if header in ('Retry-After',):
2108 continue
2109 self.client.setHeader(header, value)
2105 2110
2106 # if X-HTTP-Method-Override is set, follow the override method 2111 # if X-HTTP-Method-Override is set, follow the override method
2107 headers = self.client.request.headers 2112 headers = self.client.request.headers
2108 # Never allow GET to be an unsafe operation (i.e. data changing). 2113 # Never allow GET to be an unsafe operation (i.e. data changing).
2109 # User must use POST to "tunnel" DELETE, PUT, OPTIONS etc. 2114 # User must use POST to "tunnel" DELETE, PUT, OPTIONS etc.
2118 "X-HTTP-Method-Override: %s must be used with " 2123 "X-HTTP-Method-Override: %s must be used with "
2119 "POST method not %s." % (override, method.upper())) 2124 "POST method not %s." % (override, method.upper()))
2120 logger.info( 2125 logger.info(
2121 'Ignoring X-HTTP-Method-Override using %s request on %s', 2126 'Ignoring X-HTTP-Method-Override using %s request on %s',
2122 method.upper(), uri) 2127 method.upper(), uri)
2123
2124 # parse Accept header and get the content type
2125 # Acceptable types ordered with preferred one first
2126 # in list.
2127 try:
2128 accept_header = parse_accept_header(headers.get('Accept'))
2129 except UsageError as e:
2130 output = self.error_obj(406, _("Unable to parse Accept Header. %(error)s. "
2131 "Acceptable types: %(acceptable_types)s") % {
2132 'error': e.args[0],
2133 'acceptable_types': " ".join(sorted(self.__accepted_content_type.keys()))})
2134 accept_header = []
2135
2136 if not accept_header:
2137 accept_type = self.__default_accept_type
2138 else:
2139 accept_type = None
2140 for part in accept_header:
2141 if accept_type:
2142 # we accepted the best match, stop searching for
2143 # lower quality matches.
2144 break
2145 if part[0] in self.__accepted_content_type:
2146 accept_type = self.__accepted_content_type[part[0]]
2147 # Version order:
2148 # 1) accept header version=X specifier
2149 # application/vnd.x.y; version=1
2150 # 2) from type in accept-header type/subtype-vX
2151 # application/vnd.x.y-v1
2152 # 3) from @apiver in query string to make browser
2153 # use easy
2154 # This code handles 1 and 2. Set api_version to none
2155 # to trigger @apiver parsing below
2156 # Places that need the api_version info should
2157 # use default if version = None
2158 try:
2159 self.api_version = int(part[1]['version'])
2160 except KeyError:
2161 self.api_version = None
2162 except (ValueError, TypeError):
2163 # TypeError if int(None)
2164 msg = ("Unrecognized api version: %s. "
2165 "See /rest without specifying api version "
2166 "for supported versions." % (
2167 part[1]['version']))
2168 output = self.error_obj(400, msg)
2169
2170 # get the request format for response
2171 # priority : extension from uri (/rest/data/issue.json),
2172 # header (Accept: application/json, application/xml)
2173 # default (application/json)
2174 ext_type = os.path.splitext(urlparse(uri).path)[1][1:]
2175
2176 # Check to see if the length of the extension is less than 6.
2177 # this allows use of .vcard for a future use in downloading
2178 # user info. It also allows passing through larger items like
2179 # JWT that has a final component > 6 items. This method also
2180 # allow detection of mistyped types like jon for json.
2181 if ext_type and (len(ext_type) < 6):
2182 # strip extension so uri make sense
2183 # .../issue.json -> .../issue
2184 uri = uri[:-(len(ext_type) + 1)]
2185 else:
2186 ext_type = None
2187
2188 # headers.get('Accept') is never empty if called here.
2189 # accept_type will be set to json if there is no Accept header
2190 # accept_type wil be empty only if there is an Accept header
2191 # with invalid values.
2192 data_type = ext_type or accept_type or headers.get('Accept') or "invalid"
2193 2128
2194 if method.upper() == 'OPTIONS': 2129 if method.upper() == 'OPTIONS':
2195 # add access-control-allow-* access-control-max-age to support 2130 # add access-control-allow-* access-control-max-age to support
2196 # CORS preflight 2131 # CORS preflight
2197 self.client.setHeader( 2132 self.client.setHeader(
2347 output = self.error_obj(404, msg) 2282 output = self.error_obj(404, msg)
2348 except Reject as msg: 2283 except Reject as msg:
2349 output = self.error_obj(405, msg.args[0]) 2284 output = self.error_obj(405, msg.args[0])
2350 self.client.setHeader("Allow", msg.args[1]) 2285 self.client.setHeader("Allow", msg.args[1])
2351 2286
2287 return self.format_dispatch_output(data_type, output, pretty_output)
2288
2289 def format_dispatch_output(self, accept_mime_type, output, pretty_print):
2352 # Format the content type 2290 # Format the content type
2353 if data_type.lower() == "json": 2291 if accept_mime_type.lower() == "json":
2354 self.client.setHeader("Content-Type", "application/json") 2292 self.client.setHeader("Content-Type", "application/json")
2355 if pretty_output: 2293 if pretty_print:
2356 indent = 4 2294 indent = 4
2357 else: 2295 else:
2358 indent = None 2296 indent = None
2359 output = RoundupJSONEncoder(indent=indent).encode(output) 2297 output = RoundupJSONEncoder(indent=indent).encode(output)
2360 elif data_type.lower() == "xml" and dicttoxml: 2298 elif accept_mime_type.lower() == "xml" and dicttoxml:
2361 self.client.setHeader("Content-Type", "application/xml") 2299 self.client.setHeader("Content-Type", "application/xml")
2362 if 'error' in output: 2300 if 'error' in output:
2363 # capture values in error with types unsupported 2301 # capture values in error with types unsupported
2364 # by dicttoxml e.g. an exception, into something it 2302 # by dicttoxml e.g. an exception, into something it
2365 # can handle 2303 # can handle
2388 # FIXME?? consider moving this earlier. We should 2326 # FIXME?? consider moving this earlier. We should
2389 # error out before doing any work if we can't 2327 # error out before doing any work if we can't
2390 # display acceptable output. 2328 # display acceptable output.
2391 self.client.response_code = 406 2329 self.client.response_code = 406
2392 output = ("Requested content type '%s' is not available.\n" 2330 output = ("Requested content type '%s' is not available.\n"
2393 "Acceptable types: %s" % (data_type, 2331 "Acceptable types: %s" % (accept_mime_type,
2394 ", ".join(sorted(self.__accepted_content_type.keys())))) 2332 ", ".join(sorted(self.__accepted_content_type.keys()))))
2395 2333
2396 # Make output json end in a newline to 2334 # Make output json end in a newline to
2397 # separate from following text in logs etc.. 2335 # separate from following text in logs etc..
2398 return bs2b(output + "\n") 2336 return bs2b(output + "\n")

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