Skip to content
118 changes: 118 additions & 0 deletions UnleashClient/_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
HTTP layer under the Unleash API client: sends one request and returns the
response.
"""

from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Mapping, Optional, Protocol, runtime_checkable

import requests
from requests.adapters import HTTPAdapter
from urllib3 import Retry

from UnleashClient.constants import REQUEST_TIMEOUT


class Method(str, Enum):
GET = "GET"
POST = "POST"

__str__ = str.__str__


@dataclass(frozen=True, kw_only=True)
class Request:
"""
HTTP request.

:param method: HTTP method.
:param url: Absolute url, without the query string.
:param headers: Headers to send.
:param params: Query string parameters.
:param body: Request body, or None to send none.
"""

method: Method
url: str
headers: Mapping[str, str]
params: Mapping[str, str] = field(default_factory=dict)
body: Optional[bytes] = None


@dataclass(frozen=True, kw_only=True)
class Response:
"""
HTTP response. Every status is a response, not an error: deciding what
a status means is the API client's job.

:param status: HTTP status code.
:param headers: Response headers, looked up case-insensitively.
:param body: Raw response body.
"""

status: int
headers: Mapping[str, str]
body: bytes
Comment thread
alexanto marked this conversation as resolved.
Resolved


@runtime_checkable
class Transport(Protocol):
"""Sends requests for the sync client."""

def execute(self, request: Request) -> Response:
"""
Implementations must send ``request`` once and return the response,
regardless of its status code.

Implementations must not raise when a response was received, even for
an error status. They raise only if no response was received, for
example due to a connection error or timeout.
"""
Comment thread
alexanto marked this conversation as resolved.
Resolved


class RequestsTransport:
"""
Sends requests with ``requests``, on a fresh session for every request.

:param timeout: Seconds to wait for the connection and for each read.
:param _retries: GET requests are retried this many times on a connection
error or a 500, 502 or 504, by urllib3.
:param _request_kwargs: extra keyword arguments for every ``requests`` call.
"""

def __init__(
self,
*,
timeout: float = REQUEST_TIMEOUT,
# these two are are going away too
_retries: Optional[int] = None,
_request_kwargs: Optional[Mapping[str, Any]] = None,
) -> None:
self._timeout = timeout
self._retries = _retries
self._request_kwargs = dict(_request_kwargs or {})

def execute(self, request: Request) -> Response:
kwargs: dict = {"headers": dict(request.headers), "timeout": self._timeout}
if request.params:
kwargs["params"] = dict(request.params)
if request.body is not None:
kwargs["data"] = request.body

with requests.Session() as session:
if request.method == Method.GET and self._retries is not None:
adapter = HTTPAdapter(
max_retries=Retry(
total=self._retries, status_forcelist=[500, 502, 504]
Comment thread
alexanto marked this conversation as resolved.
Resolved
)
)
session.mount("https://", adapter)
session.mount("http://", adapter)

resp = session.request(
request.method, request.url, **kwargs, **self._request_kwargs
)
return Response(
status=resp.status_code, headers=resp.headers, body=resp.content
)
96 changes: 56 additions & 40 deletions UnleashClient/_unleash_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
from typing import Any, Dict, NamedTuple, Optional, Tuple

import requests
from requests import Response
from requests.adapters import HTTPAdapter
from requests.exceptions import InvalidHeader, InvalidSchema, InvalidURL, MissingSchema
from urllib3 import Retry

from UnleashClient._event_dispatcher import _EventDispatcher
from UnleashClient._headers import _HeaderFactory
from UnleashClient._transport import (
Method,
Request,
RequestsTransport,
Response,
Transport,
)
from UnleashClient.config import PollingOptions, UnleashConfig
from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL
from UnleashClient.events import (
Expand Down Expand Up @@ -78,31 +82,47 @@ def _normalized_url(url: str, path: str) -> str:
return f"{url}{path}"


def _text(resp: Response) -> str:
return resp.body.decode("utf-8", errors="replace")
Comment thread
alexanto marked this conversation as resolved.
Resolved


def _log_resp_info(resp: Response) -> None:
LOGGER.debug("HTTP status code: %s", resp.status_code)
LOGGER.debug("HTTP status code: %s", resp.status)
LOGGER.debug("HTTP headers: %s", resp.headers)
LOGGER.debug("HTTP content: %s", resp.text)
LOGGER.debug("HTTP content: %s", _text(resp))


class _UnleashApiClient:
"""
Sends the SDK's requests to the Unleash server: feature fetches, client
registration and metrics submission.
Talks to the Unleash API: feature fetches, client registration and metrics
submission. Builds each request and decides what its response means; the
transport only sends it.

