fastapi-service-client 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .idea/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
@@ -0,0 +1,11 @@
1
+ # CHANGELOG
2
+
3
+ <!-- version list -->
4
+
5
+ ## v1.0.0
6
+
7
+ - Initial public release.
8
+ - `BaseServiceClient` with raw / JSON / bytes / typed / model request helpers.
9
+ - `HttpxSettings` for outbound request configuration.
10
+ - Upstream exception hierarchy (`UpstreamError` and subclasses).
11
+ - Structured request/response logging via event hooks.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maxim Kovalev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-service-client
3
+ Version: 1.0.0
4
+ Summary: Shared httpx client and BaseServiceClient for typed service-to-service HTTP calls
5
+ Project-URL: Homepage, https://github.com/mkovalev-dev/fastapi-service-client
6
+ Project-URL: Repository, https://github.com/mkovalev-dev/fastapi-service-client
7
+ Project-URL: Issues, https://github.com/mkovalev-dev/fastapi-service-client/issues
8
+ Project-URL: Changelog, https://github.com/mkovalev-dev/fastapi-service-client/blob/main/CHANGELOG.md
9
+ Author-email: Maxim Kovalev <makccom0@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: async,fastapi,http-client,httpx,microservices,pydantic
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.14
24
+ Requires-Dist: httpx>=0.28
25
+ Requires-Dist: pydantic>=2.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # fastapi-service-client
29
+
30
+ Shared `httpx` client and `BaseServiceClient` for typed service-to-service HTTP calls. Eliminates copy-pasting HTTP transport boilerplate across microservices: structured logging, typed responses via Pydantic, and a clean exception hierarchy for upstream failures.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install fastapi-service-client
36
+ ```
37
+
38
+ Or with [uv](https://docs.astral.sh/uv/):
39
+
40
+ ```bash
41
+ uv add fastapi-service-client
42
+ ```
43
+
44
+ ## Quick start
45
+
46
+ ### 1. Add settings to your app
47
+
48
+ ```python
49
+ from pydantic_settings import BaseSettings
50
+ from fastapi_service_client import HttpxSettings
51
+
52
+ class AppSettings(BaseSettings):
53
+ httpx: HttpxSettings = HttpxSettings()
54
+ ```
55
+
56
+ ### 2. Implement a service client
57
+
58
+ ```python
59
+ from fastapi_service_client import BaseServiceClient, HttpxSettings
60
+
61
+ class CryptoServiceClient(BaseServiceClient):
62
+ service_name = "crypto-service"
63
+
64
+ def __init__(self, base_url: str, settings: HttpxSettings) -> None:
65
+ super().__init__(base_url=base_url, settings=settings)
66
+
67
+ async def get_key(self, key_id: str) -> dict:
68
+ return await self._request_json("GET", f"/keys/{key_id}")
69
+ ```
70
+
71
+ ### 3. Use in your endpoint / use case
72
+
73
+ ```python
74
+ client = CryptoServiceClient(
75
+ base_url=settings.crypto_service_url,
76
+ settings=settings.httpx,
77
+ )
78
+ result = await client.get_key("abc123")
79
+ ```
80
+
81
+ ## API
82
+
83
+ ### `HttpxSettings`
84
+
85
+ Pydantic model to include in your app settings.
86
+
87
+ | Field | Type | Default | Description |
88
+ |-------|------|---------|-------------|
89
+ | `ssl_verify` | `bool` | `True` | Verify SSL certificates. Set to `False` only for trusted internal services with self-signed certificates. |
90
+
91
+ ### `BaseServiceClient`
92
+
93
+ Base class for outbound HTTP clients.
94
+
95
+ **Constructor:**
96
+
97
+ ```python
98
+ BaseServiceClient(*, base_url: str = "", settings: HttpxSettings | None = None)
99
+ ```
100
+
101
+ **Methods** (all protected, call from subclass):
102
+
103
+ | Method | Returns | Description |
104
+ |--------|---------|-------------|
105
+ | `_request_raw(method, url, ...)` | `httpx.Response` | Raw response |
106
+ | `_request_json(method, url, ...)` | `Any` | Parsed JSON |
107
+ | `_request_bytes(method, url, ...)` | `bytes` | Response body as bytes |
108
+ | `_request_typed(adapter, method, url, ...)` | `T` | Validated via `TypeAdapter[T]` |
109
+ | `_request_model(model, method, url, ...)` | `TModel` | Validated Pydantic model |
110
+
111
+ All methods accept:
112
+
113
+ | Parameter | Type | Default | Description |
114
+ |-----------|------|---------|-------------|
115
+ | `headers` | `dict[str, str] \| None` | `None` | Extra request headers |
116
+ | `cookies` | `dict[str, str] \| None` | `None` | Request cookies |
117
+ | `expected_status` | `int` | `200` | Raises `UpstreamResponseError` on mismatch |
118
+ | `detail_on_bad_status` | `str` | `"Upstream request failed"` | Error message on bad status |
119
+ | `**kwargs` | | | Passed through to `httpx.AsyncClient.request` |
120
+
121
+ **Class attribute:**
122
+
123
+ ```python
124
+ class MyClient(BaseServiceClient):
125
+ service_name = "my-service" # used in exception messages and logs
126
+ ```
127
+
128
+ ### Exceptions
129
+
130
+ ```python
131
+ from fastapi_service_client import (
132
+ UpstreamError, # base
133
+ UpstreamUnavailableError, # network error / timeout
134
+ UpstreamResponseError, # unexpected HTTP status
135
+ UpstreamInvalidResponseError # unparseable response body
136
+ )
137
+ ```
138
+
139
+ All inherit from `UpstreamError`. Catch the base to handle any upstream failure:
140
+
141
+ ```python
142
+ try:
143
+ result = await client.get_key("abc")
144
+ except UpstreamUnavailableError:
145
+ raise HTTPException(status_code=503)
146
+ except UpstreamResponseError as exc:
147
+ raise HTTPException(status_code=exc.status_code, detail=exc.detail)
148
+ ```
149
+
150
+ ## Logging
151
+
152
+ The library logs via standard Python `logging`. Each outbound request and response is logged at `INFO` level under the `fastapi_service_client` namespace:
153
+
154
+ ```json
155
+ {"event": "http_out_request", "method": "GET", "url": "http://..."}
156
+ {"event": "http_out_response", "status": 200, "url": "http://...", "duration_ms": 42.1}
157
+ ```
158
+
159
+ Configure in your app's logging setup as usual.
160
+
161
+ ## Versioning
162
+
163
+ Releases are automated via [python-semantic-release](https://python-semantic-release.readthedocs.io/) on every push to `main`. Version bumps follow [Conventional Commits](https://www.conventionalcommits.org/):
164
+
165
+ | Commit prefix | Version bump |
166
+ |---------------|-------------|
167
+ | `fix:` | patch (`1.0.0` → `1.0.1`) |
168
+ | `feat:` | minor (`1.0.0` → `1.1.0`) |
169
+ | `feat!:` / `BREAKING CHANGE:` | major (`1.0.0` → `2.0.0`) |
170
+
171
+ ## License
172
+
173
+ [MIT](LICENSE)
@@ -0,0 +1,146 @@
1
+ # fastapi-service-client
2
+
3
+ Shared `httpx` client and `BaseServiceClient` for typed service-to-service HTTP calls. Eliminates copy-pasting HTTP transport boilerplate across microservices: structured logging, typed responses via Pydantic, and a clean exception hierarchy for upstream failures.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install fastapi-service-client
9
+ ```
10
+
11
+ Or with [uv](https://docs.astral.sh/uv/):
12
+
13
+ ```bash
14
+ uv add fastapi-service-client
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ### 1. Add settings to your app
20
+
21
+ ```python
22
+ from pydantic_settings import BaseSettings
23
+ from fastapi_service_client import HttpxSettings
24
+
25
+ class AppSettings(BaseSettings):
26
+ httpx: HttpxSettings = HttpxSettings()
27
+ ```
28
+
29
+ ### 2. Implement a service client
30
+
31
+ ```python
32
+ from fastapi_service_client import BaseServiceClient, HttpxSettings
33
+
34
+ class CryptoServiceClient(BaseServiceClient):
35
+ service_name = "crypto-service"
36
+
37
+ def __init__(self, base_url: str, settings: HttpxSettings) -> None:
38
+ super().__init__(base_url=base_url, settings=settings)
39
+
40
+ async def get_key(self, key_id: str) -> dict:
41
+ return await self._request_json("GET", f"/keys/{key_id}")
42
+ ```
43
+
44
+ ### 3. Use in your endpoint / use case
45
+
46
+ ```python
47
+ client = CryptoServiceClient(
48
+ base_url=settings.crypto_service_url,
49
+ settings=settings.httpx,
50
+ )
51
+ result = await client.get_key("abc123")
52
+ ```
53
+
54
+ ## API
55
+
56
+ ### `HttpxSettings`
57
+
58
+ Pydantic model to include in your app settings.
59
+
60
+ | Field | Type | Default | Description |
61
+ |-------|------|---------|-------------|
62
+ | `ssl_verify` | `bool` | `True` | Verify SSL certificates. Set to `False` only for trusted internal services with self-signed certificates. |
63
+
64
+ ### `BaseServiceClient`
65
+
66
+ Base class for outbound HTTP clients.
67
+
68
+ **Constructor:**
69
+
70
+ ```python
71
+ BaseServiceClient(*, base_url: str = "", settings: HttpxSettings | None = None)
72
+ ```
73
+
74
+ **Methods** (all protected, call from subclass):
75
+
76
+ | Method | Returns | Description |
77
+ |--------|---------|-------------|
78
+ | `_request_raw(method, url, ...)` | `httpx.Response` | Raw response |
79
+ | `_request_json(method, url, ...)` | `Any` | Parsed JSON |
80
+ | `_request_bytes(method, url, ...)` | `bytes` | Response body as bytes |
81
+ | `_request_typed(adapter, method, url, ...)` | `T` | Validated via `TypeAdapter[T]` |
82
+ | `_request_model(model, method, url, ...)` | `TModel` | Validated Pydantic model |
83
+
84
+ All methods accept:
85
+
86
+ | Parameter | Type | Default | Description |
87
+ |-----------|------|---------|-------------|
88
+ | `headers` | `dict[str, str] \| None` | `None` | Extra request headers |
89
+ | `cookies` | `dict[str, str] \| None` | `None` | Request cookies |
90
+ | `expected_status` | `int` | `200` | Raises `UpstreamResponseError` on mismatch |
91
+ | `detail_on_bad_status` | `str` | `"Upstream request failed"` | Error message on bad status |
92
+ | `**kwargs` | | | Passed through to `httpx.AsyncClient.request` |
93
+
94
+ **Class attribute:**
95
+
96
+ ```python
97
+ class MyClient(BaseServiceClient):
98
+ service_name = "my-service" # used in exception messages and logs
99
+ ```
100
+
101
+ ### Exceptions
102
+
103
+ ```python
104
+ from fastapi_service_client import (
105
+ UpstreamError, # base
106
+ UpstreamUnavailableError, # network error / timeout
107
+ UpstreamResponseError, # unexpected HTTP status
108
+ UpstreamInvalidResponseError # unparseable response body
109
+ )
110
+ ```
111
+
112
+ All inherit from `UpstreamError`. Catch the base to handle any upstream failure:
113
+
114
+ ```python
115
+ try:
116
+ result = await client.get_key("abc")
117
+ except UpstreamUnavailableError:
118
+ raise HTTPException(status_code=503)
119
+ except UpstreamResponseError as exc:
120
+ raise HTTPException(status_code=exc.status_code, detail=exc.detail)
121
+ ```
122
+
123
+ ## Logging
124
+
125
+ The library logs via standard Python `logging`. Each outbound request and response is logged at `INFO` level under the `fastapi_service_client` namespace:
126
+
127
+ ```json
128
+ {"event": "http_out_request", "method": "GET", "url": "http://..."}
129
+ {"event": "http_out_response", "status": 200, "url": "http://...", "duration_ms": 42.1}
130
+ ```
131
+
132
+ Configure in your app's logging setup as usual.
133
+
134
+ ## Versioning
135
+
136
+ Releases are automated via [python-semantic-release](https://python-semantic-release.readthedocs.io/) on every push to `main`. Version bumps follow [Conventional Commits](https://www.conventionalcommits.org/):
137
+
138
+ | Commit prefix | Version bump |
139
+ |---------------|-------------|
140
+ | `fix:` | patch (`1.0.0` → `1.0.1`) |
141
+ | `feat:` | minor (`1.0.0` → `1.1.0`) |
142
+ | `feat!:` / `BREAKING CHANGE:` | major (`1.0.0` → `2.0.0`) |
143
+
144
+ ## License
145
+
146
+ [MIT](LICENSE)
@@ -0,0 +1,72 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fastapi-service-client"
7
+ version = "1.0.0"
8
+ description = "Shared httpx client and BaseServiceClient for typed service-to-service HTTP calls"
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Maxim Kovalev", email = "makccom0@gmail.com" },
15
+ ]
16
+ keywords = ["httpx", "fastapi", "microservices", "http-client", "pydantic", "async"]
17
+ classifiers = [
18
+ "Development Status :: 5 - Production/Stable",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Topic :: Internet :: WWW/HTTP",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "httpx>=0.28",
31
+ "pydantic>=2.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/mkovalev-dev/fastapi-service-client"
36
+ Repository = "https://github.com/mkovalev-dev/fastapi-service-client"
37
+ Issues = "https://github.com/mkovalev-dev/fastapi-service-client/issues"
38
+ Changelog = "https://github.com/mkovalev-dev/fastapi-service-client/blob/main/CHANGELOG.md"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/fastapi_service_client"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = [
45
+ "src/fastapi_service_client",
46
+ "README.md",
47
+ "CHANGELOG.md",
48
+ "LICENSE",
49
+ ]
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "pytest>=8.0",
54
+ "pytest-asyncio>=0.24",
55
+ "respx>=0.22",
56
+ ]
57
+
58
+ [tool.pytest.ini_options]
59
+ asyncio_mode = "auto"
60
+
61
+ [tool.semantic_release]
62
+ version_toml = ["pyproject.toml:project.version"]
63
+ build_command = "pip install uv && uv build"
64
+ commit_message = "chore(release): v{version} [skip ci]"
65
+ tag_format = "v{version}"
66
+
67
+ [tool.semantic_release.branches.main]
68
+ match = "main"
69
+ prerelease = false
70
+
71
+ [tool.semantic_release.publish]
72
+ upload_to_vcs_release = true
@@ -0,0 +1,19 @@
1
+ from .client import create_httpx_client
2
+ from .exceptions import (
3
+ UpstreamError,
4
+ UpstreamInvalidResponseError,
5
+ UpstreamResponseError,
6
+ UpstreamUnavailableError,
7
+ )
8
+ from .service_client import BaseServiceClient
9
+ from .settings import HttpxSettings
10
+
11
+ __all__ = [
12
+ "BaseServiceClient",
13
+ "HttpxSettings",
14
+ "UpstreamError",
15
+ "UpstreamInvalidResponseError",
16
+ "UpstreamResponseError",
17
+ "UpstreamUnavailableError",
18
+ "create_httpx_client",
19
+ ]
@@ -0,0 +1,23 @@
1
+ import logging
2
+
3
+ import httpx
4
+
5
+ from .event_hooks import (
6
+ request_logger_event_hook,
7
+ response_logger_event_hook,
8
+ )
9
+ from .settings import HttpxSettings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def create_httpx_client(settings: HttpxSettings) -> httpx.AsyncClient:
15
+ """Create a stateless HTTPX client for a single outbound request."""
16
+ logger.info("Creating HTTPX client for outbound request")
17
+ return httpx.AsyncClient(
18
+ verify=settings.ssl_verify,
19
+ event_hooks={
20
+ "request": [request_logger_event_hook],
21
+ "response": [response_logger_event_hook],
22
+ },
23
+ )
@@ -0,0 +1,30 @@
1
+ import logging
2
+ import time
3
+
4
+ from httpx import Request, Response
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ async def request_logger_event_hook(request: Request) -> None:
10
+ request.extensions["start_time"] = time.perf_counter()
11
+ logger.info(
12
+ {
13
+ "event": "http_out_request",
14
+ "method": request.method,
15
+ "url": str(request.url),
16
+ }
17
+ )
18
+
19
+
20
+ async def response_logger_event_hook(response: Response) -> None:
21
+ start = response.request.extensions.get("start_time")
22
+ duration = round((time.perf_counter() - start) * 1000, 2) if start else None
23
+ logger.info(
24
+ {
25
+ "event": "http_out_response",
26
+ "status": response.status_code,
27
+ "url": str(response.request.url),
28
+ "duration_ms": duration,
29
+ }
30
+ )
@@ -0,0 +1,30 @@
1
+ class UpstreamError(Exception):
2
+ """Base upstream service integration error."""
3
+
4
+
5
+ class UpstreamUnavailableError(UpstreamError):
6
+ """Upstream service is unavailable or did not respond."""
7
+
8
+ def __init__(self, service_name: str) -> None:
9
+ detail = f"{service_name} is temporarily unavailable. Try again later."
10
+ super().__init__(detail)
11
+ self.service_name = service_name
12
+
13
+
14
+ class UpstreamResponseError(UpstreamError):
15
+ """Upstream service returned an unexpected HTTP status."""
16
+
17
+ def __init__(self, service_name: str, status_code: int, detail: str) -> None:
18
+ super().__init__(detail)
19
+ self.service_name = service_name
20
+ self.status_code = status_code
21
+ self.detail = detail
22
+
23
+
24
+ class UpstreamInvalidResponseError(UpstreamError):
25
+ """Upstream service returned an invalid or unparseable response."""
26
+
27
+ def __init__(self, service_name: str, detail: str) -> None:
28
+ super().__init__(detail)
29
+ self.service_name = service_name
30
+ self.detail = detail
@@ -0,0 +1,195 @@
1
+ import hashlib
2
+ import http
3
+ import logging
4
+ from typing import Any, TypeVar
5
+ from urllib.parse import urlsplit
6
+
7
+ import httpx
8
+ from pydantic import BaseModel, TypeAdapter
9
+
10
+ from .client import create_httpx_client
11
+ from .exceptions import (
12
+ UpstreamInvalidResponseError,
13
+ UpstreamResponseError,
14
+ UpstreamUnavailableError,
15
+ )
16
+ from .settings import HttpxSettings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ TModel = TypeVar("TModel", bound=BaseModel)
21
+ T = TypeVar("T")
22
+
23
+
24
+ class BaseServiceClient:
25
+ service_name: str = "external service"
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ base_url: str = "",
31
+ settings: HttpxSettings | None = None,
32
+ ) -> None:
33
+ self._base_url = base_url.rstrip("/")
34
+ self._settings = settings or HttpxSettings()
35
+
36
+ def _get_default_headers(self) -> dict[str, str]:
37
+ parsed_url = urlsplit(self._base_url)
38
+ hostname = (parsed_url.hostname or "").replace("_", "-")
39
+ if not hostname:
40
+ return {}
41
+ return {"Host": hostname}
42
+
43
+ @staticmethod
44
+ def _build_session_fingerprint(session: str) -> str:
45
+ session_hash = hashlib.sha256(session.encode("utf-8")).hexdigest()
46
+ return session_hash[:12]
47
+
48
+ def _build_url(self, url: str) -> str:
49
+ if url.startswith(("http://", "https://")):
50
+ return url
51
+ if not self._base_url:
52
+ return url
53
+ return f"{self._base_url}/{url.lstrip('/')}"
54
+
55
+ async def _request_raw(
56
+ self,
57
+ method: str,
58
+ url: str,
59
+ *,
60
+ headers: dict[str, str] | None = None,
61
+ cookies: dict[str, str] | None = None,
62
+ expected_status: int = http.HTTPStatus.OK,
63
+ detail_on_bad_status: str = "Upstream request failed",
64
+ **kwargs: Any,
65
+ ) -> httpx.Response:
66
+ merged_headers = {**self._get_default_headers(), **(headers or {})}
67
+ request_url = self._build_url(url)
68
+
69
+ try:
70
+ async with create_httpx_client(self._settings) as client:
71
+ response = await client.request(
72
+ method,
73
+ request_url,
74
+ headers=merged_headers or None,
75
+ cookies=cookies,
76
+ **kwargs,
77
+ )
78
+ except httpx.RequestError as exc:
79
+ logger.exception("%s request error: %s", self.service_name, exc)
80
+ raise UpstreamUnavailableError(self.service_name) from exc
81
+
82
+ if response.status_code != expected_status:
83
+ logger.warning(
84
+ "%s non-%s: %s %s",
85
+ self.service_name,
86
+ expected_status,
87
+ response.status_code,
88
+ response.text,
89
+ )
90
+ raise UpstreamResponseError(
91
+ self.service_name,
92
+ response.status_code,
93
+ detail_on_bad_status,
94
+ )
95
+
96
+ return response
97
+
98
+ async def _request_json(
99
+ self,
100
+ method: str,
101
+ url: str,
102
+ *,
103
+ headers: dict[str, str] | None = None,
104
+ cookies: dict[str, str] | None = None,
105
+ expected_status: int = http.HTTPStatus.OK,
106
+ detail_on_bad_status: str = "Upstream request failed",
107
+ **kwargs: Any,
108
+ ) -> Any:
109
+ response = await self._request_raw(
110
+ method,
111
+ url,
112
+ headers=headers,
113
+ cookies=cookies,
114
+ expected_status=expected_status,
115
+ detail_on_bad_status=detail_on_bad_status,
116
+ **kwargs,
117
+ )
118
+ try:
119
+ if not response.content:
120
+ return None
121
+ return response.json()
122
+ except ValueError as exc:
123
+ logger.exception("Invalid JSON from %s: %s", self.service_name, exc)
124
+ raise UpstreamInvalidResponseError(
125
+ self.service_name,
126
+ f"Invalid response from {self.service_name}",
127
+ ) from exc
128
+
129
+ async def _request_bytes(
130
+ self,
131
+ method: str,
132
+ url: str,
133
+ *,
134
+ headers: dict[str, str] | None = None,
135
+ cookies: dict[str, str] | None = None,
136
+ expected_status: int = http.HTTPStatus.OK,
137
+ detail_on_bad_status: str = "Upstream request failed",
138
+ **kwargs: Any,
139
+ ) -> bytes:
140
+ response = await self._request_raw(
141
+ method,
142
+ url,
143
+ headers=headers,
144
+ cookies=cookies,
145
+ expected_status=expected_status,
146
+ detail_on_bad_status=detail_on_bad_status,
147
+ **kwargs,
148
+ )
149
+ return response.content
150
+
151
+ async def _request_typed(
152
+ self,
153
+ adapter: TypeAdapter[T],
154
+ method: str,
155
+ url: str,
156
+ *,
157
+ headers: dict[str, str] | None = None,
158
+ cookies: dict[str, str] | None = None,
159
+ expected_status: int = http.HTTPStatus.OK,
160
+ detail_on_bad_status: str = "Upstream request failed",
161
+ **kwargs: Any,
162
+ ) -> T:
163
+ data = await self._request_json(
164
+ method,
165
+ url,
166
+ headers=headers,
167
+ cookies=cookies,
168
+ expected_status=expected_status,
169
+ detail_on_bad_status=detail_on_bad_status,
170
+ **kwargs,
171
+ )
172
+ return adapter.validate_python(data)
173
+
174
+ async def _request_model(
175
+ self,
176
+ model: type[TModel],
177
+ method: str,
178
+ url: str,
179
+ *,
180
+ headers: dict[str, str] | None = None,
181
+ cookies: dict[str, str] | None = None,
182
+ expected_status: int = http.HTTPStatus.OK,
183
+ detail_on_bad_status: str = "Upstream request failed",
184
+ **kwargs: Any,
185
+ ) -> TModel:
186
+ data = await self._request_json(
187
+ method,
188
+ url,
189
+ headers=headers,
190
+ cookies=cookies,
191
+ expected_status=expected_status,
192
+ detail_on_bad_status=detail_on_bad_status,
193
+ **kwargs,
194
+ )
195
+ return TypeAdapter(model).validate_python(data)
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class HttpxSettings(BaseModel):
5
+ """Settings for outbound HTTP requests. Include this in your app settings."""
6
+
7
+ ssl_verify: bool = True