Source code for craft_store.http_client

# -*- 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