Skip to content

Commit ba3709a

Browse files
authored
fix(auth): raise typed ServiceUnavailableError on token endpoint 5xx (#2099)
Fixes #2097 ## Problem When a vendor OAuth token endpoint (e.g. the Atlantic/CozyTouch token URL) is temporarily down, it can return an **HTTP 5xx with an HTML body**. The auth strategies parse token responses with `await response.json()` **without checking the HTTP status first**, so aiohttp raises a raw `ContentTypeError: Attempt to decode JSON with unexpected mimetype: text/html`. This: - leaks an aiohttp implementation detail to consumers, and - isn't caught by any `retry_on_*` decorator (they only match typed Overkiz exceptions), so a transient hiccup becomes a hard failure. Reported downstream in Home Assistant core #160761. The same unguarded `response.json()` pattern existed at four token sites: Somfy, Cozytouch, Nexity, and Rexel. A secondary gap: `check_response()` only mapped **503** to `ServiceUnavailableError`; 500/502/504 with a non-JSON body fell through to a generic `OverkizError`. ## Fix 1. **`auth/strategies.py`** — add `_raise_for_server_error()` which routes any 5xx token response through `check_response()` before JSON decoding, applied at the Somfy, Cozytouch, Nexity and Rexel token endpoints. 4xx vendor JSON error formats (e.g. `{"error": "invalid_grant"}`) are still handled by each strategy as before. 2. **`response_handler.py`** — generalize the non-JSON branch from `== 503` to `>= 500`, so any 5xx HTML body maps to `ServiceUnavailableError`. Implements points 1 & 2 of the issue. Point 3 (auto-retry of transient 5xx) was intentionally left out — consumers now get a typed, catchable `ServiceUnavailableError`, which HA already treats as a retryable update failure. ## Tests - New `TestTokenEndpointServerErrors` reproduces the exact `ContentTypeError` leak for Somfy/Cozytouch/Rexel and asserts it now raises `ServiceUnavailableError`. - New `check_response` cases for 500/502/504 + a `502-bad-gateway.html` fixture. - Full suite: **497 passed**, ruff + mypy clean.
1 parent d428363 commit ba3709a

5 files changed

Lines changed: 123 additions & 2 deletions

File tree

pyoverkiz/auth/strategies.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
if TYPE_CHECKING:
1515
from botocore.client import BaseClient
1616

17-
from aiohttp import ClientSession, FormData
17+
from aiohttp import ClientResponse, ClientSession, FormData
1818

1919
from pyoverkiz.auth.base import AuthContext, AuthStrategy, GatewayCandidate
2020
from pyoverkiz.auth.credentials import (
@@ -58,6 +58,17 @@
5858
MIN_JWT_SEGMENTS = 2
5959

6060

61+
async def _raise_for_server_error(response: ClientResponse) -> None:
62+
"""Map a 5xx token-endpoint response to a typed Overkiz exception.
63+
64+
Token endpoints handle their own 4xx JSON error format, but on 5xx may
65+
serve an HTML error page. Route 5xx through ``check_response`` so it raises
66+
``ServiceUnavailableError`` (or ``MaintenanceError``).
67+
"""
68+
if response.status >= HTTPStatus.INTERNAL_SERVER_ERROR:
69+
await check_response(response)
70+
71+
6172
class BaseAuthStrategy(AuthStrategy):
6273
"""Base class for authentication strategies."""
6374

@@ -197,6 +208,7 @@ async def _request_access_token(
197208
data=form,
198209
headers={"Content-Type": "application/x-www-form-urlencoded"},
199210
) as response:
211+
await _raise_for_server_error(response)
200212
token = await response.json()
201213

202214
if token.get("message") == "error.invalid.grant":
@@ -229,6 +241,7 @@ async def login(self) -> None:
229241
"Content-Type": "application/x-www-form-urlencoded",
230242
},
231243
) as response:
244+
await _raise_for_server_error(response)
232245
token = await response.json()
233246

234247
if token.get("error") == "invalid_grant":
@@ -297,6 +310,7 @@ def _client() -> BaseClient:
297310
f"{NEXITY_API}/deploy/api/v1/domotic/token",
298311
headers={"Authorization": id_token},
299312
) as response:
313+
await _raise_for_server_error(response)
300314
token = await response.json()
301315

302316
if "token" not in token:
@@ -467,6 +481,7 @@ async def _exchange_token(self, payload: Mapping[str, str]) -> None:
467481
REXEL_OAUTH_TOKEN_URL,
468482
data=payload,
469483
) as response:
484+
await _raise_for_server_error(response)
470485
token = await response.json()
471486

472487
# Handle OAuth error responses explicitly before accessing the access token.

pyoverkiz/response_handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ async def check_response(response: ClientResponse) -> None:
135135
if "is down for maintenance" in result:
136136
raise MaintenanceError("Server is down for maintenance") from error
137137

138-
if response.status == HTTPStatus.SERVICE_UNAVAILABLE:
138+
# Any 5xx with a non-JSON body (e.g. an HTML error page from a proxy
139+
# returning 500/502/503/504) is a transient server/gateway failure.
140+
if response.status >= HTTPStatus.INTERNAL_SERVER_ERROR:
139141
raise ServiceUnavailableError(result) from error
140142

