From 379843eb54be1f26382ac2e98a76a36cfd3810e5 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Wed, 31 Mar 2021 12:46:10 +0800 Subject: [PATCH] Shield async httpx call in generic (#47852) * Shield async httpx call * Don't set last_url/last_image on cancellation * Add test --- homeassistant/components/generic/camera.py | 31 +++++++++---- tests/components/generic/test_camera.py | 52 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 56b490e165a..1ec7f0874e0 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -125,32 +125,45 @@ class GenericCamera(Camera): ).result() async def async_camera_image(self): + """Wrap _async_camera_image with an asyncio.shield.""" + # Shield the request because of https://github.com/encode/httpx/issues/1461 + try: + self._last_url, self._last_image = await asyncio.shield( + self._async_camera_image() + ) + except asyncio.CancelledError as err: + _LOGGER.warning("Timeout getting camera image from %s", self._name) + raise err + return self._last_image + + async def _async_camera_image(self): """Return a still image response from the camera.""" try: url = self._still_image_url.async_render(parse_result=False) except TemplateError as err: _LOGGER.error("Error parsing template %s: %s", self._still_image_url, err) - return self._last_image + return self._last_url, self._last_image if url == self._last_url and self._limit_refetch: - return self._last_image - + return self._last_url, self._last_image + response = None try: async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) response = await async_client.get( url, auth=self._auth, timeout=GET_IMAGE_TIMEOUT ) response.raise_for_status() - self._last_image = response.content + image = response.content except httpx.TimeoutException: _LOGGER.error("Timeout getting camera image from %s", self._name) - return self._last_image + return self._last_url, self._last_image except (httpx.RequestError, httpx.HTTPStatusError) as err: _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - return self._last_image - - self._last_url = url - return self._last_image + return self._last_url, self._last_image + finally: + if response: + await response.aclose() + return url, image @property def name(self): diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 3e2f07b446b..deb8049da33 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -3,6 +3,7 @@ import asyncio from os import path from unittest.mock import patch +import httpx import respx from homeassistant import config as hass_config @@ -407,5 +408,56 @@ async def test_reloading(hass, hass_client): assert body == "hello world" +@respx.mock +async def test_timeout_cancelled(hass, hass_client): + """Test that timeouts and cancellations return last image.""" + + respx.get("http://example.com").respond(text="hello world") + + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/camera_proxy/camera.config_test") + + assert resp.status == 200 + assert respx.calls.call_count == 1 + assert await resp.text() == "hello world" + + respx.get("http://example.com").respond(text="not hello world") + + with patch( + "homeassistant.components.generic.camera.GenericCamera._async_camera_image", + side_effect=asyncio.CancelledError(), + ): + resp = await client.get("/api/camera_proxy/camera.config_test") + assert respx.calls.call_count == 1 + assert resp.status == 500 + + respx.get("http://example.com").side_effect = [ + httpx.RequestError, + httpx.TimeoutException, + ] + + for total_calls in range(2, 4): + resp = await client.get("/api/camera_proxy/camera.config_test") + assert respx.calls.call_count == total_calls + assert resp.status == 200 + assert await resp.text() == "hello world" + + def _get_fixtures_base_path(): return path.dirname(path.dirname(path.dirname(__file__)))