From 47a7da9a10783c4a3bb097924b4b1eef41a60d9f Mon Sep 17 00:00:00 2001 From: Jeremie Date: Sat, 16 May 2026 14:38:37 +0200 Subject: [PATCH 1/2] fix(connection): retry transient 429 / 5xx instead of failing the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #61 (504 during command polling) and addresses #63 (429 marking every Porsche Connect entity Unavailable until HA restart). Per the maintainer comment on #63: > "I'll look into having some automatic retry after some time to get it > up again." And the body of #61: > "should not cause an exception (at least not until having retried one > or two times). Note that the remote service call itself was > successful, it was only the call for status that timed out." Implementation: - `Connection.request` retries up to 3 times on {429, 502, 503, 504}. - The server-provided `Retry-After` header is respected when present (RFC 9110 §10.2.3) and is a positive integer of seconds. - Otherwise: exponential backoff (1s, 2s, 4s) with up to 0.3s of jitter to spread out concurrent retries. - Per-retry delay capped at 30s so a misbehaving server can't pin a caller for minutes. - Non-transient 4xx (e.g. 400 / 401 / 404) still raise immediately via `PorscheExceptionError` — same behaviour as before. - Token-lock acquisition moved out of the retry loop; only the actual HTTP call is retried (`ensure_valid_token` is a fast no-op when the token is still valid). Concrete repro of the previous behaviour: >>> # Previously, a single 504 from /commands/{id} would crash the >>> # coordinator update, marking every HA entity Unavailable. >>> # After this patch, the 504 is retried up to 3 times before >>> # bubbling up. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyporscheconnectapi/connection.py | 77 ++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/pyporscheconnectapi/connection.py b/pyporscheconnectapi/connection.py index e2a14c3..0a53f99 100644 --- a/pyporscheconnectapi/connection.py +++ b/pyporscheconnectapi/connection.py @@ -5,6 +5,7 @@ import asyncio import logging +import secrets import httpx @@ -14,6 +15,32 @@ _LOGGER = logging.getLogger(__name__) +# HTTP status codes that justify a retry (transient server-side issues). +# 429 (rate limit), 502/503/504 (gateway / upstream timeouts) — all surface +# during normal Porsche Connect usage and are the recommended retry targets +# per the upstream maintainer's comments on issues #61 and #63. +_RETRY_STATUS_CODES = frozenset({429, 502, 503, 504}) +_MAX_RETRIES = 3 +# Cap a single retry delay so a misbehaving server can't pin a caller for +# minutes on a Retry-After header. +_MAX_RETRY_DELAY = 30.0 + + +def _compute_retry_delay(response: httpx.Response, attempt: int) -> float: + """Return how many seconds to wait before retrying after a transient error. + + Prefer the server-provided Retry-After header (RFC 9110 §10.2.3) when + it's a positive integer of seconds — that's what's been served in + practice by the Porsche API on 429. Otherwise fall back to exponential + backoff (1s, 2s, 4s) with jitter to spread out concurrent retries. + """ + retry_after = response.headers.get("retry-after", "") + if retry_after.isdigit(): + return min(float(retry_after), _MAX_RETRY_DELAY) + # secrets.randbelow keeps this deterministic-free without pulling random. + jitter = secrets.randbelow(300) / 1000.0 # 0-0.3s + return min((2 ** attempt) + jitter, _MAX_RETRY_DELAY) + async def log_request(request): """Provide formatting for http logging.""" @@ -81,22 +108,40 @@ async def delete(self, url, data=None, json=None): """Make a DELETE request to the Porsche Connect API.""" return await self.request("DELETE", url, data=data, json=json) - async def request(self, method, url, **kwargs): - """Create a request to the Porsche Connect API.""" - try: - async with self.token_lock: - await self.oauth2_client.ensure_valid_token(self.token) - resp = await self.asyncClient.request( - method, - f"{API_BASE_URL}{url}", - headers=self.headers | {"Authorization": f"Bearer {self.token.access_token}"}, - timeout=TIMEOUT, - **kwargs, - ) - resp.raise_for_status() # A common error seem to be: httpx.HTTPStatusError: Server error '504 Gateway Time-out' - return resp.json() - except httpx.HTTPStatusError as exc: - raise PorscheExceptionError(exc.response.status_code) from exc + async def request(self, method, url, **kwargs): # noqa: RET503 - loop body always returns or raises + """Create a request to the Porsche Connect API. + + Retries up to `_MAX_RETRIES` times on transient errors (429/502/ + 503/504) - these are server-side hiccups the Porsche API surfaces + regularly and that previously caused the whole integration to + report SETUP_RETRY or mark every entity Unavailable (issues #61 + and #63). Non-transient HTTP errors (4xx other than 429) are + raised immediately as before. + """ + async with self.token_lock: + await self.oauth2_client.ensure_valid_token(self.token) + + for attempt in range(_MAX_RETRIES + 1): + try: + resp = await self.asyncClient.request( + method, + f"{API_BASE_URL}{url}", + headers=self.headers | {"Authorization": f"Bearer {self.token.access_token}"}, + timeout=TIMEOUT, + **kwargs, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as exc: # noqa: PERF203 + status = exc.response.status_code + if status not in _RETRY_STATUS_CODES or attempt == _MAX_RETRIES: + raise PorscheExceptionError(status) from exc + delay = _compute_retry_delay(exc.response, attempt) + _LOGGER.warning( + "Transient HTTP %s on %s - retrying in %.1fs (attempt %d/%d)", + status, url, delay, attempt + 1, _MAX_RETRIES, + ) + await asyncio.sleep(delay) async def close(self): """Close the asyncClient connection.""" From bdbb6ee9b9ef29fdf37035a05110857cfbf93671 Mon Sep 17 00:00:00 2001 From: Fredrik Ljunggren Date: Sat, 29 Aug 2026 17:08:34 +0200 Subject: [PATCH 2/2] Cleanup of comments and steeper backoff --- pyporscheconnectapi/connection.py | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/pyporscheconnectapi/connection.py b/pyporscheconnectapi/connection.py index 0a53f99..c57fa5a 100644 --- a/pyporscheconnectapi/connection.py +++ b/pyporscheconnectapi/connection.py @@ -15,31 +15,23 @@ _LOGGER = logging.getLogger(__name__) -# HTTP status codes that justify a retry (transient server-side issues). -# 429 (rate limit), 502/503/504 (gateway / upstream timeouts) — all surface -# during normal Porsche Connect usage and are the recommended retry targets -# per the upstream maintainer's comments on issues #61 and #63. +# HTTP status codes that justify a retry (transient server-side issues): +# 429 (rate limit), 502/503/504 (gateway / upstream timeouts) _RETRY_STATUS_CODES = frozenset({429, 502, 503, 504}) _MAX_RETRIES = 3 -# Cap a single retry delay so a misbehaving server can't pin a caller for -# minutes on a Retry-After header. _MAX_RETRY_DELAY = 30.0 def _compute_retry_delay(response: httpx.Response, attempt: int) -> float: """Return how many seconds to wait before retrying after a transient error. - Prefer the server-provided Retry-After header (RFC 9110 §10.2.3) when - it's a positive integer of seconds — that's what's been served in - practice by the Porsche API on 429. Otherwise fall back to exponential - backoff (1s, 2s, 4s) with jitter to spread out concurrent retries. + Prefer the server-provided Retry-After header (RFC 9110 §10.2.3) if + provided as digit, otherwise fall back to exponential backoff (2s, 4s, 8s). """ retry_after = response.headers.get("retry-after", "") if retry_after.isdigit(): return min(float(retry_after), _MAX_RETRY_DELAY) - # secrets.randbelow keeps this deterministic-free without pulling random. - jitter = secrets.randbelow(300) / 1000.0 # 0-0.3s - return min((2 ** attempt) + jitter, _MAX_RETRY_DELAY) + return min((2 ** (attempt + 1)), _MAX_RETRY_DELAY) async def log_request(request): @@ -109,15 +101,7 @@ async def delete(self, url, data=None, json=None): return await self.request("DELETE", url, data=data, json=json) async def request(self, method, url, **kwargs): # noqa: RET503 - loop body always returns or raises - """Create a request to the Porsche Connect API. - - Retries up to `_MAX_RETRIES` times on transient errors (429/502/ - 503/504) - these are server-side hiccups the Porsche API surfaces - regularly and that previously caused the whole integration to - report SETUP_RETRY or mark every entity Unavailable (issues #61 - and #63). Non-transient HTTP errors (4xx other than 429) are - raised immediately as before. - """ + """Create a request to the Porsche Connect API.""" async with self.token_lock: await self.oauth2_client.ensure_valid_token(self.token)