Skip to content

Commit

Permalink
Reauthenticate last (#586)
Browse files Browse the repository at this point in the history
  • Loading branch information
rikroe authored Dec 1, 2023
1 parent 9d3bd67 commit 5eb68d8
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 21 deletions.
46 changes: 25 additions & 21 deletions bimmer_connected/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,30 +84,34 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.

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

# Retry 3 times on 401 or 429
for _ in range(3):
# Handle "classic" 401 Unauthorized and try getting a new token
# We don't want to call the auth endpoint too many times, so we only do it once per 401
if response.status_code == 401 and response.status_code != prev_response_code:
prev_response_code = response.status_code
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
response = yield request
# return directly if first response was successful
if response.is_success:
return

await response.aread()

# First check against 429 Too Many Requests and 403 Quota Exceeded
retry_count = 0
while (
response.status_code == 429 or (response.status_code == 403 and "quota" in response.text.lower())
) and retry_count < 3:
# 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" in response.text.lower()):
prev_response_code = response.status_code
await response.aread()
wait_time = get_retry_wait_time(response)
_LOGGER.debug("Sleeping %s seconds due to 429 Too Many Requests", wait_time)
await asyncio.sleep(wait_time)
response = yield request
wait_time = get_retry_wait_time(response)
_LOGGER.debug("Sleeping %s seconds due to 429 Too Many Requests", wait_time)
await asyncio.sleep(wait_time)
response = yield request
await response.aread()
retry_count += 1

# Handle 401 Unauthorized and try getting a new token
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
response = yield request

# Raise if request still was not successful
try:
Expand Down
49 changes: 49 additions & 0 deletions bimmer_connected/tests/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,9 @@ async def test_429_retry_with_login_raise_vehicles(bmw_fixture: respx.Router):
httpx.Response(401),
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
]
)

Expand All @@ -533,6 +536,52 @@ async def test_multiple_401(bmw_fixture: respx.Router):
await account.get_vehicles()


@pytest.mark.asyncio
async def test_401_after_429_ok(bmw_fixture: respx.Router):
"""Test the error handling, when a 401 is received after exactly 3 429."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
await account.get_vehicles()

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

# Recover after 3 429 and 1 401
bmw_fixture.get("/eadrax-vcs/v4/vehicles").mock(
side_effect=[
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
httpx.Response(401),
*[httpx.Response(200, json={})] * 100, # Just simulate OK responses from now on
]
)
with mock.patch("asyncio.sleep", new_callable=mock.AsyncMock):
await account.get_vehicles()
assert len(account.vehicles) == get_fingerprint_state_count()


@pytest.mark.asyncio
async def test_401_after_429_fail(bmw_fixture: respx.Router):
"""Test the error handling, when a 401 is received after exactly 3 429."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)

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

# Fail after 3 429 and 1 401 with another 429
bmw_fixture.get("/eadrax-vcs/v4/vehicles").mock(
side_effect=[
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
httpx.Response(429, json=json_429),
httpx.Response(401),
httpx.Response(429, json=json_429),
]
)

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


@pytest.mark.asyncio
async def test_403_quota_exceeded_vehicles_usa(caplog, bmw_fixture: respx.Router):
"""Test 403 quota issues for vehicle state and fail if it happens too often."""
Expand Down

0 comments on commit 5eb68d8

Please sign in to comment.