|
18 | 18 | from os import getenv |
19 | 19 | from datetime import datetime |
20 | 20 |
|
| 21 | +from retrying import retry |
21 | 22 | from rest3client import RESTclient |
22 | 23 | from requests.exceptions import HTTPError |
23 | 24 | from requests.exceptions import ChunkedEncodingError |
|
29 | 30 | HOSTNAME = 'api.github.com' |
30 | 31 | VERSION = 'v3' |
31 | 32 | DEFAULT_PAGE_SIZE = 30 |
| 33 | +DEFAULT_GRAPHQL_PAGE_SIZE = 100 |
| 34 | + |
| 35 | + |
| 36 | +class GraphqlRateLimitError(Exception): |
| 37 | + """ GraphQL Rate Limit Error |
| 38 | + """ |
| 39 | + pass |
| 40 | + |
| 41 | + |
| 42 | +class GraphqlError(Exception): |
| 43 | + """ GraphQL Error |
| 44 | + """ |
| 45 | + pass |
32 | 46 |
|
33 | 47 |
|
34 | 48 | class GitHubAPI(RESTclient): |
@@ -221,15 +235,82 @@ def retry_ratelimit_error(exception): |
221 | 235 | return False |
222 | 236 |
|
223 | 237 | @staticmethod |
224 | | - def _retry_chunkedencodingerror_error(exception): |
225 | | - """ return True if exception is ChunkedEncodingError, False otherwise |
226 | | - retry: |
227 | | - wait_fixed:10000 |
228 | | - stop_max_attempt_number:120 |
| 238 | + def clear_cursor(query, cursor): |
| 239 | + """ return query with all cursor references removed if no cursor |
| 240 | + """ |
| 241 | + if not cursor: |
| 242 | + query = query.replace('after: $cursor', '') |
| 243 | + query = query.replace('$cursor: String!', '') |
| 244 | + return query |
| 245 | + |
| 246 | + @staticmethod |
| 247 | + def sanitize_query(query): |
| 248 | + """ sanitize query |
| 249 | + """ |
| 250 | + return query.replace('\n', ' ').replace(' ', '').strip() |
| 251 | + |
| 252 | + @staticmethod |
| 253 | + def raise_if_error(response): |
| 254 | + """ raise GraphqlRateLimitError if error exists in errors |
| 255 | + """ |
| 256 | + if 'errors' in response: |
| 257 | + logger.debug(f'errors detected in graphql response: {response}') |
| 258 | + for error in response['errors']: |
| 259 | + if error.get('type', '') == 'RATE_LIMITED': |
| 260 | + raise GraphqlRateLimitError(error.get('message', '')) |
| 261 | + raise GraphqlError(response['errors'][0]['message']) |
| 262 | + |
| 263 | + @staticmethod |
| 264 | + def get_value(data, keys): |
| 265 | + """ return value represented by keys dot notated string from data dictionary |
| 266 | + """ |
| 267 | + if '.' in keys: |
| 268 | + key, rest = keys.split('.', 1) |
| 269 | + if key in data: |
| 270 | + return GitHubAPI.get_value(data[key], rest) |
| 271 | + raise KeyError(f'dictionary does not have key {key}') |
| 272 | + else: |
| 273 | + return data[keys] |
| 274 | + |
| 275 | + def _get_graphql_page(self, query, variables, keys): |
| 276 | + """ return generator that yields page from graphql response |
229 | 277 | """ |
230 | | - logger.debug(f"checking if '{type(exception).__name__}' exception is a ChunkedEncodingError error") |
231 | | - if isinstance(exception, ChunkedEncodingError): |
232 | | - logger.info('ratelimit error encountered - retrying request in 10 seconds') |
| 278 | + variables['page_size'] = DEFAULT_GRAPHQL_PAGE_SIZE |
| 279 | + variables['cursor'] = '' |
| 280 | + while True: |
| 281 | + updated_query = GitHubAPI.clear_cursor(query, variables['cursor']) |
| 282 | + response = self.post('/graphql', json={'query': updated_query, 'variables': variables}) |
| 283 | + GitHubAPI.raise_if_error(response) |
| 284 | + yield GitHubAPI.get_value(response, f'{keys}.edges') |
| 285 | + |
| 286 | + page_info = GitHubAPI.get_value(response, f'{keys}.pageInfo') |
| 287 | + has_next_page = page_info['hasNextPage'] |
| 288 | + if not has_next_page: |
| 289 | + logger.debug('no more pages') |
| 290 | + break |
| 291 | + variables['cursor'] = page_info['endCursor'] |
| 292 | + |
| 293 | + def check_graphqlratelimiterror(exception): |
| 294 | + """ return True if exception is GraphQL Rate Limit Error, False otherwise |
| 295 | + """ |
| 296 | + logger.debug(f"checking if '{type(exception).__name__}' exception is a GraphqlRateLimitError") |
| 297 | + if isinstance(exception, (GraphqlRateLimitError, TypeError)): |
| 298 | + logger.debug('exception is a GraphqlRateLimitError - retrying request in 60 seconds') |
233 | 299 | return True |
234 | | - logger.debug(f'exception is not a ratelimit error: {exception}') |
| 300 | + logger.debug(f'exception is not a GraphqlRateLimitError: {exception}') |
235 | 301 | return False |
| 302 | + |
| 303 | + @retry(retry_on_exception=check_graphqlratelimiterror, wait_fixed=60000, stop_max_attempt_number=60) |
| 304 | + def graphql(self, query, variables, page=False, keys=None): |
| 305 | + """ execute graphql query and return response or paged response if page is True |
| 306 | + """ |
| 307 | + query = GitHubAPI.sanitize_query(query) |
| 308 | + if page: |
| 309 | + response = self._get_graphql_page(query, variables, keys) |
| 310 | + else: |
| 311 | + updated_query = GitHubAPI.clear_cursor(query, variables.get('cursor')) |
| 312 | + response = self.post('/graphql', json={'query': updated_query, 'variables': variables}) |
| 313 | + GitHubAPI.raise_if_error(response) |
| 314 | + return response |
| 315 | + |
| 316 | + check_graphqlratelimiterror = staticmethod(check_graphqlratelimiterror) |
0 commit comments