From 5d78fc9e02accd7f18eb060ad1d2b1e8e607ba05 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 3 Apr 2025 12:51:14 +0200 Subject: [PATCH] added handling for API connectivity errors --- pyproject.toml | 4 +-- src/delta_barth/api/requests.py | 22 +++++++++---- src/delta_barth/constants.py | 2 ++ src/delta_barth/errors.py | 16 +++++++-- src/delta_barth/session.py | 57 ++++++++++++++++++++++----------- src/delta_barth/types.py | 2 ++ tests/api/test_requests.py | 30 +++++++++++++++++ tests/conftest.py | 2 +- 8 files changed, 103 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7ce9cbd..1bae764 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "delta-barth" -version = "0.5.3" +version = "0.5.4" description = "workflows and pipelines for the Python-based Plugin of Delta Barth's ERP system" authors = [ {name = "Florian Förster", email = "f.foerster@d-opt.com"}, @@ -73,7 +73,7 @@ directory = "reports/coverage" [tool.bumpversion] -current_version = "0.5.3" +current_version = "0.5.4" parse = """(?x) (?P0|[1-9]\\d*)\\. (?P0|[1-9]\\d*)\\. diff --git a/src/delta_barth/api/requests.py b/src/delta_barth/api/requests.py index 18fdd4f..eca070a 100644 --- a/src/delta_barth/api/requests.py +++ b/src/delta_barth/api/requests.py @@ -7,6 +7,7 @@ import requests from dopt_basics.io import combine_route from pydantic import BaseModel, PositiveInt, SkipValidation +from delta_barth.constants import API_CON_TIMEOUT from delta_barth.errors import STATUS_HANDLER from delta_barth.types import DelBarApiError, ExportResponse, ResponseType, Status @@ -55,7 +56,7 @@ def get_sales_prognosis_data( company_id: int | None = None, start_date: Datetime | None = None, ) -> tuple[SalesPrognosisResponse, Status]: - resp, status = session.assert_login() + _, status = session.assert_login() if status != STATUS_HANDLER.SUCCESS: response = SalesPrognosisResponse(daten=tuple()) return response, status @@ -67,11 +68,18 @@ def get_sales_prognosis_data( FirmaId=company_id, BuchungsDatum=start_date, ) - resp = requests.get( - URL, - params=sales_prog_req.model_dump(mode="json", exclude_none=True), - headers=session.headers, # type: ignore[argumentType] - ) + empty_response = SalesPrognosisResponse(daten=tuple()) + try: + resp = requests.get( + URL, + params=sales_prog_req.model_dump(mode="json", exclude_none=True), + headers=session.headers, # type: ignore[argumentType] + timeout=API_CON_TIMEOUT, + ) + except requests.exceptions.Timeout: + return empty_response, STATUS_HANDLER.pipe_states.CONNECTION_TIMEOUT + except requests.exceptions.RequestException: + return empty_response, STATUS_HANDLER.pipe_states.CONNECTION_ERROR response: SalesPrognosisResponse status: Status @@ -79,7 +87,7 @@ def get_sales_prognosis_data( response = SalesPrognosisResponse(**resp.json()) status = STATUS_HANDLER.SUCCESS else: - response = SalesPrognosisResponse(daten=tuple()) + response = empty_response err = DelBarApiError(status_code=resp.status_code, **resp.json()) status = STATUS_HANDLER.api_error(err) diff --git a/src/delta_barth/constants.py b/src/delta_barth/constants.py index b2a49cd..412007a 100644 --- a/src/delta_barth/constants.py +++ b/src/delta_barth/constants.py @@ -38,6 +38,8 @@ class KnownDelBarApiErrorCodes(enum.Enum): COMMON = frozenset((400, 401, 409, 500)) +# ** API +API_CON_TIMEOUT: Final[float] = 1.0 # secs to response # ** API response parsing # ** column mapping [API-Response --> Target-Features] COL_MAP_SALES_PROGNOSIS: Final[DualDict[str, str]] = DualDict( diff --git a/src/delta_barth/errors.py b/src/delta_barth/errors.py index ee45b4e..abe8db9 100644 --- a/src/delta_barth/errors.py +++ b/src/delta_barth/errors.py @@ -53,9 +53,19 @@ class UApiError(Exception): ## ** internal error handling DATA_PIPELINE_STATUS_DESCR: Final[tuple[StatusDescription, ...]] = ( ("SUCCESS", 0, "Erfolg"), - ("TOO_FEW_POINTS", 1, "Datensatz besitzt nicht genügend Datenpunkte"), - ("TOO_FEW_MONTH_POINTS", 2, "nach Aggregation pro Monat nicht genügend Datenpunkte"), - ("NO_RELIABLE_FORECAST", 3, "Prognosequalität des Modells unzureichend"), + ( + "CONNECTION_TIMEOUT", + 1, + "Der Verbindungsaufbau zum API-Server dauerte zu lange. Ist der Server erreichbar?", + ), + ( + "CONNECTION_ERROR", + 2, + "Es ist keine Verbindung zum API-Server möglich. Ist der Server erreichbar?", + ), + ("TOO_FEW_POINTS", 3, "Datensatz besitzt nicht genügend Datenpunkte"), + ("TOO_FEW_MONTH_POINTS", 4, "nach Aggregation pro Monat nicht genügend Datenpunkte"), + ("NO_RELIABLE_FORECAST", 5, "Prognosequalität des Modells unzureichend"), ) diff --git a/src/delta_barth/session.py b/src/delta_barth/session.py index b240e36..39d03f1 100644 --- a/src/delta_barth/session.py +++ b/src/delta_barth/session.py @@ -14,7 +14,7 @@ from delta_barth.api.common import ( LoginResponse, validate_credentials, ) -from delta_barth.constants import DB_ECHO +from delta_barth.constants import API_CON_TIMEOUT, DB_ECHO from delta_barth.errors import STATUS_HANDLER from delta_barth.logging import logger_session as logger from delta_barth.types import DelBarApiError, Status @@ -191,11 +191,18 @@ class Session: databaseName=self.creds.database, mandantName=self.creds.mandant, ) - resp = requests.put( - URL, - login_req.model_dump_json(), - headers=self.headers, # type: ignore - ) + empty_response = LoginResponse(token="") + try: + resp = requests.put( + URL, + login_req.model_dump_json(), + headers=self.headers, # type: ignore + timeout=API_CON_TIMEOUT, + ) + except requests.exceptions.Timeout: # pragma: no cover + return empty_response, STATUS_HANDLER.pipe_states.CONNECTION_TIMEOUT + except requests.exceptions.RequestException: # pragma: no cover + return empty_response, STATUS_HANDLER.pipe_states.CONNECTION_ERROR response: LoginResponse status: Status @@ -204,7 +211,7 @@ class Session: status = STATUS_HANDLER.pipe_states.SUCCESS self._add_session_token(response.token) else: - response = LoginResponse(token="") + response = empty_response err = DelBarApiError(status_code=resp.status_code, **resp.json()) status = STATUS_HANDLER.api_error(err) @@ -216,12 +223,17 @@ class Session: ROUTE: Final[str] = "user/logout" URL: Final = combine_route(self.base_url, ROUTE) - resp = requests.put( - URL, - headers=self.headers, # type: ignore - ) + try: + resp = requests.put( + URL, + headers=self.headers, # type: ignore + timeout=API_CON_TIMEOUT, + ) + except requests.exceptions.Timeout: # pragma: no cover + return None, STATUS_HANDLER.pipe_states.CONNECTION_TIMEOUT + except requests.exceptions.RequestException: # pragma: no cover + return None, STATUS_HANDLER.pipe_states.CONNECTION_ERROR - response = None status: Status if resp.status_code == 200: status = STATUS_HANDLER.SUCCESS @@ -230,7 +242,7 @@ class Session: err = DelBarApiError(status_code=resp.status_code, **resp.json()) status = STATUS_HANDLER.api_error(err) - return response, status + return None, status def assert_login( self, @@ -246,11 +258,18 @@ class Session: ROUTE: Final[str] = "verkauf/umsatzprognosedaten" URL: Final = combine_route(self.base_url, ROUTE) params: dict[str, int] = {"FirmaId": 999999} - resp = requests.get( - URL, - params=params, - headers=self.headers, # type: ignore - ) + empty_response = LoginResponse(token="") + try: + resp = requests.get( + URL, + params=params, + headers=self.headers, # type: ignore + timeout=API_CON_TIMEOUT, + ) + except requests.exceptions.Timeout: # pragma: no cover + return empty_response, STATUS_HANDLER.pipe_states.CONNECTION_TIMEOUT + except requests.exceptions.RequestException: # pragma: no cover + return empty_response, STATUS_HANDLER.pipe_states.CONNECTION_ERROR response: LoginResponse status: Status @@ -261,7 +280,7 @@ class Session: self._remove_session_token() response, status = self.login() else: - response = LoginResponse(token="") + response = empty_response err = DelBarApiError(status_code=resp.status_code, **resp.json()) status = STATUS_HANDLER.api_error(err) diff --git a/src/delta_barth/types.py b/src/delta_barth/types.py index c65506c..0e3e11d 100644 --- a/src/delta_barth/types.py +++ b/src/delta_barth/types.py @@ -47,6 +47,8 @@ class ExportResponse(BaseModel): @dataclass(slots=True) class DataPipeStates: SUCCESS: Status + CONNECTION_TIMEOUT: Status + CONNECTION_ERROR: Status TOO_FEW_POINTS: Status TOO_FEW_MONTH_POINTS: Status NO_RELIABLE_FORECAST: Status diff --git a/tests/api/test_requests.py b/tests/api/test_requests.py index 133d221..14ba14c 100644 --- a/tests/api/test_requests.py +++ b/tests/api/test_requests.py @@ -1,8 +1,10 @@ from datetime import datetime as Datetime import pytest +import requests from delta_barth.api import requests as requests_ +from delta_barth.api.common import LoginResponse @pytest.mark.api_con_required @@ -94,3 +96,31 @@ def test_get_sales_prognosis_data_FailApiServer(session, mock_get): assert status.api_server_error.message == json["message"] assert status.api_server_error.code == json["code"] assert status.api_server_error.hints == json["hints"] + + +def test_get_sales_prognosis_data_FailGetTimeout(session, mock_get): + mock_get.side_effect = requests.exceptions.Timeout("Test timeout") + + def assert_login(): + return LoginResponse(token=""), requests_.STATUS_HANDLER.SUCCESS + + session.assert_login = assert_login + + resp, status = requests_.get_sales_prognosis_data(session, None, None) + assert resp is not None + assert len(resp.daten) == 0 + assert status.code == 1 + + +def test_get_sales_prognosis_data_FailGetRequestException(session, mock_get): + mock_get.side_effect = requests.exceptions.RequestException("Test not timeout") + + def assert_login(): + return LoginResponse(token=""), requests_.STATUS_HANDLER.SUCCESS + + session.assert_login = assert_login + + resp, status = requests_.get_sales_prognosis_data(session, None, None) + assert resp is not None + assert len(resp.daten) == 0 + assert status.code == 2 diff --git a/tests/conftest.py b/tests/conftest.py index f6d50b0..52878e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,7 +95,7 @@ def mock_put(): yield mock -@pytest.fixture +@pytest.fixture(scope="function") def mock_get(): with patch("requests.get") as mock: yield mock -- 2.34.1