diff --git a/README.md b/README.md index 3bb042b..f1c68b2 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,22 @@ async def main(): if __name__ == "__main__": asyncio.run(main()) ``` + +## error handling + +By default, failures are returned as `{"success": False, "message": "..."}` +(or `None` from the helpers). Pass `raise_errors=True` to get exceptions instead : + +``` +from pyemoncms import EmoncmsAuthError, EmoncmsClient, EmoncmsConnectionError, EmoncmsError + +client = EmoncmsClient(url, key, raise_errors=True) +try: + feeds = await client.async_list_feeds() +except EmoncmsAuthError: + ... # invalid API key (HTTP 401) +except EmoncmsConnectionError: + ... # timeout or server unreachable +except EmoncmsError: + ... # any other error response +``` diff --git a/pyemoncms/__init__.py b/pyemoncms/__init__.py index e353a6b..5b49e69 100644 --- a/pyemoncms/__init__.py +++ b/pyemoncms/__init__.py @@ -1,2 +1,7 @@ """pyemoncms module""" -from .emoncms_client import EmoncmsClient +from .emoncms_client import ( + EmoncmsAuthError, + EmoncmsClient, + EmoncmsConnectionError, + EmoncmsError, +) diff --git a/pyemoncms/emoncms_client.py b/pyemoncms/emoncms_client.py index b70e3e5..181a84a 100644 --- a/pyemoncms/emoncms_client.py +++ b/pyemoncms/emoncms_client.py @@ -34,6 +34,19 @@ Self = TypeVar("Self", bound="EmoncmsClient") + +class EmoncmsError(Exception): + """Generic emoncms error.""" + + +class EmoncmsAuthError(EmoncmsError): + """Invalid API key (HTTP 401).""" + + +class EmoncmsConnectionError(EmoncmsError): + """Emoncms server unreachable or timed out.""" + + @dataclass class EmoncmsClient: """Emoncms client.""" @@ -43,6 +56,7 @@ class EmoncmsClient: request_timeout: int = 20 session: ClientSession | None = None _close_session: bool = False + raise_errors: bool = False logger = logging.getLogger(__name__) async def async_request( @@ -51,7 +65,7 @@ async def async_request( """Request emoncms server.""" message = f"requesting emoncms on {path}" self.logger.debug(message) - data = {SUCCESS_KEY: False, MESSAGE_KEY: None} + data: dict[str, Any] = {SUCCESS_KEY: False, MESSAGE_KEY: None} if not params: params = {"apikey": self.api_key} if self.session is None: @@ -63,28 +77,46 @@ async def async_request( response = await self.session.get( url, timeout=self.request_timeout, params=params ) + if response.status == 200: + json_response = await response.json() except ClientError as er: message = f"client error : {er}" data[MESSAGE_KEY] = message - self.logger.error(message) + self._log_error(message) + if self.raise_errors: + raise EmoncmsConnectionError(message) from er return data - except asyncio.TimeoutError: + except asyncio.TimeoutError as er: message = "time out error" data[MESSAGE_KEY] = message - self.logger.error(message) + self._log_error(message) + if self.raise_errors: + raise EmoncmsConnectionError(message) from er return data if response.status == 200: data[SUCCESS_KEY] = True - json_response = await response.json() data[MESSAGE_KEY] = json_response else: message = f"error {response.status}" if response.status in HTTP_STATUS: message = f"{message} {HTTP_STATUS[response.status]}" data[MESSAGE_KEY] = message - self.logger.error(message) + self._log_error(message) + self._raise_for_status(response.status, message) return data + def _log_error(self, message: str) -> None: + """Log at debug when raising, the caller decides how to report.""" + self.logger.log(logging.DEBUG if self.raise_errors else logging.ERROR, message) + + def _raise_for_status(self, status: int, message: str) -> None: + """Raise the matching exception when raise_errors is enabled.""" + if not self.raise_errors: + return + if status == 401: + raise EmoncmsAuthError(message) + raise EmoncmsError(message) + async def async_input_post( self, data: Dict[str, Any], @@ -110,11 +142,21 @@ async def async_input_post( if time is not None: params["time"] = str(time) - async with self.session.post(endpoint, params=params) as resp: - text = await resp.text() - if resp.status != 200: - raise RuntimeError(f"POST failed ({resp.status}): {text}") - return True + try: + async with self.session.post( + endpoint, params=params, timeout=self.request_timeout + ) as resp: + text = await resp.text() + status = resp.status + except (ClientError, asyncio.TimeoutError) as er: + if self.raise_errors: + raise EmoncmsConnectionError(f"POST failed : {er!r}") from er + raise + if status != 200: + message = f"POST failed ({status}): {text}" + self._raise_for_status(status, message) + raise RuntimeError(message) + return True async def async_get_uuid(self) -> str | None: """Return the unique identifier or None. diff --git a/setup.py b/setup.py index 7854f0b..1e645e9 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="pyemoncms", - version="0.1.3", + version="0.1.4", author="Alexandre CUER", author_email="alexandre.cuer@wanadoo.fr", description="A python library to interrogate emoncms API", diff --git a/tests/test_async_request.py b/tests/test_async_request.py index 3fba08f..0cd1529 100644 --- a/tests/test_async_request.py +++ b/tests/test_async_request.py @@ -3,11 +3,17 @@ import os import sys +import pytest from aiohttp import web root_path = os.getcwd() sys.path.insert(0, root_path) -from pyemoncms.emoncms_client import EmoncmsClient # pylint: disable=E0401,C0413 +from pyemoncms.emoncms_client import ( # pylint: disable=E0401,C0413 + EmoncmsAuthError, + EmoncmsClient, + EmoncmsConnectionError, + EmoncmsError, +) API_KEY = "123" @@ -37,10 +43,21 @@ async def feed_list_route(request): return web.json_response(FEEDS) +async def input_post_route(request): + """Emoncms input post route.""" + if request.query["apikey"] == "500": + raise web.HTTPInternalServerError(text="boom") + if request.query["apikey"] != API_KEY: + raise web.HTTPUnauthorized(text="Invalid API key") + return web.Response(text="ok") + + def create_feed_api(): """Emoncms server.""" app = web.Application() app.router.add_route("GET", "/feed/list.json", feed_list_route) + app.router.add_route("GET", "/user/getuuid.json", feed_list_route) + app.router.add_route("POST", "/input/post", input_post_route) return app async def test_response_ok(aiohttp_server): @@ -71,3 +88,46 @@ async def test_client_error(): datas = await client.async_request("/feed/list.json") assert not datas["success"] assert "client error" in datas["message"] + +async def test_raise_auth_error(aiohttp_server): + """Test incorrect API key raises with raise_errors.""" + await aiohttp_server(create_feed_api(), port=8080) + client = EmoncmsClient("http://127.0.0.1:8080", "666", raise_errors=True) + with pytest.raises(EmoncmsAuthError): + await client.async_list_feeds() + with pytest.raises(EmoncmsAuthError): + await client.async_get_uuid() + await client.close() + +async def test_raise_timeout(): + """Test timeout raises with raise_errors.""" + client = EmoncmsClient( + "http://192.16.1.32:8080", API_KEY, request_timeout=1, raise_errors=True + ) + with pytest.raises(EmoncmsConnectionError): + await client.async_request("/feed/list.json") + await client.close() + +async def test_raise_client_error(caplog): + """Test refused connection raises with raise_errors, without error logs.""" + client = EmoncmsClient("http://127.0.0.1:8087", API_KEY, raise_errors=True) + with pytest.raises(EmoncmsConnectionError): + await client.async_request("/feed/list.json") + with pytest.raises(EmoncmsConnectionError): + await client.async_input_post({"a": 1}) + await client.close() + assert not [r for r in caplog.records if r.levelname == "ERROR"] + +async def test_input_post(aiohttp_server): + """Test input post success and errors.""" + await aiohttp_server(create_feed_api(), port=8080) + url = "http://127.0.0.1:8080" + async with EmoncmsClient(url, API_KEY, raise_errors=True) as client: + assert await client.async_input_post({"a": 1}) + with pytest.raises(EmoncmsAuthError): + await client.async_input_post({"a": 1}, apikey="666") + with pytest.raises(EmoncmsError): + await client.async_input_post({"a": 1}, apikey="500") + async with EmoncmsClient(url, API_KEY) as client: + with pytest.raises(RuntimeError): + await client.async_input_post({"a": 1}, apikey="666")