Skip to content

Commit

Permalink
Update user agent (#445)
Browse files Browse the repository at this point in the history
* Update x-user-agent

* Fix mileage not available

* Retry on 429 after waiting

* Add tests for 429 handling

* Fix tests and raise after 3rd 429

Co-authored-by: rikroe <[email protected]>
  • Loading branch information
rikroe and rikroe authored May 28, 2022
1 parent fb40735 commit 69040fc
Show file tree
Hide file tree
Showing 8 changed files with 96 additions and 24 deletions.
2 changes: 1 addition & 1 deletion bimmer_connected/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async def get_vehicles(self) -> None:
await client.get(
VEHICLES_URL,
params=vehicles_request_params,
headers=client.generate_default_header(brand),
headers=client.generate_default_header(self.region, brand),
)
for brand in CarBrands
]
Expand Down
21 changes: 17 additions & 4 deletions bimmer_connected/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
request.headers["authorization"] = f"Bearer {self.access_token}"
request.headers["bmw-session-id"] = self.session_id
yield request
elif response.status_code == 429:
for _ in range(3):
if response.status_code == 429:
await response.aread()
wait_time = math.ceil(
next(iter([int(i) for i in response.json().get("message", "") if i.isdigit()]), 2) * 1.25
)
_LOGGER.debug("Sleeping %s seconds due to 429 Too Many Requests", wait_time)
await asyncio.sleep(wait_time)
response = yield request
# Raise if still error after 3rd retry
response.raise_for_status()

async def login(self) -> None:
"""Get a valid OAuth token."""
Expand Down Expand Up @@ -324,9 +336,10 @@ def __init__(self, *args, **kwargs):

kwargs["auth"] = MyBMWLoginRetry()

# Set default values
kwargs["base_url"] = get_server_url(kwargs.pop("region"))
kwargs["headers"] = {"user-agent": USER_AGENT, "x-user-agent": X_USER_AGENT.format("bmw")}
# Set default values#
region = kwargs.pop("region")
kwargs["base_url"] = get_server_url(region)
kwargs["headers"] = {"user-agent": USER_AGENT, "x-user-agent": X_USER_AGENT.format("bmw", region.value)}

# Register event hooks
kwargs["event_hooks"] = defaultdict(list, **kwargs.get("event_hooks", {}))
Expand Down Expand Up @@ -356,7 +369,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
# Try getting a response
response: httpx.Response = (yield request)

