Skip to content

Commit

Permalink
Handle quota exceeded in HTTP 403 (#543)
Browse files Browse the repository at this point in the history
  • Loading branch information
rikroe authored Jun 21, 2023
1 parent b60fb7f commit 770b1fa
Show file tree
Hide file tree
Showing 5 changed files with 40 additions and 8 deletions.
5 changes: 4 additions & 1 deletion bimmer_connected/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,18 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.

# Try getting a response
response: httpx.Response = (yield request)
await response.aread()

# Handle "classic" 401 Unauthorized
if response.status_code == 401:
async with self.login_lock:
_LOGGER.debug("Received unauthorized response, refreshing token.")
await self.login()
request.headers["authorization"] = f"Bearer {self.access_token}"
request.headers["bmw-session-id"] = self.session_id
yield request
elif response.status_code == 429:
# Quota errors can either be 429 Too Many Requests or 403 Quota Exceeded (instead of 403 Forbidden)
elif response.status_code == 429 or (response.status_code == 403 and "quota exceeded" in response.text.lower()):
for _ in range(3):
if response.status_code == 429:
await response.aread()
Expand Down
12 changes: 9 additions & 3 deletions bimmer_connected/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from Crypto.Hash import SHA256
from Crypto.Util.Padding import pad

from bimmer_connected.models import AnonymizedResponse, MyBMWAPIError, MyBMWAuthError
from bimmer_connected.models import AnonymizedResponse, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError

UNICODE_CHARACTER_SET = string.ascii_letters + string.digits + "-._~"
RE_VIN = re.compile(r"(?P<vin>WB[a-zA-Z0-9]{15})")
Expand Down Expand Up @@ -56,14 +56,20 @@ async def handle_httpstatuserror(
_logger = log_handler or logging.getLogger(__name__)
_level = logging.DEBUG if dont_raise else logging.ERROR

await ex.response.aread()

# 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()
# Quota errors can either be 429 Too Many Requests or 403 Quota Exceeded (instead of 401 Forbidden)
if ex.response.status_code == 429 or (
ex.response.status_code == 403 and "quota exceeded" in ex.response.text.lower()
):
_ex_to_raise = MyBMWQuotaError

try:
# Try parsing the known BMW API error JSON
Expand All @@ -73,7 +79,7 @@ async def handle_httpstatuserror(
# 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)
_logger.log(_level, "%s due to %s", _ex_to_raise.__name__, _err_message)

if not dont_raise:
raise _ex_to_raise(_err_message) from ex
Expand Down
4 changes: 4 additions & 0 deletions bimmer_connected/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,5 +153,9 @@ class MyBMWAuthError(MyBMWAPIError):
"""Auth-related error from BMW API (HTTP status codes 401 and 403)."""


class MyBMWQuotaError(MyBMWAPIError):
"""Quota exceeded on BMW API."""


class MyBMWRemoteServiceError(MyBMWAPIError):
"""Error when executing remote services."""
4 changes: 2 additions & 2 deletions bimmer_connected/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import time
import traceback
from enum import Enum
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, Dict, List, Optional, Union

from bimmer_connected.models import AnonymizedResponse

Expand Down Expand Up @@ -62,7 +62,7 @@ def parse_datetime(date_str: str) -> Optional[datetime.datetime]:
class MyBMWJSONEncoder(json.JSONEncoder):
"""JSON Encoder that handles data classes, properties and additional data types."""

def default(self, o): # noqa: D102
def default(self, o) -> Union[str, dict]: # noqa: D102
if isinstance(o, (datetime.datetime, datetime.date, datetime.time)):
return o.isoformat()
if not isinstance(o, Enum) and hasattr(o, "__dict__") and isinstance(o.__dict__, Dict):
Expand Down
23 changes: 21 additions & 2 deletions test/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
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, MyBMWAPIError, MyBMWAuthError
from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError

from . import (
ALL_CHARGING_SETTINGS,
Expand Down Expand Up @@ -584,7 +584,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(MyBMWAPIError):
with pytest.raises(MyBMWQuotaError):
await account.get_vehicles()

log_429 = [
Expand All @@ -595,6 +595,25 @@ async def test_429_retry_raise_vehicles(caplog):
assert len(log_429) == 3


@pytest.mark.asyncio
async def test_403_quota_exceeded_vehicles_usa(caplog):
"""Test 403 quota issues for vehicle state and fail if it happens too often."""
with account_mock() as mock_api:
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
# get vehicles once
await account.get_vehicles()

mock_api.get("/eadrax-vcs/v4/vehicles/state").mock(return_value=httpx.Response(403, text="403 Quota Exceeded"))
caplog.set_level(logging.DEBUG)

with mock.patch("asyncio.sleep", new_callable=mock.AsyncMock):
with pytest.raises(MyBMWQuotaError):
await account.get_vehicles()

log_quota = [r for r in caplog.records if "Quota Exceeded" in r.message]
assert len(log_quota) == 1


@account_mock()
@pytest.mark.asyncio
async def test_client_async_only():
Expand Down

0 comments on commit 770b1fa

Please sign in to comment.