From 3b8b36cf41fc183acbe43ecb76f612d672d98aa2 Mon Sep 17 00:00:00 2001 From: "Mitch Harding (the weird one)" Date: Tue, 17 Dec 2024 12:24:57 -0500 Subject: [PATCH] CASMCMS-9225: Create generic API client class --- constraints.txt.in | 2 +- requirements.txt | 2 +- src/bos/common/clients/__init__.py | 0 src/bos/common/clients/api_client.py | 62 ++++++++++++++++++++++++++++ src/bos/common/utils.py | 34 +++++++++++---- 5 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 src/bos/common/clients/__init__.py create mode 100644 src/bos/common/clients/api_client.py diff --git a/constraints.txt.in b/constraints.txt.in index f7434180..295b5cf5 100644 --- a/constraints.txt.in +++ b/constraints.txt.in @@ -37,7 +37,7 @@ PyYAML>=6.0.1,<6.1 redis>=5.0,<5.1 requests>=2.28.2,<2.29 requests-oauthlib>=1.3.1,<1.4 -requests-retry-session>=0.1,<0.2 +requests-retry-session>=2.0,<2.1 retrying>=1.3.4,<1.4 rsa>=4.9,<4.10 s3transfer>=0.6.2,<0.7 diff --git a/requirements.txt b/requirements.txt index e4118e7a..f46304f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ python-dateutil PyYAML redis requests -requests-retry-session +requests-retry-session>=2.0 urllib3 # The purpose of this file is to contain python runtime requirements diff --git a/src/bos/common/clients/__init__.py b/src/bos/common/clients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/bos/common/clients/api_client.py b/src/bos/common/clients/api_client.py new file mode 100644 index 00000000..029adb08 --- /dev/null +++ b/src/bos/common/clients/api_client.py @@ -0,0 +1,62 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +from abc import ABC +from typing import Type, TypeVar + +from bos.common.clients.endpoints import BaseGenericEndpoint +from bos.common.utils import RetrySessionManager + +ClientEndpoint = TypeVar('ClientEndpoint', bound=BaseGenericEndpoint) + + +class APIClient(RetrySessionManager, ABC): + """ + As a subclass of RetrySessionManager, this class can be used as a context manager, + and will have a requests session available as self.requests_session + + This context manager is used to provide API endpoints, via subclassing. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._endpoint_values: dict[Type[ClientEndpoint], ClientEndpoint] = {} + + def get_endpoint(self, + endpoint_type: Type[ClientEndpoint]) -> ClientEndpoint: + """ + Endpoints are created only as needed, and passed the managed retry session. + """ + if endpoint_type not in self._endpoint_values: + self._endpoint_values[endpoint_type] = endpoint_type( + self.requests_session) + return self._endpoint_values[endpoint_type] + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool | None: + """ + The only cleanup we need to do when exiting the context manager is to clear out + our list of API clients. Our call to super().__exit__ will take care of closing + out the underlying request session. + """ + self._endpoint_values.clear() + return super().__exit__(exc_type, exc_val, exc_tb) diff --git a/src/bos/common/utils.py b/src/bos/common/utils.py index efd813a2..25e1a4a3 100644 --- a/src/bos/common/utils.py +++ b/src/bos/common/utils.py @@ -31,7 +31,7 @@ # Third party imports from dateutil.parser import parse -from requests_retry_session import requests_retry_session as base_requests_retry_session +import requests_retry_session as rrs PROTOCOL = 'http' TIME_DURATION_PATTERN = re.compile(r"^(\d+?)(\D+?)$", re.M | re.S) @@ -68,14 +68,32 @@ def duration_to_timedelta(timestamp: str): return datetime.timedelta(seconds=seconds) -requests_retry_session = partial(base_requests_retry_session, - retries=10, - backoff_factor=0.5, - status_forcelist=(500, 502, 503, 504), - connect_timeout=3, - read_timeout=10, +DEFAULT_RETRY_ADAPTER_ARGS = rrs.RequestsRetryAdapterArgs( + retries=10, + backoff_factor=0.5, + status_forcelist=(500, 502, 503, 504), + connect_timeout=3, + read_timeout=10) + + +class RetrySessionManager(rrs.RetrySessionManager): + """ + Just sets the default values we use for our requests sessions + """ + + def __init__(self, + protocol: str = PROTOCOL, + **adapter_kwargs: Unpack[rrs.RequestsRetryAdapterArgs]): + for key, value in DEFAULT_RETRY_ADAPTER_ARGS.items(): + if key not in adapter_kwargs: + adapter_kwargs[key] = value + super().__init__(protocol=protocol, **adapter_kwargs) + + +requests_retry_session = partial(rrs.requests_retry_session, session=None, - protocol=PROTOCOL) + protocol=PROTOCOL, + **DEFAULT_RETRY_ADAPTER_ARGS) def compact_response_text(response_text: str) -> str: