From 11c1f174b58753ee42f1a89bdb0bbbdd096f74b0 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Mon, 25 Nov 2024 19:00:06 +0100 Subject: [PATCH 1/4] use AsyncClientSession with automatic retries --- .../polestar_api/pypolestar/graphql.py | 20 ++++++- .../polestar_api/pypolestar/polestar.py | 57 ++++++++++--------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/custom_components/polestar_api/pypolestar/graphql.py b/custom_components/polestar_api/pypolestar/graphql.py index 05b98c8..ed2a39c 100644 --- a/custom_components/polestar_api/pypolestar/graphql.py +++ b/custom_components/polestar_api/pypolestar/graphql.py @@ -1,5 +1,8 @@ +import backoff import httpx -from gql import Client, gql +from gql import gql +from gql.client import AsyncClientSession, Client +from gql.transport.exceptions import TransportQueryError from gql.transport.httpx import HTTPXAsyncTransport from .const import HTTPX_TIMEOUT @@ -29,6 +32,21 @@ def get_gql_client(client: httpx.AsyncClient, url: str) -> Client: ) +async def get_gql_session(client: Client) -> AsyncClientSession: + retry_connect = backoff.on_exception(wait_gen=backoff.expo, exception=Exception) + retry_execute = backoff.on_exception( + wait_gen=backoff.expo, + exception=Exception, + max_tries=3, + giveup=lambda e: isinstance(e, TransportQueryError), + ) + return await client.connect_async( + reconnecting=True, + retry_connect=retry_connect, + retry_execute=retry_execute, + ) + + QUERY_GET_AUTH_TOKEN = gql( """ query getAuthToken($code: String!) { diff --git a/custom_components/polestar_api/pypolestar/polestar.py b/custom_components/polestar_api/pypolestar/polestar.py index 8fdb39a..f0d74db 100644 --- a/custom_components/polestar_api/pypolestar/polestar.py +++ b/custom_components/polestar_api/pypolestar/polestar.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import httpx +from gql.client import AsyncClientSession from gql.transport.exceptions import TransportQueryError from graphql import DocumentNode @@ -24,6 +25,7 @@ QUERY_GET_CONSUMER_CARS_V2_VERBOSE, QUERY_GET_ODOMETER_DATA, get_gql_client, + get_gql_session, ) from .models import CarBatteryData, CarInformationData, CarOdometerData @@ -54,9 +56,11 @@ def __init__( self.logger = _LOGGER.getChild(unique_id) if unique_id else _LOGGER self.api_url = API_MYSTAR_V2_URL self.gql_client = get_gql_client(url=self.api_url, client=self.client_session) + self.gql_session: AsyncClientSession | None = None async def async_init(self, verbose: bool = False) -> None: """Initialize the Polestar API.""" + await self.auth.async_init() await self.auth.get_token() @@ -64,6 +68,8 @@ async def async_init(self, verbose: bool = False) -> None: self.logger.warning("No access token %s", self.username) return + self.gql_session = await get_gql_session(self.gql_client) + if not (car_data := await self._get_vehicle_data(verbose=verbose)): self.logger.warning("No cars found for %s", self.username) return @@ -276,34 +282,33 @@ async def _query_graph_ql( operation_name: str | None = None, variable_values: dict | None = None, ): + if self.gql_session is None: + raise RuntimeError("GraphQL not connected") + self.logger.debug("GraphQL URL: %s", self.api_url) - async with self.gql_client as client: - try: - result = await client.execute( - query, - operation_name=operation_name, - variable_values=variable_values, - extra_args={ - "headers": {"Authorization": f"Bearer {self.auth.access_token}"} - }, - ) - except TransportQueryError as exc: - self.logger.debug("GraphQL TransportQueryError: %s", str(exc)) - if ( - exc.errors - and exc.errors[0].get("extensions", {}).get("code") - == "UNAUTHENTICATED" - ): - self.latest_call_code = 401 - raise PolestarNotAuthorizedException( - exc.errors[0]["message"] - ) from exc - self.latest_call_code = 500 - raise PolestarApiException from exc - except Exception as exc: - self.logger.debug("GraphQL Exception: %s", str(exc)) - raise exc + try: + result = await self.gql_session.execute( + query, + operation_name=operation_name, + variable_values=variable_values, + extra_args={ + "headers": {"Authorization": f"Bearer {self.auth.access_token}"} + }, + ) + except TransportQueryError as exc: + self.logger.debug("GraphQL TransportQueryError: %s", str(exc)) + if ( + exc.errors + and exc.errors[0].get("extensions", {}).get("code") == "UNAUTHENTICATED" + ): + self.latest_call_code = 401 + raise PolestarNotAuthorizedException(exc.errors[0]["message"]) from exc + self.latest_call_code = 500 + raise PolestarApiException from exc + except Exception as exc: + self.logger.debug("GraphQL Exception: %s", str(exc)) + raise exc self.logger.debug("GraphQL Result: %s", result) self.latest_call_code = 200 From 215276a9567c7ac87b9d23157ffe0d8941bbbb52 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Mon, 25 Nov 2024 19:07:32 +0100 Subject: [PATCH 2/4] add max retries on connect --- custom_components/polestar_api/pypolestar/graphql.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/polestar_api/pypolestar/graphql.py b/custom_components/polestar_api/pypolestar/graphql.py index ed2a39c..beda666 100644 --- a/custom_components/polestar_api/pypolestar/graphql.py +++ b/custom_components/polestar_api/pypolestar/graphql.py @@ -33,7 +33,11 @@ def get_gql_client(client: httpx.AsyncClient, url: str) -> Client: async def get_gql_session(client: Client) -> AsyncClientSession: - retry_connect = backoff.on_exception(wait_gen=backoff.expo, exception=Exception) + retry_connect = backoff.on_exception( + wait_gen=backoff.expo, + exception=Exception, + max_tries=5, + ) retry_execute = backoff.on_exception( wait_gen=backoff.expo, exception=Exception, From 169a4b8bd86e5b9fb52df62994abe6a25506f624 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Wed, 27 Nov 2024 06:35:18 +0100 Subject: [PATCH 3/4] define constants, add docs --- custom_components/polestar_api/pypolestar/const.py | 3 +++ custom_components/polestar_api/pypolestar/graphql.py | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/custom_components/polestar_api/pypolestar/const.py b/custom_components/polestar_api/pypolestar/const.py index d280d2e..3b84333 100644 --- a/custom_components/polestar_api/pypolestar/const.py +++ b/custom_components/polestar_api/pypolestar/const.py @@ -7,6 +7,9 @@ HTTPX_TIMEOUT = 30 TOKEN_REFRESH_WINDOW_MIN = 300 +GRAPHQL_CONNECT_RETRIES = 5 +GRAPHQL_EXECUTE_RETRIES = 3 + OIDC_PROVIDER_BASE_URL = "https://polestarid.eu.polestar.com" OIDC_REDIRECT_URI = "https://www.polestar.com/sign-in-callback" OIDC_CLIENT_ID = "l3oopkc_10" diff --git a/custom_components/polestar_api/pypolestar/graphql.py b/custom_components/polestar_api/pypolestar/graphql.py index beda666..9fb0167 100644 --- a/custom_components/polestar_api/pypolestar/graphql.py +++ b/custom_components/polestar_api/pypolestar/graphql.py @@ -5,7 +5,7 @@ from gql.transport.exceptions import TransportQueryError from gql.transport.httpx import HTTPXAsyncTransport -from .const import HTTPX_TIMEOUT +from .const import GRAPHQL_CONNECT_RETRIES, GRAPHQL_EXECUTE_RETRIES, HTTPX_TIMEOUT class _HTTPXAsyncTransport(HTTPXAsyncTransport): @@ -24,6 +24,7 @@ async def close(self): def get_gql_client(client: httpx.AsyncClient, url: str) -> Client: + """Get GraphQL Client using existing httpx AsyncClient""" transport = _HTTPXAsyncTransport(url=url, client=client) return Client( transport=transport, @@ -33,15 +34,16 @@ def get_gql_client(client: httpx.AsyncClient, url: str) -> Client: async def get_gql_session(client: Client) -> AsyncClientSession: + """Get GraphQL Session with automatic retries""" retry_connect = backoff.on_exception( wait_gen=backoff.expo, exception=Exception, - max_tries=5, + max_tries=GRAPHQL_CONNECT_RETRIES, ) retry_execute = backoff.on_exception( wait_gen=backoff.expo, exception=Exception, - max_tries=3, + max_tries=GRAPHQL_EXECUTE_RETRIES, giveup=lambda e: isinstance(e, TransportQueryError), ) return await client.connect_async( From 51c0cb798f20e0ccb998d1041db06b4546b744e6 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Wed, 27 Nov 2024 06:41:24 +0100 Subject: [PATCH 4/4] tweek retry exceptions --- custom_components/polestar_api/pypolestar/graphql.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/polestar_api/pypolestar/graphql.py b/custom_components/polestar_api/pypolestar/graphql.py index 9fb0167..e5ed6da 100644 --- a/custom_components/polestar_api/pypolestar/graphql.py +++ b/custom_components/polestar_api/pypolestar/graphql.py @@ -2,7 +2,7 @@ import httpx from gql import gql from gql.client import AsyncClientSession, Client -from gql.transport.exceptions import TransportQueryError +from gql.transport.exceptions import TransportError, TransportQueryError from gql.transport.httpx import HTTPXAsyncTransport from .const import GRAPHQL_CONNECT_RETRIES, GRAPHQL_EXECUTE_RETRIES, HTTPX_TIMEOUT @@ -37,12 +37,12 @@ async def get_gql_session(client: Client) -> AsyncClientSession: """Get GraphQL Session with automatic retries""" retry_connect = backoff.on_exception( wait_gen=backoff.expo, - exception=Exception, + exception=(TransportError, httpx.TransportError), max_tries=GRAPHQL_CONNECT_RETRIES, ) retry_execute = backoff.on_exception( wait_gen=backoff.expo, - exception=Exception, + exception=(TransportError,), max_tries=GRAPHQL_EXECUTE_RETRIES, giveup=lambda e: isinstance(e, TransportQueryError), )