for _ in range(5):
for _ in range(3):
if response.status_code == 429:
await response.aread()
wait_time = math.ceil(
Expand Down
10 changes: 5 additions & 5 deletions bimmer_connected/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.regions import get_server_url
from bimmer_connected.api.utils import get_correlation_id, log_to_to_file
from bimmer_connected.const import HTTPX_TIMEOUT, USER_AGENT, X_USER_AGENT, CarBrands
from bimmer_connected.const import HTTPX_TIMEOUT, USER_AGENT, X_USER_AGENT, CarBrands, Regions


@dataclass
Expand All @@ -35,7 +35,7 @@ def __init__(self, config: MyBMWClientConfiguration, *args, brand: CarBrands = N

# Set default values
kwargs["base_url"] = kwargs.get("base_url") or get_server_url(config.authentication.region)
kwargs["headers"] = kwargs.get("headers") or self.generate_default_header(brand)
kwargs["headers"] = kwargs.get("headers") or self.generate_default_header(config.authentication.region, brand)

# Register event hooks
kwargs["event_hooks"] = defaultdict(list, **kwargs.get("event_hooks", {}))
Expand All @@ -56,7 +56,7 @@ async def raise_for_status_event_handler(response: httpx.Response):
Will only raise on 4xx/5xx errors (but not 401!) and not raise on 3xx.
"""
if response.is_error and response.status_code != 401:
if response.is_error and response.status_code not in [401, 429]:
await response.aread()
response.raise_for_status()

Expand All @@ -65,12 +65,12 @@ async def raise_for_status_event_handler(response: httpx.Response):
super().__init__(*args, **kwargs)

@staticmethod
def generate_default_header(brand: CarBrands = None) -> Dict[str, str]:
def generate_default_header(region: Regions, brand: CarBrands = None) -> Dict[str, str]:
"""Generate a header for HTTP requests to the server."""
return {
"accept": "application/json",
"accept-language": "en",
"user-agent": USER_AGENT,
"x-user-agent": X_USER_AGENT.format(brand or CarBrands.BMW),
"x-user-agent": X_USER_AGENT.format((brand or CarBrands.BMW), region.value),
**get_correlation_id(),
}
2 changes: 1 addition & 1 deletion bimmer_connected/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async def get_status(args) -> None:

for vehicle in account.vehicles:
print(f"VIN: {vehicle.vin}")
print(f"Mileage: {vehicle.status.mileage}")
print(f"Mileage: {vehicle.mileage.value} {vehicle.mileage.unit}")
print("Vehicle data:")
print(json.dumps(account.vehicles, cls=MyBMWJSONEncoder, indent=4))

Expand Down
12 changes: 6 additions & 6 deletions bimmer_connected/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ def _missing_(cls, value):
MINI = "mini"


class Regions(Enum):
class Regions(str, Enum):
"""Regions of the world with separate servers."""

NORTH_AMERICA = 0
CHINA = 1
REST_OF_WORLD = 2
NORTH_AMERICA = "na"
CHINA = "cn"
REST_OF_WORLD = "row"


SERVER_URLS_MYBMW = {
Expand All @@ -44,8 +44,8 @@ class Regions(Enum):

HTTPX_TIMEOUT = 30.0

USER_AGENT = "Dart/2.13 (dart:io)"
X_USER_AGENT = "android(v1.07_20200330);{};2.3.0(13603)"
USER_AGENT = "Dart/2.14 (dart:io)"
X_USER_AGENT = "android(SP1A.210812.016.C1);{};2.5.2(14945);{}"


AUTH_CHINA_PUBLIC_KEY_URL = "/eadrax-coas/v1/cop/publickey"
Expand Down
3 changes: 2 additions & 1 deletion bimmer_connected/vehicle/vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ def drive_train(self) -> DriveTrainType:
@property
def mileage(self) -> ValueWithUnit:
"""Get the mileage of the vehicle."""
return ValueWithUnit(self._status["currentMileage"]["mileage"], self._status["currentMileage"]["units"])
current_mileage = self._status.get("currentMileage", {})
return ValueWithUnit(current_mileage.get("mileage"), current_mileage.get("units"))

@property
def timestamp(self) -> Optional[datetime.datetime]:
Expand Down
2 changes: 1 addition & 1 deletion test/responses/G21/json_export.json
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@
},
"heading": 123,
"vehicle_update_timestamp": "2021-11-14T20:20:21",
"account_region": "Regions.REST_OF_WORLD",
"account_region": "row",
"remote_service_position": null
},
"doors_and_windows": {
Expand Down
68 changes: 63 additions & 5 deletions test/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from bimmer_connected.account import ConnectedDriveAccount, MyBMWAccount
from bimmer_connected.api.authentication import MyBMWAuthentication, MyBMWLoginRetry
from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.const import Regions
from bimmer_connected.vehicle.models import GPSPosition

from . import (
Expand Down Expand Up @@ -51,11 +52,14 @@ def authenticate_sideeffect(request: httpx.Request) -> httpx.Response:
def vehicles_sideeffect(request: httpx.Request) -> httpx.Response:
"""Returns /vehicles response based on x-user-agent."""
x_user_agent = request.headers.get("x-user-agent", "").split(";")
if len(x_user_agent) == 3:
if len(x_user_agent) == 4:
brand = x_user_agent[1]
else:
raise ValueError("x-user-agent not configured correctly!")

# Test if given region is valid
_ = Regions(x_user_agent[3])

response_vehicles: List[Dict] = []
files = RESPONSE_DIR.rglob(f"vehicles_v2_{brand}_0.json")
for file in files:
Expand Down Expand Up @@ -396,7 +400,7 @@ async def test_refresh_token_getset():


@pytest.mark.asyncio
async def test_429_retry_ok(caplog):
async def test_429_retry_ok_login(caplog):
"""Test the login flow using refresh_token."""
with account_mock() as mock_api:
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
Expand Down Expand Up @@ -424,24 +428,78 @@ async def test_429_retry_ok(caplog):


@pytest.mark.asyncio
async def test_429_retry_raise(caplog):
async def test_429_retry_raise_login(caplog):
"""Test the login flow using refresh_token."""
with account_mock() as mock_api:
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

mock_api.get("/eadrax-ucs/v1/presentation/oauth/config").mock(
mock_api.get("/eadrax-ucs/v1/presentation/oauth/config").mock(return_value=httpx.Response(429, json=json_429))
caplog.set_level(logging.DEBUG)

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

log_429 = [
r
for r in caplog.records
if r.module == "authentication" and "seconds due to 429 Too Many Requests" in r.message
]
assert len(log_429) == 3


@pytest.mark.asyncio
async def test_429_retry_ok_vehicles(caplog):
"""Test waiting on 429 for vehicles."""
with account_mock() as mock_api:
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

mock_api.get("/eadrax-vcs/v1/vehicles").mock(
side_effect=[
*[httpx.Response(429, json=json_429)] * 6,
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
*[httpx.Response(200, json=[])] * 2,
]
)
caplog.set_level(logging.DEBUG)

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

log_429 = [
r
for r in caplog.records
if r.module == "authentication" and "seconds due to 429 Too Many Requests" in r.message
]
assert len(log_429) == 2


@pytest.mark.asyncio
async def test_429_retry_raise_vehicles(caplog):
"""Test waiting on 429 for vehicles and fail if it happens too often."""
with account_mock() as mock_api:
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

mock_api.get("/eadrax-vcs/v1/vehicles").mock(return_value=httpx.Response(429, json=json_429))
caplog.set_level(logging.DEBUG)

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

log_429 = [
r
for r in caplog.records
if r.module == "authentication" and "seconds due to 429 Too Many Requests" in r.message
]
assert len(log_429) == 3


@account_mock()
@pytest.mark.asyncio
Expand Down

0 comments on commit 69040fc

Please sign in to comment.