141143
raise OverkizError(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1><p>The proxy server received an invalid response from an upstream server.</p></body></html>

tests/test_auth.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,106 @@ async def test_login_propagates_non_auth_client_error(self):
663663
await strategy.login()
664664

665665

666+
def _server_error_response(status: int = 502):
667+
"""Return a mock token-endpoint response with a 5xx HTML body.
668+
669+
Mirrors aiohttp: ``response.json()`` (the default, content-type-checked
670+
call the strategies use) raises ContentTypeError on an HTML body, while
671+
``response.json(content_type=None)`` (used by check_response) raises
672+
JSONDecodeError when it tries to parse the HTML as JSON.
673+
"""
674+
from json import JSONDecodeError
675+
676+
from aiohttp import ContentTypeError
677+
678+
html = "<html><head><title>502 Bad Gateway</title></head></html>"
679+
680+
async def _json(content_type: str | None = "application/json"):
681+
if content_type is None:
682+
raise JSONDecodeError("Expecting value", html, 0)
683+
raise ContentTypeError(
684+
request_info=MagicMock(),
685+
history=(),
686+
message="Attempt to decode JSON with unexpected mimetype: text/html",
687+
)
688+
689+
response = MagicMock()
690+
response.status = status
691+
response.url = "https://apis.groupe-atlantic.com/token"
692+
response.json = AsyncMock(side_effect=_json)
693+
response.text = AsyncMock(return_value=html)
694+
response.__aenter__ = AsyncMock(return_value=response)
695+
response.__aexit__ = AsyncMock(return_value=None)
696+
return response
697+
698+
699+
class TestTokenEndpointServerErrors:
700+
"""A 5xx HTML body from a token endpoint must raise a typed Overkiz error."""
701+
702+
@pytest.mark.asyncio
703+
async def test_somfy_token_502_raises_service_unavailable(self):
704+
"""Somfy token endpoint 502 maps to ServiceUnavailableError, not aiohttp."""
705+
from pyoverkiz.exceptions import ServiceUnavailableError
706+
707+
server_config = ServerConfig(
708+
server=Server.SOMFY_EUROPE,
709+
name="Somfy",
710+
endpoint="https://api.somfy.com",
711+
manufacturer="Somfy",
712+
api_type=APIType.CLOUD,
713+
)
714+
credentials = UsernamePasswordCredentials("user", "pass")
715+
session = AsyncMock(spec=ClientSession)
716+
session.post = MagicMock(return_value=_server_error_response(502))
717+
718+
strategy = SomfyAuthStrategy(credentials, session, server_config, True)
719+
720+
with pytest.raises(ServiceUnavailableError):
721+
await strategy.login()
722+
723+
@pytest.mark.asyncio
724+
async def test_cozytouch_token_502_raises_service_unavailable(self):
725+
"""Cozytouch token endpoint 502 maps to ServiceUnavailableError."""
726+
from pyoverkiz.exceptions import ServiceUnavailableError
727+
728+
server_config = ServerConfig(
729+
server=Server.ATLANTIC_COZYTOUCH,
730+
name="Cozytouch",
731+
endpoint="https://api.cozytouch.com",
732+
manufacturer="Atlantic",
733+
api_type=APIType.CLOUD,
734+
)
735+
credentials = UsernamePasswordCredentials("user", "pass")
736+
session = AsyncMock(spec=ClientSession)
737+
session.post = MagicMock(return_value=_server_error_response(502))
738+
739+
strategy = CozytouchAuthStrategy(credentials, session, server_config, True)
740+
741+
with pytest.raises(ServiceUnavailableError):
742+
await strategy.login()
743+
744+
@pytest.mark.asyncio
745+
async def test_rexel_token_504_raises_service_unavailable(self):
746+
"""Rexel token exchange 504 maps to ServiceUnavailableError."""
747+
from pyoverkiz.exceptions import ServiceUnavailableError
748+
749+
server_config = ServerConfig(
750+
server=Server.REXEL,
751+
name="Rexel",
752+
endpoint="https://api.rexel.com",
753+
manufacturer="Rexel",
754+
api_type=APIType.CLOUD,
755+
)
756+
credentials = RexelOAuthCodeCredentials("code", "https://redirect", "verifier")
757+
session = AsyncMock(spec=ClientSession)
758+
session.post = MagicMock(return_value=_server_error_response(504))
759+
760+
strategy = RexelAuthStrategy(credentials, session, server_config, True)
761+
762+
with pytest.raises(ServiceUnavailableError):
763+
await strategy._exchange_token({"grant_type": "authorization_code"})
764+
765+
666766
class TestRexelAuthStrategy:
667767
"""Tests for Rexel auth specifics."""
668768

tests/test_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,9 @@ async def test_get_diagnostic_data_returns_structured_dict(
445445
[
446446
("cloud/503-empty.html", exceptions.ServiceUnavailableError, 503),
447447
("cloud/503-maintenance.html", exceptions.MaintenanceError, 503),
448+
("cloud/502-bad-gateway.html", exceptions.ServiceUnavailableError, 502),
449+
("cloud/502-bad-gateway.html", exceptions.ServiceUnavailableError, 504),
450+
("cloud/502-bad-gateway.html", exceptions.ServiceUnavailableError, 500),
448451
(
449452
"cloud/access-denied-to-gateway.json",
450453
exceptions.AccessDeniedToGatewayError,

0 commit comments

Comments
 (0)