:param config: Client configuration.
:param headers: Builds the header set each endpoint needs.
:param events: Receives ``FETCH_COMPLETED`` after every feature fetch.
:param transport: Sends the requests. Defaults to a :class:`RequestsTransport`
built from ``config.http``.
"""

def __init__(
self,
config: UnleashConfig,
headers: _HeaderFactory,
events: Optional[_EventDispatcher] = None,
transport: Optional[Transport] = None,
) -> None:
self._config: UnleashConfig = config
self._headers: _HeaderFactory = headers
self._events: Optional[_EventDispatcher] = events

self._transport: Transport = transport or RequestsTransport(
timeout=config.http.timeout,
_retries=config.http.retries,
# this is going to go away soon, just like the whole httpOptions in the client constructor
_request_kwargs=config.http.options,
)

# pylint: disable=broad-except
def fetch_features(self, etag: str = "", *, options: PollingOptions) -> FetchResult:
"""
Expand Down Expand Up @@ -146,31 +166,24 @@ def _fetch_features(
if options.project_name:
base_params = {"project": options.project_name}

adapter = HTTPAdapter(
max_retries=Retry(
total=config.http.retries, status_forcelist=[500, 502, 504]
)
)
with requests.Session() as session:
session.mount("https://", adapter)
session.mount("http://", adapter)
resp = session.get(
base_url,
resp = self._transport.execute(
Request(
method=Method.GET,
url=base_url,
headers={
**self._headers.polling(options),
**request_specific_headers,
},
params=base_params,
timeout=config.http.timeout,
**config.http.options,
)
)

http_status_code = resp.status_code
if resp.status_code not in [200, 304]:
http_status_code = resp.status
if resp.status not in [200, 304]:
_log_resp_info(resp)
LOGGER.warning(
"Unleash Client feature fetch failed due to unexpected HTTP status code: %s",
resp.status_code,
resp.status,
)
raise Exception(
"Unleash Client feature fetch failed!"
Expand All @@ -180,13 +193,14 @@ def _fetch_features(
if "etag" in resp.headers.keys():
fetched_etag = resp.headers["etag"]

if resp.status_code == 304:
if resp.status == 304:
return (
FetchResult(None, fetched_etag, not_modified=True),
FetchStatus.NOT_CHANGED,
)

return FetchResult(resp.text, fetched_etag), _fetched_status(resp.text)
raw_state = _text(resp)
return FetchResult(raw_state, fetched_etag), _fetched_status(raw_state)
except Exception as exc:
LOGGER.exception(
"Unleash Client feature fetch failed due to exception: %s", exc
Expand All @@ -212,19 +226,20 @@ def register(self, payload: Dict[str, Any]) -> bool:
LOGGER.info("Registering unleash client with unleash @ %s", config.url)
LOGGER.info("Registration request information: %s", payload)

resp = requests.post(
_normalized_url(config.url, REGISTER_URL),
data=json.dumps(payload),
headers=self._headers.base(),
timeout=config.http.timeout,
**config.http.options,
resp = self._transport.execute(
Request(
method=Method.POST,
url=_normalized_url(config.url, REGISTER_URL),
headers=self._headers.base(),
body=json.dumps(payload).encode("utf-8"),
)
)

if resp.status_code not in {200, 202}:
if resp.status not in {200, 202}:
_log_resp_info(resp)
LOGGER.warning(
"Unleash Client registration failed due to unexpected HTTP status code: %s",
resp.status_code,
resp.status,
)
return False

Expand Down Expand Up @@ -255,19 +270,20 @@ def send_metrics(self, payload: Dict[str, Any]) -> bool:
LOGGER.info("Sending messages to with unleash @ %s", config.url)
LOGGER.info("unleash metrics information: %s", payload)

resp = requests.post(
_normalized_url(config.url, METRICS_URL),
data=json.dumps(payload),
headers=self._headers.metrics(),
timeout=config.http.timeout,
**config.http.options,
resp = self._transport.execute(
Comment thread
alexanto marked this conversation as resolved.
Resolved
Request(
method=Method.POST,
url=_normalized_url(config.url, METRICS_URL),
headers=self._headers.metrics(),
body=json.dumps(payload).encode("utf-8"),
)
)

if resp.status_code != 202:
if resp.status != 202:
_log_resp_info(resp)
LOGGER.warning(
"Unleash Client metrics submission due to unexpected HTTP status code: %s",
resp.status_code,
resp.status,
)
return False

Expand Down
Loading
Loading