Repository: bimmerconnected/bimmer_connected
Pull request #522 (2023-03-26 16:41:09)

Update httpx error handler
## Proposed change
<!--
  Describe the big picture of your changes here to communicate to the
  maintainers why we should accept this pull request. If it fixes a bug
  or resolves a feature request, be sure to link to that issue in the
  additional information section.
-->
Prompted by https://github.com/home-assistant/core/pull/90274, this PR introduces two new exceptions: `MyBMWAuthException` (HTTP status codes 401 and 403) and `MyBMWAPIException` (all other HTTP error codes).
These two are replacing all `httpx.HTTPStatusError` exceptions that might be raised (i.e. a request was technically successful but has a 4xx or 5xx HTTP status code).

The `request.raise_for_status()` handlers in `MyBMWClient`, `MyBMWLoginClient` and `MyBMWLoginRetry` have been adjusted accordingly to use `handle_httpstattuserror()`.

Most changes originate from `MyBMWAuthentication._login_row_na()` and  `MyBMWAuthentication._login_row_china()` as the `try ... except` block was removed (this is now handled directly in `MyBMWLoginClient`.

While this is a change deep at the base of our HTTP logic, it should not cause any issue. All tests pass after being converted from `httpx.HTTPStatusError` to the new exception classes.

## Type of change
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (which adds functionality to this library)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [x] Code quality improvements to existing code or addition of tests

## Additional information
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes #
- This PR is related to issue: 

## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [x] Tests have been added to verify that the new code works.


codecov[bot]: ## [Codecov](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`0a81d51`)](https://codecov.io/gh/bimmerconnected/bimmer_connected/commit/0a81d51beac649ffd24c508c10167ba489257b52?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected) 100.00% compared to head [(`6056175`)](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected) 100.00%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master      #522   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           16        16           
  Lines         1361      1372   +11     
=========================================
+ Hits          1361      1372   +11     
```

| Flag | Coverage Δ | |
|---|---|---|
| 3.10 | `100.00% <100.00%> (ø)` | |
| 3.11 | `100.00% <100.00%> (ø)` | |
| 3.7 | `100.00% <100.00%> (ø)` | |
| 3.8 | `100.00% <100.00%> (ø)` | |
| 3.9 | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected) | Coverage Δ | |
|---|---|---|
| [bimmer\_connected/api/authentication.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9hcGkvYXV0aGVudGljYXRpb24ucHk=) | `100.00% <100.00%> (ø)` | |
| [bimmer\_connected/api/client.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9hcGkvY2xpZW50LnB5) | `100.00% <100.00%> (ø)` | |
| [bimmer\_connected/api/utils.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9hcGkvdXRpbHMucHk=) | `100.00% <100.00%> (ø)` | |
| [bimmer\_connected/models.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9tb2RlbHMucHk=) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/522?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected).


Patch:
diff --git a/bimmer_connected/api/authentication.py b/bimmer_connected/api/authentication.py
index 0bac832..c422325 100644
--- a/bimmer_connected/api/authentication.py
+++ b/bimmer_connected/api/authentication.py
@@ -20,7 +20,7 @@ from bimmer_connected.api.utils import (
     create_s256_code_challenge,
     generate_token,
     get_correlation_id,
-    handle_http_status_error,
+    handle_httpstatuserror,
 )
 from bimmer_connected.const import (
     AUTH_CHINA_LOGIN_URL,
@@ -31,6 +31,7 @@ from bimmer_connected.const import (
     USER_AGENT,
     X_USER_AGENT,
 )
+from bimmer_connected.models import MyBMWAPIError
 
 EXPIRES_AT_OFFSET = datetime.timedelta(seconds=HTTPX_TIMEOUT * 2)
 
@@ -38,7 +39,7 @@ _LOGGER = logging.getLogger(__name__)
 
 
 class MyBMWAuthentication(httpx.Auth):
-    """Authentication for MyBMW API."""
+    """Authentication and Retry Handler for MyBMW API."""
 
     def __init__(
         self,
@@ -97,7 +98,10 @@ class MyBMWAuthentication(httpx.Auth):
                     await asyncio.sleep(wait_time)
                     response = yield request
             # Raise if still error after 3rd retry
-            response.raise_for_status()
+            try:
+                response.raise_for_status()
+            except httpx.HTTPStatusError as ex:
+                await handle_httpstatuserror(ex, log_handler=_LOGGER)
 
     async def login(self) -> None:
         """Get a valid OAuth token."""
@@ -124,80 +128,76 @@ class MyBMWAuthentication(httpx.Auth):
 
     async def _login_row_na(self):
         """Login to Rest of World and North America."""
-        try:
-            async with MyBMWLoginClient(region=self.region) as client:
-                _LOGGER.debug("Authenticating with MyBMW flow for North America & Rest of World.")
-
-                # Get OAuth2 settings from BMW API
-                r_oauth_settings = await client.get(
-                    OAUTH_CONFIG_URL,
-                    headers={
-                        "ocp-apim-subscription-key": get_ocp_apim_key(self.region),
-                        "bmw-session-id": self.session_id,
-                        **get_correlation_id(),
-                    },
-                )
-                oauth_settings = r_oauth_settings.json()
-
-                # Generate OAuth2 Code Challenge + State
-                code_verifier = generate_token(86)
-                code_challenge = create_s256_code_challenge(code_verifier)
-
-                state = generate_token(22)
-
-                # Set up authenticate endpoint
-                authenticate_url = oauth_settings["tokenEndpoint"].replace("/token", "/authenticate")
-                oauth_base_values = {
-                    "client_id": oauth_settings["clientId"],
-                    "response_type": "code",
-                    "redirect_uri": oauth_settings["returnUrl"],
-                    "state": state,
-                    "nonce": "login_nonce",
-                    "scope": " ".join(oauth_settings["scopes"]),
-                    "code_challenge": code_challenge,
-                    "code_challenge_method": "S256",
-                }
-
-                # Call authenticate endpoint first time (with user/pw) and get authentication
-                response = await client.post(
-                    authenticate_url,
-                    data=dict(
-                        oauth_base_values,
-                        **{
-                            "grant_type": "authorization_code",
-                            "username": self.username,
-                            "password": self.password,
-                        },
-                    ),
-                )
-                authorization = httpx.URL(response.json()["redirect_to"]).params["authorization"]
-
-                # With authorization, call authenticate endpoint second time to get code
-                response = await client.post(
-                    authenticate_url,
-                    data=dict(oauth_base_values, **{"authorization": authorization}),
-                )
-                code = response.next_request.url.params["code"]
-
-                # With code, get token
-                current_utc_time = datetime.datetime.utcnow()
-                response = await client.post(
-                    oauth_settings["tokenEndpoint"],
-                    data={
-                        "code": code,
-                        "code_verifier": code_verifier,
-                        "redirect_uri": oauth_settings["returnUrl"],
+        async with MyBMWLoginClient(region=self.region) as client:
+            _LOGGER.debug("Authenticating with MyBMW flow for North America & Rest of World.")
+
+            # Get OAuth2 settings from BMW API
+            r_oauth_settings = await client.get(
+                OAUTH_CONFIG_URL,
+                headers={
+                    "ocp-apim-subscription-key": get_ocp_apim_key(self.region),
+                    "bmw-session-id": self.session_id,
+                    **get_correlation_id(),
+                },
+            )
+            oauth_settings = r_oauth_settings.json()
+
+            # Generate OAuth2 Code Challenge + State
+            code_verifier = generate_token(86)
+            code_challenge = create_s256_code_challenge(code_verifier)
+
+            state = generate_token(22)
+
+            # Set up authenticate endpoint
+            authenticate_url = oauth_settings["tokenEndpoint"].replace("/token", "/authenticate")
+            oauth_base_values = {
+                "client_id": oauth_settings["clientId"],
+                "response_type": "code",
+                "redirect_uri": oauth_settings["returnUrl"],
+                "state": state,
+                "nonce": "login_nonce",
+                "scope": " ".join(oauth_settings["scopes"]),
+                "code_challenge": code_challenge,
+                "code_challenge_method": "S256",
+            }
+
+            # Call authenticate endpoint first time (with user/pw) and get authentication
+            response = await client.post(
+                authenticate_url,
+                data=dict(
+                    oauth_base_values,
+                    **{
                         "grant_type": "authorization_code",
+                        "username": self.username,
+                        "password": self.password,
                     },
-                    auth=(oauth_settings["clientId"], oauth_settings["clientSecret"]),
-                )
-                response_json = response.json()
-
-                expiration_time = int(response_json["expires_in"])
-                expires_at = current_utc_time + datetime.timedelta(seconds=expiration_time)
+                ),
+            )
+            authorization = httpx.URL(response.json()["redirect_to"]).params["authorization"]
+
+            # With authorization, call authenticate endpoint second time to get code
+            response = await client.post(
+                authenticate_url,
+                data=dict(oauth_base_values, **{"authorization": authorization}),
+            )
+            code = response.next_request.url.params["code"]
+
+            # With code, get token
+            current_utc_time = datetime.datetime.utcnow()
+            response = await client.post(
+                oauth_settings["tokenEndpoint"],
+                data={
+                    "code": code,
+                    "code_verifier": code_verifier,
+                    "redirect_uri": oauth_settings["returnUrl"],
+                    "grant_type": "authorization_code",
+                },
+                auth=(oauth_settings["clientId"], oauth_settings["clientSecret"]),
+            )
+            response_json = response.json()
 
-        except httpx.HTTPStatusError as ex:
-            handle_http_status_error(ex, "Authentication", _LOGGER)
+            expiration_time = int(response_json["expires_in"])
+            expires_at = current_utc_time + datetime.timedelta(seconds=expiration_time)
 
         return {
             "access_token": response_json["access_token"],
@@ -239,9 +239,8 @@ class MyBMWAuthentication(httpx.Auth):
                 expiration_time = int(response_json["expires_in"])
                 expires_at = current_utc_time + datetime.timedelta(seconds=expiration_time)
 
-        except httpx.HTTPStatusError as ex:
-            _LOGGER.debug("Unable to get access token using refresh token.")
-            handle_http_status_error(ex, "Authentication", _LOGGER, debug=True)
+        except MyBMWAPIError:
+            _LOGGER.debug("Unable to get access token using refresh token, falling back to username/password.")
             return {}
 
         return {
@@ -251,38 +250,34 @@ class MyBMWAuthentication(httpx.Auth):
         }
 
     async def _login_china(self):
-        try:
-            async with MyBMWLoginClient(region=self.region) as client:
-                _LOGGER.debug("Authenticating with MyBMW flow for China.")
-
-                # Get current RSA public certificate & use it to encrypt password
-                response = await client.get(
-                    AUTH_CHINA_PUBLIC_KEY_URL,
-                )
-                pem_public_key = response.json()["data"]["value"]
-
-                public_key = RSA.import_key(pem_public_key)
-                cipher_rsa = PKCS1_v1_5.new(public_key)
-                encrypted = cipher_rsa.encrypt(self.password.encode())
-                pw_encrypted = base64.b64encode(encrypted).decode("UTF-8")
-
-                cipher_aes = AES.new(**get_aes_keys(self.region), mode=AES.MODE_CBC)
-                nonce = f"{self.username}|{datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%fZ')}".encode()
-
-                # Get token
-                response = await client.post(
-                    AUTH_CHINA_LOGIN_URL,
-                    headers={"x-login-nonce": base64.b64encode(cipher_aes.encrypt(pad(nonce, 16))).decode()},
-                    json={"mobile": self.username, "password": pw_encrypted},
-                )
-                response_json = response.json()["data"]
-
-                decoded_token = jwt.decode(
-                    response_json["access_token"], algorithms=["HS256"], options={"verify_signature": False}
-                )
-
-        except httpx.HTTPStatusError as ex:
-            handle_http_status_error(ex, "Authentication", _LOGGER)
+        async with MyBMWLoginClient(region=self.region) as client:
+            _LOGGER.debug("Authenticating with MyBMW flow for China.")
+
+            # Get current RSA public certificate & use it to encrypt password
+            response = await client.get(
+                AUTH_CHINA_PUBLIC_KEY_URL,
+            )
+            pem_public_key = response.json()["data"]["value"]
+
+            public_key = RSA.import_key(pem_public_key)
+            cipher_rsa = PKCS1_v1_5.new(public_key)
+            encrypted = cipher_rsa.encrypt(self.password.encode())
+            pw_encrypted = base64.b64encode(encrypted).decode("UTF-8")
+
+            cipher_aes = AES.new(**get_aes_keys(self.region), mode=AES.MODE_CBC)
+            nonce = f"{self.username}|{datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%fZ')}".encode()
+
+            # Get token
+            response = await client.post(
+                AUTH_CHINA_LOGIN_URL,
+                headers={"x-login-nonce": base64.b64encode(cipher_aes.encrypt(pad(nonce, 16))).decode()},
+                json={"mobile": self.username, "password": pw_encrypted},
+            )
+            response_json = response.json()["data"]
+
+            decoded_token = jwt.decode(
+                response_json["access_token"], algorithms=["HS256"], options={"verify_signature": False}
+            )
 
         return {
             "access_token": response_json["access_token"],
@@ -310,9 +305,8 @@ class MyBMWAuthentication(httpx.Auth):
                 expiration_time = int(response_json["expires_in"])
                 expires_at = current_utc_time + datetime.timedelta(seconds=expiration_time)
 
-        except httpx.HTTPStatusError as ex:
-            _LOGGER.debug("Unable to get access token using refresh token.")
-            handle_http_status_error(ex, "Authentication", _LOGGER, debug=True)
+        except MyBMWAPIError:
+            _LOGGER.debug("Unable to get access token using refresh token, falling back to username/password.")
             return {}
 
         return {
@@ -346,11 +340,13 @@ class MyBMWLoginClient(httpx.AsyncClient):
         async def raise_for_status_event_handler(response: httpx.Response):
             """Event handler that automatically raises HTTPStatusErrors when attached.
 
-            Will only raise on 4xx/5xx errors (but not on 429) and not raise on 3xx.
+            Will only raise on 4xx/5xx errors but not 429 which is handled `self.auth`.
             """
-            if response.is_error and not response.status_code == 429:
-                await response.aread()
-                response.raise_for_status()
+            if response.is_error and response.status_code != 429:
+                try:
+                    response.raise_for_status()
+                except httpx.HTTPStatusError as ex:
+                    await handle_httpstatuserror(ex, log_handler=_LOGGER)
 
         kwargs["event_hooks"]["response"].append(raise_for_status_event_handler)
 
@@ -376,6 +372,10 @@ class MyBMWLoginRetry(httpx.Auth):
                 _LOGGER.debug("Sleeping %s seconds due to 429 Too Many Requests", wait_time)
                 await asyncio.sleep(wait_time)
                 response = yield request
+        # Only checking for 429 errors, as all other errors are handled by the
+        # response hook of MyBMWLoginClient
         if response.status_code == 429:
-            await response.aread()
-            response.raise_for_status()
+            try:
+                response.raise_for_status()
+            except httpx.HTTPStatusError as ex:
+                await handle_httpstatuserror(ex, log_handler=_LOGGER)
diff --git a/bimmer_connected/api/client.py b/bimmer_connected/api/client.py
index a305865..14b294c 100644
--- a/bimmer_connected/api/client.py
+++ b/bimmer_connected/api/client.py
@@ -1,5 +1,6 @@
 """Generic API management."""
 
+import logging
 from collections import defaultdict, deque
 from dataclasses import dataclass
 from typing import Deque, Dict, Optional
@@ -8,10 +9,12 @@ import httpx
 
 from bimmer_connected.api.authentication import MyBMWAuthentication
 from bimmer_connected.api.regions import get_app_version, get_server_url
-from bimmer_connected.api.utils import anonymize_response, get_correlation_id
+from bimmer_connected.api.utils import anonymize_response, get_correlation_id, handle_httpstatuserror
 from bimmer_connected.const import HTTPX_TIMEOUT, USER_AGENT, X_USER_AGENT, CarBrands
 from bimmer_connected.models import AnonymizedResponse, GPSPosition
 
+_LOGGER = logging.getLogger(__name__)
+
 RESPONSE_STORE: Deque[AnonymizedResponse] = deque(maxlen=10)
 
 
@@ -62,11 +65,13 @@ class MyBMWClient(httpx.AsyncClient):
         async def raise_for_status_event_handler(response: httpx.Response):
             """Event handler that automatically raises HTTPStatusErrors when attached.
 
-            Will only raise on 4xx/5xx errors (but not 401!) and not raise on 3xx.
+            Will only raise on 4xx/5xx errors but not 401/429 which are handled `self.auth`.
             """
             if response.is_error and response.status_code not in [401, 429]:
-                await response.aread()
-                response.raise_for_status()
+                try:
+                    response.raise_for_status()
+                except httpx.HTTPStatusError as ex:
+                    await handle_httpstatuserror(ex, log_handler=_LOGGER)
 
         kwargs["event_hooks"]["response"].append(raise_for_status_event_handler)
 
diff --git a/bimmer_connected/api/utils.py b/bimmer_connected/api/utils.py
index 9c2e821..34a9415 100644
--- a/bimmer_connected/api/utils.py
+++ b/bimmer_connected/api/utils.py
@@ -13,7 +13,7 @@ from uuid import uuid4
 
 import httpx
 
-from bimmer_connected.models import AnonymizedResponse
+from bimmer_connected.models import AnonymizedResponse, MyBMWAPIError, MyBMWAuthError
 
 UNICODE_CHARACTER_SET = string.ascii_letters + string.digits + "-._~"
 RE_VIN = re.compile(r"(?P<vin>WB[a-zA-Z0-9]{15})")
@@ -42,22 +42,37 @@ def get_correlation_id() -> Dict[str, str]:
     }
 
 
-def handle_http_status_error(
+async def handle_httpstatuserror(
     ex: httpx.HTTPStatusError,
-    module: str = "MyBMW API",
+    module: str = "API",
     log_handler: Optional[logging.Logger] = None,
-    debug: bool = False,
+    dont_raise: bool = False,
 ) -> None:
     """Try to extract information from response and re-raise Exception."""
     _logger = log_handler or logging.getLogger(__name__)
-    _level = logging.DEBUG if debug else logging.ERROR
+    _level = logging.DEBUG if dont_raise else logging.ERROR
+
+    # By default we will raise a MyBMWAPIError
+    _ex_to_raise = MyBMWAPIError
+
+    # HTTP status code is 401 or 403, raise MyBMWAuthError instead
+    if ex.response.status_code in [401, 403]:
+        _ex_to_raise = MyBMWAuthError
+
+    await ex.response.aread()
+
     try:
-        err = ex.response.json()
-        _logger.log(_level, "%s error (%s): %s", module, err["error"], err["error_description"])
+        # Try parsing the known BMW API error JSON
+        _err = ex.response.json()
+        _err_message = f'{type(ex).__name__}: {_err["error"]} - {_err.get("error_description", "")}'
     except (json.JSONDecodeError, KeyError):
-        _logger.log(_level, "%s error: %s", module, ex.response.text)
-    if not debug:
-        raise ex
+        # If format has changed or is not JSON
+        _err_message = f"{type(ex).__name__}: {ex.response.text or str(ex)}"
+
+    _logger.log(_level, "MyBMW %s error: %s", module, _err_message)
+
+    if not dont_raise:
+        raise _ex_to_raise(_err_message) from ex
 
 
 def anonymize_data(json_data: Union[List, Dict]) -> Union[List, Dict]:
diff --git a/bimmer_connected/models.py b/bimmer_connected/models.py
index 6f193b8..456c5e5 100644
--- a/bimmer_connected/models.py
+++ b/bimmer_connected/models.py
@@ -143,3 +143,11 @@ class ChargingSettings:
     isUnlockCableActive = None
     acLimitValue: Optional[int] = None
     dcLoudness = None
+
+
+class MyBMWAPIError(Exception):
+    """General BMW API error."""
+
+
+class MyBMWAuthError(MyBMWAPIError):
+    """Auth-related error from BMW API (HTTP status codes 401 and 403)."""


Test patch:
diff --git a/test/test_account.py b/test/test_account.py
index a81af74..13b9c1c 100644
--- a/test/test_account.py
+++ b/test/test_account.py
@@ -23,7 +23,7 @@ from bimmer_connected.api.authentication import MyBMWAuthentication, MyBMWLoginR
 from bimmer_connected.api.client import MyBMWClient
 from bimmer_connected.api.regions import get_region_from_name
 from bimmer_connected.const import ATTR_CAPABILITIES, Regions
-from bimmer_connected.models import GPSPosition
+from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError
 
 from . import (
     ALL_CHARGING_SETTINGS,
@@ -204,7 +204,7 @@ async def test_login_refresh_token_row_na_invalid(caplog):
 
         debug_messages = [r.message for r in caplog.records if r.name.startswith("bimmer_connected")]
         assert "Authenticating with refresh token for North America & Rest of World." in debug_messages
-        assert "Unable to get access token using refresh token." in debug_messages
+        assert "Unable to get access token using refresh token, falling back to username/password." in debug_messages
         assert "Authenticating with MyBMW flow for North America & Rest of World." in debug_messages
 
 
@@ -277,7 +277,7 @@ async def test_login_refresh_token_china_invalid(caplog):
 
         debug_messages = [r.message for r in caplog.records if r.name.startswith("bimmer_connected")]
         assert "Authenticating with refresh token for China." in debug_messages
-        assert "Unable to get access token using refresh token." in debug_messages
+        assert "Unable to get access token using refresh token, falling back to username/password." in debug_messages
         assert "Authenticating with MyBMW flow for China." in debug_messages
 
 
@@ -330,7 +330,7 @@ async def test_invalid_password():
         mock_api.post("/gcdm/oauth/authenticate").respond(
             401, json=load_response(RESPONSE_DIR / "auth" / "auth_error_wrong_password.json")
         )
-        with pytest.raises(httpx.HTTPStatusError):
+        with pytest.raises(MyBMWAuthError):
             account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
             await account.get_vehicles()
 
@@ -342,7 +342,7 @@ async def test_invalid_password_china():
         mock_api.post("/eadrax-coas/v2/login/pwd").respond(
             422, json=load_response(RESPONSE_DIR / "auth" / "auth_cn_login_error.json")
         )
-        with pytest.raises(httpx.HTTPStatusError):
+        with pytest.raises(MyBMWAPIError):
             account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name("china"))
             await account.get_vehicles()
 
@@ -354,7 +354,7 @@ async def test_server_error():
         mock_api.post("/gcdm/oauth/authenticate").respond(
             500, text=load_response(RESPONSE_DIR / "auth" / "auth_error_internal_error.txt")
         )
-        with pytest.raises(httpx.HTTPStatusError):
+        with pytest.raises(MyBMWAPIError):
             account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
             await account.get_vehicles()
 
@@ -380,7 +380,7 @@ async def test_get_fingerprints():
         mock_api.get("/eadrax-vcs/v4/vehicles/state").respond(
             500, text=load_response(RESPONSE_DIR / "auth" / "auth_error_internal_error.txt")
         )
-        with pytest.raises(httpx.HTTPStatusError):
+        with pytest.raises(MyBMWAPIError):
             await account.get_vehicles()
 
     filenames = [Path(f.filename) for f in account.get_stored_responses()]
@@ -512,7 +512,7 @@ async def test_429_retry_raise_login(caplog):
         caplog.set_level(logging.DEBUG)
 
         with mock.patch("asyncio.sleep", new_callable=mock.AsyncMock):
-            with pytest.raises(httpx.HTTPStatusError):
+            with pytest.raises(MyBMWAPIError):
                 await account.get_vehicles()
 
         log_429 = [
@@ -563,7 +563,7 @@ async def test_429_retry_raise_vehicles(caplog):
         caplog.set_level(logging.DEBUG)
 
         with mock.patch("asyncio.sleep", new_callable=mock.AsyncMock):
-            with pytest.raises(httpx.HTTPStatusError):
+            with pytest.raises(MyBMWAPIError):
                 await account.get_vehicles()
 
         log_429 = [
diff --git a/test/test_api.py b/test/test_api.py
index c8d8ca7..6411383 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -1,12 +1,12 @@
 """Tests for API that are not covered by other tests."""
 import json
 
-import httpx
 import pytest
 
 from bimmer_connected.account import MyBMWAccount
 from bimmer_connected.api.regions import get_region_from_name, valid_regions
 from bimmer_connected.api.utils import anonymize_data
+from bimmer_connected.models import MyBMWAPIError
 from bimmer_connected.utils import log_response_store_to_file
 
 from . import RESPONSE_DIR, TEST_PASSWORD, TEST_REGION, TEST_USERNAME, get_fingerprint_count, load_response
@@ -63,7 +63,7 @@ async def test_storing_fingerprints(tmp_path):
         mock_api.get("/eadrax-vcs/v4/vehicles/state").respond(
             500, text=load_response(RESPONSE_DIR / "auth" / "auth_error_internal_error.txt")
         )
-        with pytest.raises(httpx.HTTPStatusError):
+        with pytest.raises(MyBMWAPIError):
             await account.get_vehicles()
 
     log_response_store_to_file(account.get_stored_responses(), tmp_path)
diff --git a/test/test_remote_services.py b/test/test_remote_services.py
index aed3504..42ed3da 100644
--- a/test/test_remote_services.py
+++ b/test/test_remote_services.py
@@ -5,6 +5,7 @@ from copy import deepcopy
 from pathlib import Path
 from typing import Dict, List
 
+from bimmer_connected.models import MyBMWAPIError
 from bimmer_connected.vehicle.charging_profile import ChargingMode
 
 try:
@@ -207,7 +208,7 @@ async def test_get_remote_service_status():
             ],
         )
 
-        with pytest.raises(httpx.HTTPStatusError):
+        with pytest.raises(MyBMWAPIError):
             await vehicle.remote_services._block_until_done(uuid4())
         with pytest.raises(ValueError):
             await vehicle.remote_services._block_until_done(uuid4())