Treat smartcam 500 errors after handshake as retryable (#1395)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run

`smartcam` devices can respond with 500 if another session is created from the same host
This commit is contained in:
Steven B. 2024-12-20 19:23:18 +00:00 committed by GitHub
parent 93ca3ad2e1
commit 8418ba3eef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 107 additions and 8 deletions

View File

@ -113,10 +113,23 @@ class HttpClient:
ssl=ssl, ssl=ssl,
) )
async with resp: async with resp:
if resp.status == 200: response_data = await resp.read()
response_data = await resp.read()
if return_json: if resp.status == 200:
if return_json:
response_data = json_loads(response_data.decode())
else:
_LOGGER.debug(
"Device %s received status code %s with response %s",
self._config.host,
resp.status,
str(response_data),
)
if response_data and return_json:
try:
response_data = json_loads(response_data.decode()) response_data = json_loads(response_data.decode())
except Exception:
_LOGGER.debug("Device %s response could not be parsed as json")
except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
if not self._wait_between_requests: if not self._wait_between_requests:

View File

@ -8,6 +8,7 @@ import hashlib
import logging import logging
import secrets import secrets
import ssl import ssl
from contextlib import suppress
from enum import Enum, auto from enum import Enum, auto
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@ -229,6 +230,31 @@ class SslAesTransport(BaseTransport):
ssl=await self._get_ssl_context(), ssl=await self._get_ssl_context(),
) )
if TYPE_CHECKING:
assert self._encryption_session is not None
# Devices can respond with 500 if another session is created from
# the same host. Decryption may not succeed after that
if status_code == 500:
msg = (
f"Device {self._host} replied with status 500 after handshake, "
f"response: "
)
decrypted = None
if isinstance(resp_dict, dict) and (
response := resp_dict.get("result", {}).get("response")
):
with suppress(Exception):
decrypted = self._encryption_session.decrypt(response.encode())
if decrypted:
msg += decrypted
else:
msg += str(resp_dict)
_LOGGER.debug(msg)
raise _RetryableError(msg)
if status_code != 200: if status_code != 200:
raise KasaException( raise KasaException(
f"{self._host} responded with an unexpected " f"{self._host} responded with an unexpected "
@ -241,7 +267,6 @@ class SslAesTransport(BaseTransport):
if TYPE_CHECKING: if TYPE_CHECKING:
resp_dict = cast(dict[str, Any], resp_dict) resp_dict = cast(dict[str, Any], resp_dict)
assert self._encryption_session is not None
if "result" in resp_dict and "response" in resp_dict["result"]: if "result" in resp_dict and "response" in resp_dict["result"]:
raw_response: str = resp_dict["result"]["response"] raw_response: str = resp_dict["result"]["response"]

View File

@ -18,6 +18,7 @@ from kasa.exceptions import (
DeviceError, DeviceError,
KasaException, KasaException,
SmartErrorCode, SmartErrorCode,
_RetryableError,
) )
from kasa.httpclient import HttpClient from kasa.httpclient import HttpClient
from kasa.transports.aestransport import AesEncyptionSession from kasa.transports.aestransport import AesEncyptionSession
@ -217,6 +218,48 @@ async def test_device_blocked_response(mocker):
await transport.perform_handshake() await transport.perform_handshake()
@pytest.mark.parametrize(
("response", "expected_msg"),
[
pytest.param(
{"error_code": -1, "msg": "Check tapo tag failed"},
'{"error_code": -1, "msg": "Check tapo tag failed"}',
id="can-decrypt",
),
pytest.param(
b"12345678",
str({"result": {"response": "12345678"}, "error_code": 0}),
id="cannot-decrypt",
),
],
)
async def test_device_500_error(mocker, response, expected_msg):
"""Test 500 error raises retryable exception."""
host = "127.0.0.1"
mock_ssl_aes_device = MockSslAesDevice(host)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
transport = SslAesTransport(
config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
)
request = {
"method": "getDeviceInfo",
"params": None,
}
await transport.perform_handshake()
mock_ssl_aes_device.put_next_response(response)
mock_ssl_aes_device.status_code = 500
msg = f"Device 127.0.0.1 replied with status 500 after handshake, response: {expected_msg}"
with pytest.raises(_RetryableError, match=msg):
await transport.send(json_dumps(request))
async def test_port_override(): async def test_port_override():
"""Test that port override sets the app_url.""" """Test that port override sets the app_url."""
host = "127.0.0.1" host = "127.0.0.1"
@ -302,6 +345,8 @@ class MockSslAesDevice:
self.digest_password_fail = digest_password_fail self.digest_password_fail = digest_password_fail
self.device_blocked = device_blocked self.device_blocked = device_blocked
self._next_responses: list[dict | bytes] = []
async def post(self, url: URL, params=None, json=None, data=None, *_, **__): async def post(self, url: URL, params=None, json=None, data=None, *_, **__):
if data: if data:
json = json_loads(data) json = json_loads(data)
@ -386,11 +431,24 @@ class MockSslAesDevice:
assert self.encryption_session assert self.encryption_session
decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) decrypted_request = self.encryption_session.decrypt(encrypted_request.encode())
decrypted_request_dict = json_loads(decrypted_request) decrypted_request_dict = json_loads(decrypted_request)
decrypted_response = await self._post(url, decrypted_request_dict)
async with decrypted_response:
decrypted_response_data = await decrypted_response.read()
encrypted_response = self.encryption_session.encrypt(decrypted_response_data) if self._next_responses:
next_response = self._next_responses.pop(0)
if isinstance(next_response, dict):
decrypted_response_data = json_dumps(next_response).encode()
encrypted_response = self.encryption_session.encrypt(
decrypted_response_data
)
else:
encrypted_response = next_response
else:
decrypted_response = await self._post(url, decrypted_request_dict)
async with decrypted_response:
decrypted_response_data = await decrypted_response.read()
encrypted_response = self.encryption_session.encrypt(
decrypted_response_data
)
response = ( response = (
decrypted_response_data decrypted_response_data
if self.do_not_encrypt_response if self.do_not_encrypt_response
@ -405,3 +463,6 @@ class MockSslAesDevice:
async def _return_send_response(self, url: URL, json: dict[str, Any]): async def _return_send_response(self, url: URL, json: dict[str, Any]):
result = {"result": {"method": None}, "error_code": self.send_error_code} result = {"result": {"method": None}, "error_code": self.send_error_code}
return self._mock_response(self.status_code, result) return self._mock_response(self.status_code, result)
def put_next_response(self, request: dict | bytes) -> None:
self._next_responses.append(request)