# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2021 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Craft Store HTTPClient."""
import logging
import os
import requests
from requests.adapters import HTTPAdapter, Retry
from . import errors
logger = logging.getLogger(__name__)
REQUEST_TOTAL_RETRIES = 8
"""Amount of retries for a request."""
REQUEST_BACKOFF = 1
"""Backoff before retrying a request."""
def _get_retry_value(environment_var: str, default_value: int) -> int:
"""Return the backoff to use in HTTPClient."""
environment_value = os.getenv(environment_var)
if environment_value is None:
return default_value
try:
value = int(environment_value)
except ValueError:
logger.debug(
"%r set to invalid value %r, setting to %r.",
environment_var,
environment_value,
default_value,
)
return default_value
if value < 0:
logger.debug(
"%r set to non positive value %r, setting to %r.",
environment_var,
value,
default_value,
)
return default_value
return value
[docs]
class HTTPClient:
"""Generic HTTP Client to communicate with Canonical's Developer Gateway.
This client has a requests like interface, it creates a requests.Session
on initialization to handle retries over HTTP and HTTPS requests.
The default number of retries is set in :data:`.REQUEST_TOTAL_RETRIES` and can
be overridden with the ``CRAFT_STORE_RETRIES`` environment variable.
The backoff factor has a default set in :data:`.REQUEST_BACKOFF` and can be
overridden with the ``CRAFT_STORE_BACKOFF`` environment variable.
Retries are done for the following return codes: ``500``, ``502``, ``503``
and ``504``.
:ivar user_agent: User-Agent header to identify the client.
"""
def __init__(self, *, user_agent: str) -> None:
"""Initialize an HTTPClient with a given user_agent.
:param user_agent: User-Agent header to identify the client.
"""
self._session = requests.Session()
self.user_agent = user_agent
# Setup max retries for all store URLs and the CDN
retries = Retry(
total=_get_retry_value("CRAFT_STORE_RETRIES", REQUEST_TOTAL_RETRIES),
backoff_factor=_get_retry_value("CRAFT_STORE_BACKOFF", REQUEST_BACKOFF),
status_forcelist=[500, 502, 503, 504],
)
http_adapter = HTTPAdapter(max_retries=retries)
self._session.mount("http://", http_adapter)
self._session.mount("https://", http_adapter)
[docs]
def get(self, *args, **kwargs) -> requests.Response: # type: ignore[no-untyped-def]
"""Perform an HTTP GET request."""
return self.request("GET", *args, **kwargs)
[docs]
def post(self, *args, **kwargs) -> requests.Response: # type: ignore[no-untyped-def]
"""Perform an HTTP POST request."""
return self.request("POST", *args, **kwargs)
[docs]
def put(self, *args, **kwargs) -> requests.Response: # type: ignore[no-untyped-def]
"""Perform an HTTP PUT request."""
return self.request("PUT", *args, **kwargs)
[docs]
def request( # type: ignore[no-untyped-def]
self,
method: str,
url: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
**kwargs,
) -> requests.Response:
"""Send a request to url.
:attr:`.user_agent` is set as part of the headers for the request.
All requests are logged through a debug logs, headers matching
Authorization and Macaroons have their value replaced.
:param method: HTTP method used for the request.
:param url: URL to request with method.
:param params: Query parameters to be sent along with the request.
:param headers: Headers to be sent along with the request.
:raises errors.StoreServerError: for error responses.
:raises errors.NetworkError: for lower level network issues.
:return: Response from the request.
"""
if headers:
headers["User-Agent"] = self.user_agent
else:
headers = {"User-Agent": self.user_agent}
debug_headers = headers.copy()
if debug_headers.get("Authorization"):
debug_headers["Authorization"] = "<macaroon>"
if debug_headers.get("Macaroons"):
debug_headers["Macaroons"] = "<macaroon>"
logger.debug(
"HTTP %r for %r with params %r and headers %r",
method,
url,
params,
debug_headers,
)
try:
response = self._session.request(
method, url, headers=headers, params=params, **kwargs
)
except (
requests.exceptions.ConnectionError,
requests.exceptions.RetryError,
) as error:
raise errors.NetworkError(error) from error
if not response.ok:
raise errors.StoreServerError(response)
return response