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.
- fastapi_service_client-1.0.0/.gitignore +13 -0
- fastapi_service_client-1.0.0/CHANGELOG.md +11 -0
- fastapi_service_client-1.0.0/LICENSE +21 -0
- fastapi_service_client-1.0.0/PKG-INFO +173 -0
- fastapi_service_client-1.0.0/README.md +146 -0
- fastapi_service_client-1.0.0/pyproject.toml +72 -0
- fastapi_service_client-1.0.0/src/fastapi_service_client/__init__.py +19 -0
- fastapi_service_client-1.0.0/src/fastapi_service_client/client.py +23 -0
- fastapi_service_client-1.0.0/src/fastapi_service_client/event_hooks.py +30 -0
- fastapi_service_client-1.0.0/src/fastapi_service_client/exceptions.py +30 -0
- fastapi_service_client-1.0.0/src/fastapi_service_client/py.typed +0 -0
- fastapi_service_client-1.0.0/src/fastapi_service_client/service_client.py +195 -0
- fastapi_service_client-1.0.0/src/fastapi_service_client/settings.py +7 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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)
|