Skip to content
This repository has been archived by the owner on Mar 25, 2024. It is now read-only.

Commit

Permalink
Add retry logic upon 401 response (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
bachya authored Nov 1, 2019
1 parent f334982 commit 4dd13af
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 41 deletions.
17 changes: 14 additions & 3 deletions eufy_security/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from aiohttp.client_exceptions import ClientError

from .camera import Camera
from .errors import RequestError, raise_error
from .errors import InvalidCredentialsError, RequestError, raise_error

_LOGGER: logging.Logger = logging.getLogger(__name__)

Expand All @@ -21,9 +21,10 @@ def __init__(self, email: str, password: str, websession: ClientSession) -> None
"""Initialize."""
self._email: str = email
self._password: str = password
self._retry_on_401: bool = False
self._session: ClientSession = websession
self._token: Optional[str] = None
self._token_expiration: Optional[datetime] = None
self._session: ClientSession = websession

self.cameras: Dict[str, Camera] = {}

Expand All @@ -35,6 +36,7 @@ async def async_authenticate(self) -> None:
json={"email": self._email, "password": self._password},
)

self._retry_on_401 = False
self._token = auth_resp["data"]["auth_token"]
self._token_expiration = datetime.fromtimestamp(
auth_resp["data"]["token_expires_at"]
Expand Down Expand Up @@ -92,9 +94,18 @@ async def request(

return data
except ClientError as err:
if "401" in str(err):
if self._retry_on_401:
raise InvalidCredentialsError("Token failed multiple times")

self._retry_on_401 = True
await self.async_authenticate()
return await self.request(
method, endpoint, headers=headers, json=json
)
raise RequestError(
f"There was an unknown error while requesting {endpoint}: {err}"
)
) from None


def _raise_on_error(data: dict) -> None:
Expand Down
140 changes: 102 additions & 38 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,70 @@
)


@pytest.mark.asyncio
async def test_401_refresh_failure(
aresponses, devices_list_json, event_loop, login_success_json
):
"""Test that multiple 401 responses in a row raises the right exception."""
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=json.dumps(login_success_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/app/get_devs_list",
"post",
aresponses.Response(text=None, status=401),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=None, status=401),
)

async with aiohttp.ClientSession(loop=event_loop) as websession:
with pytest.raises(InvalidCredentialsError):
await async_login(TEST_EMAIL, TEST_PASSWORD, websession)


@pytest.mark.asyncio
async def test_401_refresh_success(
aresponses, devices_list_json, event_loop, login_success_json
):
"""Test that a 401 response re-authenticates successfully."""
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=json.dumps(login_success_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/app/get_devs_list",
"post",
aresponses.Response(text=json.dumps(devices_list_json), status=401),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=json.dumps(login_success_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/app/get_devs_list",
"post",
aresponses.Response(text=json.dumps(devices_list_json), status=200),
)

async with aiohttp.ClientSession(loop=event_loop) as websession:
api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession)
assert len(api.cameras) == 2


@pytest.mark.asyncio
async def test_bad_email(aresponses, event_loop, login_invalid_email_json):
"""Test authenticating with a bad email."""
Expand Down Expand Up @@ -73,18 +137,40 @@ async def test_empty_response(


@pytest.mark.asyncio
async def test_http_error(aresponses, event_loop, login_success_json):
"""Test the Eufy Security web API returning a non-2xx HTTP error code."""
async def test_expired_access_token(
aresponses, devices_list_json, event_loop, login_success_json
):
"""Test that an expired access token refreshes automatically and correctly."""
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=None, status=500),
aresponses.Response(text=json.dumps(login_success_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/app/get_devs_list",
"post",
aresponses.Response(text=json.dumps(devices_list_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=json.dumps(login_success_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/app/get_devs_list",
"post",
aresponses.Response(text=json.dumps(devices_list_json), status=200),
)

async with aiohttp.ClientSession(loop=event_loop) as websession:
with pytest.raises(RequestError):
await async_login(TEST_EMAIL, TEST_PASSWORD, websession)
api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession)
api._token_expiration = datetime.now() - timedelta(seconds=10)
await api.async_update_device_info()
assert len(api.cameras) == 2


@pytest.mark.asyncio
Expand Down Expand Up @@ -118,49 +204,25 @@ async def test_get_history(


@pytest.mark.asyncio
async def test_login_success(
aresponses, devices_list_json, event_loop, login_success_json
):
"""Test a successful login and API object creation."""
async def test_http_error(aresponses, event_loop, login_success_json):
"""Test the Eufy Security web API returning a non-2xx HTTP error code."""
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=json.dumps(login_success_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/app/get_devs_list",
"post",
aresponses.Response(text=json.dumps(devices_list_json), status=200),
aresponses.Response(text=None, status=500),
)

async with aiohttp.ClientSession(loop=event_loop) as websession:
api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession)
assert api._email == TEST_EMAIL
assert api._password == TEST_PASSWORD
assert api._token is not None
assert api._token_expiration is not None
assert len(api.cameras) == 2
with pytest.raises(RequestError):
await async_login(TEST_EMAIL, TEST_PASSWORD, websession)


@pytest.mark.asyncio
async def test_refreshing_access_token(
async def test_login_success(
aresponses, devices_list_json, event_loop, login_success_json
):
"""Test that an expired access token refreshes automatically and correctly."""
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
"post",
aresponses.Response(text=json.dumps(login_success_json), status=200),
)
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/app/get_devs_list",
"post",
aresponses.Response(text=json.dumps(devices_list_json), status=200),
)
"""Test a successful login and API object creation."""
aresponses.add(
"mysecurity.eufylife.com",
"/api/v1/passport/login",
Expand All @@ -176,6 +238,8 @@ async def test_refreshing_access_token(

async with aiohttp.ClientSession(loop=event_loop) as websession:
api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession)
api._token_expiration = datetime.now() - timedelta(seconds=10)
await api.async_update_device_info()
assert api._email == TEST_EMAIL
assert api._password == TEST_PASSWORD
assert api._token is not None
assert api._token_expiration is not None
assert len(api.cameras) == 2

0 comments on commit 4dd13af

Please sign in to comment.