Skip to content
This repository has been archived by the owner on Sep 12, 2023. It is now read-only.

Commit

Permalink
feat!: new http client pattern. (#200)
Browse files Browse the repository at this point in the history
  • Loading branch information
peterschutt authored Jan 2, 2023
1 parent 4b91404 commit 5538f83
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 58 deletions.
17 changes: 16 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ sentry-sdk = "*"
sqlalchemy = "==2.0.0b4"
starlite = "^1.40.1"
structlog = ">=22.2.0"
tenacity = "*"
uvicorn = "*"
uvloop = "*"

Expand Down
185 changes: 142 additions & 43 deletions src/starlite_saqlalchemy/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,80 +4,179 @@
from typing import Any

import httpx
import tenacity

from . import settings

clients = set[httpx.AsyncClient]()
"""For bookkeeping of clients.
We close them on app shutdown.
"""


class ClientException(Exception):
"""Base client exception."""


class Client:
"""Base class for HTTP clients.
"""A simple HTTP client class with retrying and exponential backoff.
```python
client = Client()
response = await client.request("GET", "/some/resource")
assert response.status_code == 200
```
This class uses the `tenacity` library to retry failed HTTP httpx
with exponential backoff and jitter. It also uses a `httpx.Session`
instance to manage HTTP connections and cookies.
"""

_client = httpx.AsyncClient()

async def request(self, *args: Any, **kwargs: Any) -> httpx.Response:
"""Make a HTTP request.
Passes `*args`, `**kwargs` straight through to `httpx.AsyncClient.request`, we call
`raise_for_status()` on the response and wrap any `HTTPX` error in a `ClientException`.
def __init__(self, base_url: str, headers: dict[str, str] | None = None) -> None:
"""
Args:
base_url: e.g., http://localhost
headers: Headers that are applied to every request
"""
self.base_url = base_url
self.client = httpx.AsyncClient(base_url=base_url)
clients.add(self.client)
self.client.headers.update({"Content-Type": "application/json"})
if headers is not None:
self.client.headers.update(headers)

@tenacity.retry(
wait=tenacity.wait_random_exponential( # type:ignore[attr-defined]
multiplier=settings.http.EXPONENTIAL_BACKOFF_MULTIPLIER,
exp_base=settings.http.EXPONENTIAL_BACKOFF_BASE,
min=settings.http.BACKOFF_MIN,
max=settings.http.BACKOFF_MAX,
),
retry=tenacity.retry_if_exception_type(httpx.TransportError), # type:ignore[attr-defined]
)
async def request(
self,
method: str,
path: str,
params: dict[str, Any] | None = None,
content: bytes | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Make an HTTP request with retrying and exponential backoff.
This method uses the `httpx` library to make an HTTP request and
the `tenacity` library to retry the request if it fails. It uses
exponential backoff with jitter to wait between retries.
Args:
*args: Unpacked into `httpx.AsyncClient.request()`.
**kwargs: Unpacked into `httpx.AsyncClient.request()`.
method: The HTTP method (e.g. "GET", "POST")
path: The URL path (e.g. "/users/123")
params: Query parameters (optional)
content: Data to send in the request body (optional)
headers: HTTP headers to send with the request (optional)
Returns:
Return value of `httpx.AsyncClient.request()` after calling
`httpx.Response.raise_for_status()`
The `httpx.Response` object.
Raises:
ClientException: Wraps any `httpx.HTTPError` arising from the request or response status
check.
httpx.RequestException: If the request fails and cannot be retried.
"""
try:
req = await self._client.request(*args, **kwargs)
req.raise_for_status()
response = await self.client.request(
method, path, params=params, content=content, headers=headers
)
response.raise_for_status()
except httpx.HTTPError as exc:
url = exc.request.url
raise ClientException(f"Client Error for '{url}'") from exc
return req
raise ClientException from exc
return response

def json(self, response: httpx.Response) -> Any:
"""Parse response as JSON.
async def get(
self,
path: str,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Make an HTTP GET request with retrying and exponential backoff.
Abstracts deserializing to allow for optional unwrapping of server response, e.g.,
`{"data": []}`.
This method is a convenience wrapper around the `request` method that
sends an HTTP GET request.
Args:
response: Response object, we call `.json()` on it.
path: The URL path (e.g. "/users/123")
params: Query parameters (optional)
headers: HTTP headers to send with the request (optional)
Returns:
The result of `httpx.Response.json()` after passing through `self.unwrap_json()`.
The `httpx.Response` object.
Raises:
httpx.RequestException: If the request fails and cannot be retried.
"""
return self.unwrap_json(response.json())
return await self.request("GET", path, params=params, headers=headers)

@staticmethod
def unwrap_json(data: Any) -> Any:
"""Receive parsed JSON.
async def post(
self,
path: str,
content: bytes | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Make an HTTP POST request with retrying and exponential backoff.
Override this method for pre-processing response data, for example unwrapping enveloped
data.
This method is a convenience wrapper around the `request` method that
sends an HTTP POST request.
Args:
data: Value returned from `response.json()`.
path: The URL path (e.g. "/users/123")
content: Data to send in the request body (optional)
headers: HTTP headers to send with the request (optional)
Returns:
Pre-processed data, default is pass-through/no-op.
The `httpx.Response` object.
Raises:
httpx.RequestException: If the request fails and cannot be retried.
"""
return data
return await self.request("POST", path, content=content, headers=headers)

async def put(
self,
path: str,
content: bytes | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Make an HTTP PUT request with retrying and exponential backoff.
This method is a convenience wrapper around the `request` method that
sends an HTTP PUT request.
Args:
path: The URL path (e.g. "/users/123")
content: Data to send in the request body (optional)
headers: HTTP headers to send with the request (optional)
Returns:
The `httpx.Response` object.
Raises:
httpx.RequestException: If the request fails and cannot be retried.
"""
return await self.request("PUT", path, content=content, headers=headers)

async def delete(self, path: str, headers: dict[str, str] | None = None) -> httpx.Response:
"""Make an HTTP DELETE request with retrying and exponential backoff.
This method is a convenience wrapper around the `request` method that
sends an HTTP DELETE request.
Args:
path: The URL path (e.g. "/users/123")
headers: HTTP headers to send with the request (optional)
Returns:
The `httpx.Response` object.
Raises:
httpx.RequestException: If the request fails and cannot be retried.
"""
return await self.request("DELETE", path, headers=headers)


@classmethod
async def close(cls) -> None:
"""Close underlying client transport and proxies."""
await cls._client.aclose()
async def on_shutdown() -> None:
"""Close any clients that have been created."""
for client in clients:
await client.aclose()
2 changes: 1 addition & 1 deletion src/starlite_saqlalchemy/init_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ def __call__(self, app_config: AppConfig) -> AppConfig:
self.configure_type_encoders(app_config)
self.configure_worker(app_config)

app_config.on_shutdown.extend([http.Client.close, redis.client.close])
app_config.on_shutdown.extend([http.on_shutdown, redis.client.close])
return app_config

def configure_after_exception(self, app_config: AppConfig) -> None:
Expand Down
16 changes: 16 additions & 0 deletions src/starlite_saqlalchemy/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,20 @@ class Config:
"""Directories to watch for reloading."""


class HTTPClientSettings(BaseSettings):
"""HTTP Client configurations."""

class Config:
case_sensitive = True
env_file = ".env"
env_prefix = "HTTP_"

BACKOFF_MAX: float = 60
BACKOFF_MIN: float = 0
EXPONENTIAL_BACKOFF_BASE: float = 2
EXPONENTIAL_BACKOFF_MULTIPLIER: float = 1


# `.parse_obj()` thing is a workaround for pyright and pydantic interplay, see:
# https://github.com/pydantic/pydantic/issues/3753#issuecomment-1087417884
api = APISettings.parse_obj({})
Expand All @@ -249,6 +263,8 @@ class Config:
"""App settings."""
db = DatabaseSettings.parse_obj({})
"""Database settings."""
http = HTTPClientSettings.parse_obj({})
"""HTTP Client Settings."""
log = LogSettings.parse_obj({})
"""Log settings."""
openapi = OpenAPISettings.parse_obj({})
Expand Down
64 changes: 51 additions & 13 deletions tests/unit/test_http.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""Tests for http.py."""
# pylint: disable=protected-access
from __future__ import annotations

from typing import TYPE_CHECKING
Expand All @@ -15,30 +14,69 @@
from pytest import MonkeyPatch


async def test_client_request(monkeypatch: MonkeyPatch) -> None:
@pytest.fixture(name="client")
def fx_client() -> http.Client:
"""Mock client."""
return http.Client(base_url="https://something.com")


async def test_client_request(client: http.Client, monkeypatch: MonkeyPatch) -> None:
"""Tests logic of request() method."""
response_mock = MagicMock()
request_mock = AsyncMock(return_value=response_mock)
monkeypatch.setattr(http.Client._client, "request", request_mock)
res = await http.Client().request("with", "args", and_some="kwargs")
request_mock.assert_called_once_with("with", "args", and_some="kwargs")
monkeypatch.setattr(client.client, "request", request_mock)
res = await client.request("GET", "/here")
request_mock.assert_called_once_with("GET", "/here", params=None, content=None, headers=None)
response_mock.raise_for_status.assert_called_once()
assert res is response_mock


async def test_client_raises_client_exception(monkeypatch: MonkeyPatch) -> None:
async def test_client_raises_client_exception(
client: http.Client, monkeypatch: MonkeyPatch
) -> None:
"""Tests that we convert httpx exceptions into ClientException."""
exc = httpx.HTTPError("a message")
req = AsyncMock(side_effect=exc)
req.url = "http://whatever.com"
exc.request = req
monkeypatch.setattr(http.Client._client, "request", req)
monkeypatch.setattr(client.client, "request", req)
with pytest.raises(http.ClientException):
await http.Client().request()
await client.request("GET", "/here")


def test_client_adds_headers_to_httpx_client() -> None:
"""Test headers are added to underlying client."""
client = http.Client("http://localhost", headers={"X-Api-Key": "abc123"})
assert "x-api-key" in client.client.headers


async def test_client_get(client: http.Client, monkeypatch: MonkeyPatch) -> None:
"""Test client GET call."""
request_mock = AsyncMock()
monkeypatch.setattr(http.Client, "request", request_mock)
await client.get("/a", {"b": "c"}, {"d": "e"})
request_mock.assert_called_once_with("GET", "/a", params={"b": "c"}, headers={"d": "e"})


async def test_client_post(client: http.Client, monkeypatch: MonkeyPatch) -> None:
"""Test client POST call."""
request_mock = AsyncMock()
monkeypatch.setattr(http.Client, "request", request_mock)
await client.post("/a", b"bc", {"d": "e"})
request_mock.assert_called_once_with("POST", "/a", content=b"bc", headers={"d": "e"})


async def test_client_put(client: http.Client, monkeypatch: MonkeyPatch) -> None:
"""Test client PUT call."""
request_mock = AsyncMock()
monkeypatch.setattr(http.Client, "request", request_mock)
await client.put("/a", b"bc", {"d": "e"})
request_mock.assert_called_once_with("PUT", "/a", content=b"bc", headers={"d": "e"})


def test_client_json() -> None:
"""Tests the json() and unwrap_json() passthrough."""
resp = MagicMock()
resp.json.return_value = {"data": "data"}
assert http.Client().json(resp) == {"data": "data"}
async def test_client_delete(client: http.Client, monkeypatch: MonkeyPatch) -> None:
"""Test client DELETE call."""
request_mock = AsyncMock()
monkeypatch.setattr(http.Client, "request", request_mock)
await client.delete("/a", {"d": "e"})
request_mock.assert_called_once_with("DELETE", "/a", headers={"d": "e"})

0 comments on commit 5538f83

Please sign in to comment.