"""GraphQL client.""" from gql.transport.requests import RequestsHTTPTransport from gql import Client from gql import gql from gql.transport.exceptions import TransportError from requests.exceptions import ConnectionError import backoff from connectors.logging import log from constants.backoff import ( BASE, FACTOR, MAX_TRIES, MAX_TIME, MAX_VALUE ) import config GRAPHQL_ROUTER_URL_SUFFIX = "graphql-router.theorchard.io/graphql" class GraphQLConnector: """GraphQL client.""" def __init__(self, headers: dict = None, query: str = None): """Create a connection to a GraphQL endpoint. Args: query (str): A graphql query. Default is None. """ if not headers: headers = { "apollographql-client-name": config.APPLICATION_NAME, "apollographql-client-version": config.APP_VERSION, "Content-Type": "application/json", "Orchard-Identity-Id": config.ORCH_HEADER_IDENTITY_ID, "Orchard-Profile-Type": config.ORCH_HEADER_PROFILE_TYPE, "Orchard-Profile-Id": config.ORCH_HEADER_PROFILE_ID, "Orchard-Roles": config.ORCH_HEADER_ROLE, "Orchard-User-Id": config.OA_USER, "Orchard-Profile-UUID": config.ORCH_HEADER_PROFILE_UUID } env = ( config.QA_ENVIRONMENT if config.ENVIRONMENT == config.DEV_ENVIRONMENT else config.ENVIRONMENT ) self.url = f"https://{env}-{GRAPHQL_ROUTER_URL_SUFFIX}" self.transport = RequestsHTTPTransport( url=self.url, # use_json=True, headers=headers, ) self.client = Client( transport=self.transport, # fetch_schema_from_transport=False ) if query: self.query = gql(query) def set_query(self, query: str): """Set query. Args: query (str): Query """ self.query = gql(query) def set_headers(self, headers: dict): """Set headers. Args: headers (dict): Headers """ self.transport.headers.update(headers) def execute_query(self, query: str, data: dict): """Execute a call to a GraphQL endpoint with a provided query. Args: query (str): The query to execute. If not provided, the internal query will be used. data (dict): JSON formatted params """ return self.client.execute(gql(query), variable_values=data) def execute(self, data: dict): """Execute a call to a GraphQL endpoint. Args: data (dict): JSON formatted params Returns: dict: Response """ if self.query: return self.client.execute(self.query, variable_values=data) else: raise ValueError("No query set.") class GraphQLError(Exception): """Any GraphQL errors.""" def __init__(self, errors): """Init. Args: errors (list): """ self.errors = [] # ConnectionError if isinstance(errors, ConnectionError): err = errors.args[0] reason = err.reason msg = err.args[0] url = err.url underlying_reason = err.reason underlying_message = underlying_reason.args body = self.args[0].request.body # msg = errors.args[0].get('args')[0] # body = errors.request.body # method = errors.request.method # url = errors.request.url # headers = errors.request.headers self.errors.append( { 'message': msg, 'response': reason, 'error': err, 'url': url, 'underlying_reason': underlying_reason, 'underlying_message': underlying_message, 'body': body, # 'method': method, # 'headers': headers } ) self.response = None self.stacktrace = None super().__init__(err) # TransportError elif isinstance(errors, TransportError): for error in errors.errors: msg = error.get('message') locations = error.get('locations') path = error.get('path') if locations: locations = '; '.join([ f"line: {loc.get('line')}, column: {loc.get('column')}" for loc in locations ]) if path: path = '::'.join([str(p) for p in path]) response = None code = None stacktrace = None extensions = error.get('extensions') if extensions: code = extensions.get('code') stacktrace = extensions.get('stacktrace') response = extensions.get('response') self.errors.append( { 'message': msg, 'locations': locations, 'path': path, 'loc_data': error.get('locations'), 'code': code, 'response': response, 'stacktrace': stacktrace } ) # Make sure you construct an exception message with the # superclass for nice output in Sentry / State Machine etc. msg = f"Error 1 of {len(self.errors)}: " \ f"{self.errors[0]['message']} - " \ f"{self.errors[0]['response']}" msg = f"{msg} - {self.errors[0]['code']}" \ if self.errors[0].get('code') else msg msg = f"{msg} - {self.errors[0]['locations']}" \ if self.errors[0].get('locations') else msg super().__init__((msg)) def get_response_status(self): """Return service response status code. Returns: int """ if self.response: return self.response.get('status') # If the response is not populated something went wrong return 500 def get_response_body(self): """Return service response body. Returns: dict """ if self.response: return self.response.get('body') return {} def get_stacktrace(self): """Return service stacktrace. Returns: list """ return self.stacktrace def get_response(self): """Return service response. Returns: dict """ return self.response def log_backoff(details: dict) -> None: """Log backoff details. Args: details (dict): Details """ error = details['exception'] kwargs = details['kwargs'] log.warning( f"Backing off {details['wait']:.2f} seconds after {details['tries']} " f"tries due to error: {error}. " f"Error type: {type(error).__name__}. ") if kwargs.get('task_id'): log.warning(f"Task ID: {kwargs['task_id']}") def log_giveup(details: dict) -> None: """Log backoff details. Args: details (dict): Details """ error = details['exception'] kwargs = details['kwargs'] target = details['target'] log.warning( f"Giving up on GraphQL attempts query via {target} after " f"{details['tries']} tries due to error: {error}. " f"Error type: {type(error).__name__}. ") if kwargs.get('task_id'): log.warning(f"Task ID: {kwargs['task_id']}") class GraphQLBackoffConnector(GraphQLConnector): """Connector to an upstream GraphQL service with backoff.""" # Max worst case time: 1 + 2 + 4 = 7 seconds @backoff.on_exception( backoff.expo, GraphQLError, factor=FACTOR, max_tries=MAX_TRIES, max_time=MAX_TIME, max_value=MAX_VALUE, base=BASE, on_backoff=log_backoff, on_giveup=log_giveup) def execute_query(self, query: str, data: dict): """Execute a call to a GraphQL endpoint with a provided query. Args: query (str): The query to execute. If not provided, the internal query will be used. data (dict): JSON formatted params """ try: response = self.client.execute( gql(query), variable_values=data ) except TransportError as te: raise GraphQLError(te) except ConnectionError as ce: raise GraphQLError(ce) return response