xtr-http-client 4.0.2__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.
- xtr_http_client-4.0.2/LICENSE +22 -0
- xtr_http_client-4.0.2/PKG-INFO +505 -0
- xtr_http_client-4.0.2/README.md +469 -0
- xtr_http_client-4.0.2/pyproject.toml +178 -0
- xtr_http_client-4.0.2/pyproject.toml.orig +188 -0
- xtr_http_client-4.0.2/src/xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
- xtr_http_client-4.0.2/src/xtr_http_client/__init__.py +47 -0
- xtr_http_client-4.0.2/src/xtr_http_client/bundle/__init__.py +17 -0
- xtr_http_client-4.0.2/src/xtr_http_client/bundle/_clients.py +217 -0
- xtr_http_client-4.0.2/src/xtr_http_client/bundle/client_options_config.py +79 -0
- xtr_http_client-4.0.2/src/xtr_http_client/bundle/http_client_bundle.py +85 -0
- xtr_http_client-4.0.2/src/xtr_http_client/bundle/http_client_config.py +75 -0
- xtr_http_client-4.0.2/src/xtr_http_client/bundle/retry_failed_config.py +77 -0
- xtr_http_client-4.0.2/src/xtr_http_client/bundle/scoped_client_config.py +99 -0
- xtr_http_client-4.0.2/src/xtr_http_client/exception/__init__.py +40 -0
- xtr_http_client-4.0.2/src/xtr_http_client/exception/_http_error_message.py +129 -0
- xtr_http_client-4.0.2/src/xtr_http_client/http_client.py +57 -0
- xtr_http_client-4.0.2/src/xtr_http_client/http_client_mixin.py +176 -0
- xtr_http_client-4.0.2/src/xtr_http_client/httpx_http_client.py +327 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/__init__.py +13 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/debug_type.py +14 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_body.py +88 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_clients.py +203 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_errors.py +63 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_redirect.py +63 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_request.py +272 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/mock_head.py +144 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/mock_info.py +86 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/option_merge.py +108 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/response_body.py +270 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/retry_count_response.py +148 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/retry_delay.py +45 -0
- xtr_http_client-4.0.2/src/xtr_http_client/internal/retry_options.py +98 -0
- xtr_http_client-4.0.2/src/xtr_http_client/mock_http_client.py +253 -0
- xtr_http_client-4.0.2/src/xtr_http_client/no_private_network_http_client.py +342 -0
- xtr_http_client-4.0.2/src/xtr_http_client/py.typed +0 -0
- xtr_http_client-4.0.2/src/xtr_http_client/recipe/__init__.py +15 -0
- xtr_http_client-4.0.2/src/xtr_http_client/recipe/manifest.toml +23 -0
- xtr_http_client-4.0.2/src/xtr_http_client/response/__init__.py +10 -0
- xtr_http_client-4.0.2/src/xtr_http_client/response/http_response.py +412 -0
- xtr_http_client-4.0.2/src/xtr_http_client/response/httpx_response.py +173 -0
- xtr_http_client-4.0.2/src/xtr_http_client/response/json_mock_response.py +119 -0
- xtr_http_client-4.0.2/src/xtr_http_client/response/mock_response.py +375 -0
- xtr_http_client-4.0.2/src/xtr_http_client/retry/__init__.py +25 -0
- xtr_http_client-4.0.2/src/xtr_http_client/retry/generic_retry_strategy.py +215 -0
- xtr_http_client-4.0.2/src/xtr_http_client/retry/retry_context.py +73 -0
- xtr_http_client-4.0.2/src/xtr_http_client/retry/retry_strategy_interface.py +74 -0
- xtr_http_client-4.0.2/src/xtr_http_client/retryable_http_client.py +346 -0
- xtr_http_client-4.0.2/src/xtr_http_client/scoping_http_client.py +303 -0
- xtr_http_client-4.0.2/src/xtr_http_client/throttling_http_client.py +143 -0
- xtr_http_client-4.0.2/src/xtr_http_client/traceable_http_client.py +406 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
Copyright (c) 2018-present Fabien Potencier
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-http-client
|
|
3
|
+
Version: 4.0.2
|
|
4
|
+
Summary: An async HTTP client behind one interface: typed request options, bodies read on demand, scoped clients, a private-network guard and a mock client for tests.
|
|
5
|
+
Keywords: http,http-client,request,response,mock,asyncio
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: typing-extensions>=4.4
|
|
20
|
+
Requires-Dist: anyio>=4.0
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: xtr-http-client-contracts>=4.0,<5
|
|
23
|
+
Requires-Dist: xtr-http-foundation>=4.0,<5
|
|
24
|
+
Requires-Dist: xtr-http-message>=4.0,<5
|
|
25
|
+
Requires-Dist: xtr-http-message-contracts>=4.0,<5
|
|
26
|
+
Requires-Dist: xtr-logging-contracts>=4.0,<5
|
|
27
|
+
Requires-Dist: xtr-service-contracts>=4.0,<5
|
|
28
|
+
Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
|
|
29
|
+
Requires-Dist: httpx[http2]>=0.27 ; extra == 'http2'
|
|
30
|
+
Requires-Dist: xtr-rate-limiter>=4.0,<5 ; extra == 'throttling'
|
|
31
|
+
Requires-Python: >=3.11
|
|
32
|
+
Provides-Extra: di
|
|
33
|
+
Provides-Extra: http2
|
|
34
|
+
Provides-Extra: throttling
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
<div align="center">
|
|
38
|
+
|
|
39
|
+
# xtr-http-client
|
|
40
|
+
|
|
41
|
+
**An async HTTP client behind one interface: typed options in, a response read on demand out.**
|
|
42
|
+
|
|
43
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
44
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
45
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
46
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
47
|
+
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Why?
|
|
53
|
+
|
|
54
|
+
Sending a request is the easy half. The rest — one set of defaults per host, a timeout that
|
|
55
|
+
means what it says, a redirect that drops the credentials it must not carry on, an address an
|
|
56
|
+
untrusted URL must never reach, and a way to answer from a script instead of the network in a
|
|
57
|
+
test — is what every application writes again.
|
|
58
|
+
|
|
59
|
+
This package implements [xtr-http-client-contracts](../xtr-http-client-contracts) and puts that
|
|
60
|
+
rest behind the same interface: every feature is a client wrapping a client, so the code that
|
|
61
|
+
calls out sees one type however much is layered under it.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uv add xtr-http-client
|
|
67
|
+
uv add "xtr-http-client[di]" # + the bundle
|
|
68
|
+
uv add "xtr-http-client[throttling]" # + the limiter the throttling client paces by
|
|
69
|
+
uv add "xtr-http-client[http2]" # + the framing a request asking for version "2" needs
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Requires Python 3.11+.
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
`HttpClient.create()` hands back a client, typed as the contract's interface, so nothing in an
|
|
77
|
+
application has to name an implementation:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from xtr_http_client import HttpClient
|
|
81
|
+
|
|
82
|
+
client = HttpClient.create(base_uri="https://api.example.com/", timeout=5)
|
|
83
|
+
|
|
84
|
+
async with await client.request("GET", "orders", query={"paid": "1"}) as response:
|
|
85
|
+
print(response.get_status_code())
|
|
86
|
+
orders = await response.to_array()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Three things in that are worth saying out loud.
|
|
90
|
+
|
|
91
|
+
- **`request()` returns as soon as the status line and the headers are in**, and never raises
|
|
92
|
+
because of the status: a 404 or a 503 is an answer. The body is read when it is asked for.
|
|
93
|
+
- **The body is read on demand and kept** — in a temporary file past 2 MiB — so
|
|
94
|
+
`await response.get_content()` twice reads the same bytes twice, and `response.get_body()` is
|
|
95
|
+
the message stream over that same kept body.
|
|
96
|
+
- **A response holds a connection until it is done with.** Leaving the `async with` block
|
|
97
|
+
closes the body, which frees the connection; `await response.cancel()` does it mid-transfer.
|
|
98
|
+
|
|
99
|
+
`get_content()` and `to_array()` are where an unsuccessful status *is* raised —
|
|
100
|
+
`RedirectionError`, `ClientError`, `ServerError`. Pass `throw=False` to read the body a 4xx
|
|
101
|
+
came with instead:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
response = await client.request("GET", "orders/404")
|
|
105
|
+
|
|
106
|
+
if response.get_status_code() == 404:
|
|
107
|
+
explanation = await response.get_content(throw=False)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`get_info()` reports the transfer without waiting and without raising, so it is readable while
|
|
111
|
+
one is still going on: `get_info("http_code")`, `"url"`, `"redirect_count"`, `"total_time"`,
|
|
112
|
+
`"response_headers"` — the raw status and header lines of every hop — and the rest the
|
|
113
|
+
[contract](../xtr-http-client-contracts#the-response) lists.
|
|
114
|
+
|
|
115
|
+
### Sending a message request
|
|
116
|
+
|
|
117
|
+
`send_request` takes a [message request](../xtr-http-message) that already knows its own
|
|
118
|
+
method, URI, headers and body. The request wins wherever both have an opinion; the options
|
|
119
|
+
fill in what it leaves out:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from xtr_http_message import Request
|
|
123
|
+
|
|
124
|
+
request = Request("POST", "/orders", headers={"Idempotency-Key": "k-1"}, body=b'{"total":42}')
|
|
125
|
+
|
|
126
|
+
async with await client.send_request(request, headers={"Accept": "application/json"}) as response:
|
|
127
|
+
created = await response.to_array()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
So the `Idempotency-Key` is the request's, the `Accept` comes from the options, a relative URI
|
|
131
|
+
is resolved against `base_uri`, a `query` option is merged into the URI, and `auth_bearer`
|
|
132
|
+
applies only where the request carries no `Authorization` of its own. A `body` or `json`
|
|
133
|
+
option alongside a request that already has a body is refused rather than merged.
|
|
134
|
+
|
|
135
|
+
### Options
|
|
136
|
+
|
|
137
|
+
Every call takes the contract's [`RequestOptions`](../xtr-http-client-contracts#request-options)
|
|
138
|
+
as keyword arguments — `headers`, `query`, `body`, `json`, `timeout`, `max_duration`,
|
|
139
|
+
`max_redirects`, `proxy`, `resolve`, `verify_peer`, `base_uri`, `auth_basic`, `auth_bearer` and
|
|
140
|
+
the rest. An option this client does not know is refused with `InvalidArgumentError`, naming
|
|
141
|
+
the closest one it does, because an option spelled wrong that was quietly dropped is a setting
|
|
142
|
+
that silently never applied.
|
|
143
|
+
|
|
144
|
+
`with_options()` turns options into the defaults of another client of the same type, leaving
|
|
145
|
+
the one everyone else holds as it was:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
authorized = client.with_options(auth_bearer="t0ken", timeout=30)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## The clients
|
|
152
|
+
|
|
153
|
+
Every feature here is a client wrapping a client, all of them `HttpClientInterface`, so the
|
|
154
|
+
code that calls out never sees how deep the stack is.
|
|
155
|
+
|
|
156
|
+
### `HttpxHttpClient` — the one that opens connections
|
|
157
|
+
|
|
158
|
+
The transport, and what `HttpClient.create()` hands back. [httpx](https://www.python-httpx.org)
|
|
159
|
+
does the HTTP work and is named nowhere else, so nothing above this depends on it.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from xtr_http_client import HttpxHttpClient
|
|
163
|
+
|
|
164
|
+
async with HttpxHttpClient({"timeout": 5}, max_host_connections=4) as client:
|
|
165
|
+
async with await client.request("GET", "https://api.example.com/orders") as response:
|
|
166
|
+
orders = await response.to_array()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- Connections are pooled by *how* they connect rather than by host: a proxy, a trust store, a
|
|
170
|
+
client certificate, the protocol version and the deadlines each decide what a connection is,
|
|
171
|
+
so every combination of them gets a pool, made the first time something asks for it.
|
|
172
|
+
`max_host_connections` is each pool's ceiling across every host it reaches.
|
|
173
|
+
- **Proxy settings are read from the environment** — `http_proxy`, `https_proxy`, `no_proxy` —
|
|
174
|
+
and are part of what picks a pool, so changing them asks for a different pool rather than
|
|
175
|
+
being ignored. The `proxy` option overrides them per request.
|
|
176
|
+
- Redirects are followed here rather than by the backend, for the one thing a backend cannot be
|
|
177
|
+
told: an `Authorization` header belongs to the host it was meant for, so it is dropped the
|
|
178
|
+
moment a chain leaves that host and put back only if the chain returns to it.
|
|
179
|
+
- `aclose()` — or leaving the `async with` — closes every pool. Nothing is opened until a
|
|
180
|
+
request is made.
|
|
181
|
+
|
|
182
|
+
What it refuses, before anything is sent: an empty method or one that is not uppercase ASCII
|
|
183
|
+
letters; `auth_basic` and `auth_bearer` together, or either in a shape credentials cannot be
|
|
184
|
+
read from; `json` and `body` together; an option it does not know; and an `http_version` other
|
|
185
|
+
than `"1.1"` or `"2"` — HTTP/1.0 cannot be asked for, and `"2"` without the `http2` extra is
|
|
186
|
+
refused by name rather than failing as a missing import.
|
|
187
|
+
|
|
188
|
+
### `ScopingHttpClient` — a set of defaults per service
|
|
189
|
+
|
|
190
|
+
A scope is a regular expression the start of a URL is matched against, and the options every
|
|
191
|
+
request landing in it starts from:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from xtr_http_client import ScopingHttpClient
|
|
195
|
+
|
|
196
|
+
client = ScopingHttpClient(
|
|
197
|
+
inner,
|
|
198
|
+
{r"https://api\.example\.com/": {"auth_bearer": "t0ken", "headers": {"Accept": "*/*"}}},
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The first pattern matching is the one that applies, so order them from most specific to least.
|
|
203
|
+
What the request itself asked for always wins — a scope fills in, it never overrides — and
|
|
204
|
+
`headers` is the one option merged name by name rather than replaced, so a scope-wide `Accept`
|
|
205
|
+
survives a request setting its own `Content-Type`.
|
|
206
|
+
|
|
207
|
+
`ScopingHttpClient.for_base_uri(inner, "https://api.example.com/", {"timeout": 10})` is the
|
|
208
|
+
common case said shortly: one scope over that base, which is also the scope a relative URL
|
|
209
|
+
resolves against. `for_base_uris(inner, [...])` is the same for a service answering at several,
|
|
210
|
+
the first being what relative URLs resolve against.
|
|
211
|
+
|
|
212
|
+
### `RetryableHttpClient` — make the request again while that is worth doing
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from xtr_http_client import GenericRetryStrategy, RetryableHttpClient
|
|
216
|
+
|
|
217
|
+
client = RetryableHttpClient(inner, GenericRetryStrategy([500, 502, 503]), max_retries=2)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`max_retries` is how many *further* attempts are allowed, so the request is made at most that
|
|
221
|
+
many times plus one; a request may lower or raise it with the `max_retries` option. A
|
|
222
|
+
`Retry-After` header wins over whatever delay the strategy names — a server saying when to come
|
|
223
|
+
back is better informed than a client guessing. The response reports how many tries it took,
|
|
224
|
+
under the `retry_count` entry of `get_info()`.
|
|
225
|
+
|
|
226
|
+
A body that cannot be produced twice is sent once: a second attempt would send a request that
|
|
227
|
+
is not the one that failed. And when `base_uri` names several, each attempt moves one along
|
|
228
|
+
them — how a client is pointed at a second host rather than at the one that has just refused.
|
|
229
|
+
|
|
230
|
+
`GenericRetryStrategy` is the default. It retries by status code and, where it matters, by
|
|
231
|
+
method: a 503 is "not now" whatever was asked, while a 500 after a `POST` may have changed
|
|
232
|
+
something already. Out of the box that is `DEFAULT_RETRY_STATUS_CODES` — `423`, `425`, `429`,
|
|
233
|
+
`502` and `503` for every method, and `0` (nothing came back), `500`, `504`, `507` and `510`
|
|
234
|
+
for `IDEMPOTENT_METHODS` only. The delay starts at `delay_ms` and is multiplied by `multiplier`
|
|
235
|
+
per attempt already made, capped by `max_delay_ms`, with `jitter` spreading it over a window so
|
|
236
|
+
a thousand clients that failed together do not come back together.
|
|
237
|
+
|
|
238
|
+
`RetryStrategyInterface` is the seam for a strategy of your own: `should_retry(context,
|
|
239
|
+
content, error)` answers `True`, `False`, or `None` for "not without the body" — the client
|
|
240
|
+
then reads the whole body and asks once more with it — and `get_delay(...)` says how long to
|
|
241
|
+
wait in milliseconds. `RetryContext` carries the attempt as it went out: the method, the
|
|
242
|
+
resolved URL, the merged options, the `retry_count`, the response or `None`, and
|
|
243
|
+
`get_status_code()`, which is `0` when nothing answered.
|
|
244
|
+
|
|
245
|
+
### `ThrottlingHttpClient` — hold requests to a pace
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from xtr_http_client import ThrottlingHttpClient
|
|
249
|
+
|
|
250
|
+
client = ThrottlingHttpClient(inner, limiter)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`limiter` is an [xtr-rate-limiter](../xtr-rate-limiter) `LimiterInterface`, from the
|
|
254
|
+
`throttling` extra. A token is booked before each request and the request waits for the moment
|
|
255
|
+
that booking is due; booking first and waiting after is what makes the pace hold under
|
|
256
|
+
concurrency, since requests then leave in the order they were asked for rather than all at
|
|
257
|
+
once. Which requests share a pace is decided by which of them are given the same limiter — one
|
|
258
|
+
per host, one per account, one for the whole application — and a copy made by `with_options()`
|
|
259
|
+
shares it.
|
|
260
|
+
|
|
261
|
+
### `TraceableHttpClient` — a record of every request made
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from xtr_http_client import TraceableHttpClient
|
|
265
|
+
|
|
266
|
+
client = TraceableHttpClient(inner)
|
|
267
|
+
|
|
268
|
+
async with await client.request("GET", "https://api.example.com/orders") as response:
|
|
269
|
+
_ = await response.get_content()
|
|
270
|
+
|
|
271
|
+
traced = client.get_traced_requests()
|
|
272
|
+
assert traced[0]["method"] == "GET"
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
A `TracedRequest` is added the moment a request is made and filled in as it goes: `info` when
|
|
276
|
+
the response arrives — or the error alone when nothing does — and `content` once the caller
|
|
277
|
+
reads the body. So a trace read mid-request says how far it has got rather than nothing.
|
|
278
|
+
|
|
279
|
+
The bodies are the costly half of a record and the half most likely to carry a credential, so
|
|
280
|
+
`extra={"trace_content": False}` leaves them out: `body` and `json` are dropped from `options`
|
|
281
|
+
and `content` is `False` rather than bytes. Pass `disabled=lambda: ...` to record nothing while
|
|
282
|
+
it says so. There is one record per client and its copies; `reset()` clears it.
|
|
283
|
+
|
|
284
|
+
### `NoPrivateNetworkHttpClient` — refuse what leads inwards
|
|
285
|
+
|
|
286
|
+
A URL that came from outside is data, so without a guard a request is a way to make this
|
|
287
|
+
process reach an address only this process can see — another service on the same network, an
|
|
288
|
+
admin port, a metadata endpoint handing out credentials.
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from xtr_http_client import NoPrivateNetworkHttpClient
|
|
292
|
+
|
|
293
|
+
client = NoPrivateNetworkHttpClient(inner, allow_list=("10.1.2.3",))
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The check runs on what the host *resolves to*, not on how it is written, and one refused
|
|
297
|
+
address refuses the whole request — whichever of them the connection would pick, nothing is
|
|
298
|
+
sent. The request is then pinned to the address that was checked, through the `resolve` option,
|
|
299
|
+
so a name whose records change between the check and the connection reaches what was vetted or
|
|
300
|
+
nothing at all. Redirects are followed here too, because a hop nobody checked would walk
|
|
301
|
+
straight past the guard.
|
|
302
|
+
|
|
303
|
+
A refusal is `TransportError`: `Host "<host>" is blocked for "<url>".`, or `IP "<ip>" is
|
|
304
|
+
blocked for "<url>".` for an address written out. With no `subnets` it refuses
|
|
305
|
+
`IpUtils.PRIVATE_SUBNETS` from [xtr-http-foundation](../xtr-http-foundation) — loopback,
|
|
306
|
+
private, link-local, carrier-grade NAT, unique-local, multicast, reserved and unspecified
|
|
307
|
+
alike. `subnets` replaces that set and `allow_list` opens one receiver without opening the
|
|
308
|
+
network it sits in; both take addresses or CIDR networks, never host names. What a host
|
|
309
|
+
resolved to is remembered for the client's life, and `reset()` forgets it.
|
|
310
|
+
|
|
311
|
+
### `MockHttpClient` — answer from a script
|
|
312
|
+
|
|
313
|
+
The same client as any other here — the options it refuses, the defaults it starts from and the
|
|
314
|
+
way a request and its options are merged are all the ones a real transfer uses — with the
|
|
315
|
+
transfer replaced by what a test wrote out. Nothing is opened.
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
from xtr_http_client import JsonMockResponse, MockHttpClient, MockResponse
|
|
319
|
+
|
|
320
|
+
client = MockHttpClient([JsonMockResponse({"id": 7}, {"http_code": 201}), MockResponse(b"ok")])
|
|
321
|
+
|
|
322
|
+
async with await client.request("POST", "/orders") as response:
|
|
323
|
+
assert response.get_status_code() == 201
|
|
324
|
+
assert await response.to_array() == {"id": 7}
|
|
325
|
+
|
|
326
|
+
assert client.get_requests_count() == 1
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Where the answers come from is whatever a test finds easiest to write: nothing, and every
|
|
330
|
+
request is an empty 200; one `MockResponse`, which answers one request; an iterable of them,
|
|
331
|
+
one per request in order; or a callable handed the method, the URL and the merged options,
|
|
332
|
+
which is how a test asserts on the request that was made and answers differently depending on
|
|
333
|
+
it — it may be async, and an iterable may hold callables too.
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
from xtr_http_client_contracts import RequestOptions
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def answer(method: str, url: str, options: RequestOptions) -> MockResponse:
|
|
340
|
+
assert options["auth_bearer"] == "t0ken"
|
|
341
|
+
return JsonMockResponse({"url": url})
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
client = MockHttpClient(answer)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
A request past the end of an iterable is a mistake and is reported as one — a test making more
|
|
348
|
+
requests than it wrote answers for is no longer testing what it says. Nothing is followed and
|
|
349
|
+
nothing is resolved: a 3xx is the answer, and `base_uri` starts at `https://example.com` so a
|
|
350
|
+
test can say `/orders` and mean something. `set_response_factory(...)` changes the answers
|
|
351
|
+
mid-test, keeping the count; `reset()` clears the count, keeping the answers.
|
|
352
|
+
|
|
353
|
+
`MockResponse(body, info)` writes out the answer: `body` is bytes, text, or an iterable of
|
|
354
|
+
bytes handed over a piece at a time, and `info` is what the transfer is to claim about itself —
|
|
355
|
+
`http_code` (200 when left out), `response_headers`, and `error` for a transfer that is to fail
|
|
356
|
+
rather than answer. Any other entry is carried through to `get_info()`.
|
|
357
|
+
`JsonMockResponse(value, info)` encodes a value and labels it `application/json` unless the
|
|
358
|
+
script named a content type of its own, and `JsonMockResponse.from_file(path)` reads a recorded
|
|
359
|
+
document — now rather than when the response is issued, so a wrong path is reported where it
|
|
360
|
+
was written.
|
|
361
|
+
|
|
362
|
+
A written response is half a response: the method, the URL and the options it answers are the
|
|
363
|
+
client's, filled in when the client issues it. Reading one no client issued raises
|
|
364
|
+
`InvalidArgumentError`, and `get_request_method()`, `get_request_url()` and
|
|
365
|
+
`get_request_options()` are what a test asserts the issued half on.
|
|
366
|
+
|
|
367
|
+
## Use in an application
|
|
368
|
+
|
|
369
|
+
Everything adding this package to an application on
|
|
370
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
371
|
+
removing it undoes.
|
|
372
|
+
|
|
373
|
+
- **Install** — `uv add "xtr-http-client[di]"`; add `throttling` to pace a client with an
|
|
374
|
+
[xtr-rate-limiter](../xtr-rate-limiter) limiter, `http2` to ask for HTTP/2.
|
|
375
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
376
|
+
`HttpClientBundle`. There is no config file, environment or ignore line to write; it prints the
|
|
377
|
+
step to name the services the application calls in `config/http_client.py`, which a recipe
|
|
378
|
+
cannot make for you (see [xtr-recipes](../xtr-recipes)).
|
|
379
|
+
- **Activate** — `HttpClientBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`,
|
|
380
|
+
imported from `xtr_http_client.bundle`.
|
|
381
|
+
- **Brings along** — the rate limiter bundle when xtr-rate-limiter is installed.
|
|
382
|
+
- **Configure** — optional: with no configuration there is one client, an
|
|
383
|
+
`HttpClientInterface` sending requests as they are given. Default options, a client per
|
|
384
|
+
service, retries, a pace and a mock for tests go in `<app>/config/http_client.py`, a
|
|
385
|
+
`@configure` function returning `HttpClientConfig` — see [Kernel / bundle](#kernel--bundle).
|
|
386
|
+
- **Environment** — nothing. A credential given as `env(...)` is read when the client needing
|
|
387
|
+
it is built.
|
|
388
|
+
- **Ignore** — nothing.
|
|
389
|
+
- **Remove** — drop the `BUNDLES` entry, delete `<app>/config/http_client.py`, then
|
|
390
|
+
`uv remove xtr-http-client`.
|
|
391
|
+
- **Check** — `debug:bundles` shows `http_client` as `listed` and `active`.
|
|
392
|
+
|
|
393
|
+
## Kernel / bundle
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
# app/bundles.py
|
|
397
|
+
from xtr_http_client.bundle import HttpClientBundle
|
|
398
|
+
|
|
399
|
+
BUNDLES = {HttpClientBundle: {"all": True}}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
# app/config/http_client.py
|
|
404
|
+
from xtr_dependency_injection import configure, env
|
|
405
|
+
|
|
406
|
+
from xtr_http_client.bundle import (
|
|
407
|
+
ClientOptionsConfig,
|
|
408
|
+
HttpClientConfig,
|
|
409
|
+
RetryFailedConfig,
|
|
410
|
+
ScopedClientConfig,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@configure
|
|
415
|
+
def http_client() -> HttpClientConfig:
|
|
416
|
+
return HttpClientConfig(
|
|
417
|
+
default_options=ClientOptionsConfig(
|
|
418
|
+
timeout=10, retry_failed=RetryFailedConfig(enabled=True)
|
|
419
|
+
),
|
|
420
|
+
scoped_clients={
|
|
421
|
+
"github": ScopedClientConfig(
|
|
422
|
+
base_uri="https://api.github.com/",
|
|
423
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
424
|
+
auth_bearer=env("GITHUB_TOKEN"),
|
|
425
|
+
),
|
|
426
|
+
},
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The default client is the unqualified `HttpClientInterface`; each scoped client is the
|
|
431
|
+
`HttpClientInterface` qualified by its name:
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
from typing import Annotated
|
|
435
|
+
|
|
436
|
+
from xtr_dependency_injection import Target, as_service
|
|
437
|
+
from xtr_http_client_contracts import HttpClientInterface
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@as_service
|
|
441
|
+
class GithubRepositories:
|
|
442
|
+
def __init__(self, github: Annotated[HttpClientInterface, Target("github")]) -> None:
|
|
443
|
+
self._github = github
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
`HttpClientConfig`:
|
|
447
|
+
|
|
448
|
+
| Field | Default | What it sets |
|
|
449
|
+
|---|---|---|
|
|
450
|
+
| `max_host_connections` | `6` | how many connections one pool may hold |
|
|
451
|
+
| `default_options` | `ClientOptionsConfig()` | what every request starts from, and the default client's layers |
|
|
452
|
+
| `scoped_clients` | `{}` | a client per service, by name |
|
|
453
|
+
| `mock_response_factory` | `False` | answer every request from `MockHttpClient` instead of the network |
|
|
454
|
+
| `tracing` | `False` | wrap every client in a `TraceableHttpClient` |
|
|
455
|
+
|
|
456
|
+
`ClientOptionsConfig` carries the request options of the same name — `headers`, `timeout`,
|
|
457
|
+
`max_redirects`, `proxy`, `verify_peer` and the rest, `None` leaving each to the client — and
|
|
458
|
+
two layers: `retry_failed`, a `RetryFailedConfig` (`enabled`, `max_retries`, `delay`,
|
|
459
|
+
`multiplier`, `max_delay`, `jitter`, `http_codes`, `base_uris`, or a `retry_strategy` service
|
|
460
|
+
instead of the numbers), and `rate_limiter`, the name of the xtr-rate-limiter limiter requests
|
|
461
|
+
wait their turn from. `ScopedClientConfig` adds `scope` or `base_uri` (one is required),
|
|
462
|
+
`auth_basic`, `auth_bearer`, `query` and its own `mock_response_factory`; the
|
|
463
|
+
`retry_failed` and `rate_limiter` it leaves unset are the default options' own.
|
|
464
|
+
|
|
465
|
+
Each client is built, from the inside out, as the transport, the pace of its limiter, its
|
|
466
|
+
scope, its retries — outside the scope when they move along `base_uris` — and the trace.
|
|
467
|
+
Every client shares one transport, and so its connections; nothing is opened until a request
|
|
468
|
+
is made, and the connections are closed with the container. With `mock_response_factory`, the
|
|
469
|
+
`MockHttpClient` service is the transport: a test scripts it with `set_response_factory`.
|
|
470
|
+
|
|
471
|
+
## Errors
|
|
472
|
+
|
|
473
|
+
The hierarchy is the [contract's](../xtr-http-client-contracts#errors), re-exported from
|
|
474
|
+
`xtr_http_client.exception` so one import answers "what can this client raise":
|
|
475
|
+
|
|
476
|
+
| Error | Raised when |
|
|
477
|
+
|---|---|
|
|
478
|
+
| `HttpClientError` | the root of all of them |
|
|
479
|
+
| `TransportError` | no answer came — a refused connection, a broken transfer, a cancelled response, a guarded address |
|
|
480
|
+
| `TransportTimeoutError` | no answer came in time; a `TransportError` |
|
|
481
|
+
| `HttpError` | there is an answer and it is not a success |
|
|
482
|
+
| `RedirectionError` | a 3xx read with `throw`, once `max_redirects` was reached |
|
|
483
|
+
| `ClientError` | a 4xx read with `throw` |
|
|
484
|
+
| `ServerError` | a 5xx read with `throw` |
|
|
485
|
+
| `DecodingError` | the body is not what it was read as |
|
|
486
|
+
| `JsonError` | the body is not the JSON object it was read as; a `DecodingError` |
|
|
487
|
+
| `InvalidArgumentError` | the request is one a client refuses to make, before anything is sent |
|
|
488
|
+
|
|
489
|
+
An unsuccessful status names itself: `HTTP 404 returned for "<url>".`, the status line the
|
|
490
|
+
server sent, or the `title` and `detail` of the body when that body is a JSON error document.
|
|
491
|
+
|
|
492
|
+
## Development
|
|
493
|
+
|
|
494
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
495
|
+
`packages/xtr-http-client`; run the commands below from there. The `python-xtr-http-client`
|
|
496
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
497
|
+
|
|
498
|
+
```sh
|
|
499
|
+
uv sync --all-extras
|
|
500
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
## License
|
|
504
|
+
|
|
505
|
+
MIT — see [LICENSE](LICENSE).
|