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