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.
Files changed (51) hide show
  1. xtr_http_client-4.0.2/LICENSE +22 -0
  2. xtr_http_client-4.0.2/PKG-INFO +505 -0
  3. xtr_http_client-4.0.2/README.md +469 -0
  4. xtr_http_client-4.0.2/pyproject.toml +178 -0
  5. xtr_http_client-4.0.2/pyproject.toml.orig +188 -0
  6. xtr_http_client-4.0.2/src/xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
  7. xtr_http_client-4.0.2/src/xtr_http_client/__init__.py +47 -0
  8. xtr_http_client-4.0.2/src/xtr_http_client/bundle/__init__.py +17 -0
  9. xtr_http_client-4.0.2/src/xtr_http_client/bundle/_clients.py +217 -0
  10. xtr_http_client-4.0.2/src/xtr_http_client/bundle/client_options_config.py +79 -0
  11. xtr_http_client-4.0.2/src/xtr_http_client/bundle/http_client_bundle.py +85 -0
  12. xtr_http_client-4.0.2/src/xtr_http_client/bundle/http_client_config.py +75 -0
  13. xtr_http_client-4.0.2/src/xtr_http_client/bundle/retry_failed_config.py +77 -0
  14. xtr_http_client-4.0.2/src/xtr_http_client/bundle/scoped_client_config.py +99 -0
  15. xtr_http_client-4.0.2/src/xtr_http_client/exception/__init__.py +40 -0
  16. xtr_http_client-4.0.2/src/xtr_http_client/exception/_http_error_message.py +129 -0
  17. xtr_http_client-4.0.2/src/xtr_http_client/http_client.py +57 -0
  18. xtr_http_client-4.0.2/src/xtr_http_client/http_client_mixin.py +176 -0
  19. xtr_http_client-4.0.2/src/xtr_http_client/httpx_http_client.py +327 -0
  20. xtr_http_client-4.0.2/src/xtr_http_client/internal/__init__.py +13 -0
  21. xtr_http_client-4.0.2/src/xtr_http_client/internal/debug_type.py +14 -0
  22. xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_body.py +88 -0
  23. xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_clients.py +203 -0
  24. xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_errors.py +63 -0
  25. xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_redirect.py +63 -0
  26. xtr_http_client-4.0.2/src/xtr_http_client/internal/httpx_request.py +272 -0
  27. xtr_http_client-4.0.2/src/xtr_http_client/internal/mock_head.py +144 -0
  28. xtr_http_client-4.0.2/src/xtr_http_client/internal/mock_info.py +86 -0
  29. xtr_http_client-4.0.2/src/xtr_http_client/internal/option_merge.py +108 -0
  30. xtr_http_client-4.0.2/src/xtr_http_client/internal/response_body.py +270 -0
  31. xtr_http_client-4.0.2/src/xtr_http_client/internal/retry_count_response.py +148 -0
  32. xtr_http_client-4.0.2/src/xtr_http_client/internal/retry_delay.py +45 -0
  33. xtr_http_client-4.0.2/src/xtr_http_client/internal/retry_options.py +98 -0
  34. xtr_http_client-4.0.2/src/xtr_http_client/mock_http_client.py +253 -0
  35. xtr_http_client-4.0.2/src/xtr_http_client/no_private_network_http_client.py +342 -0
  36. xtr_http_client-4.0.2/src/xtr_http_client/py.typed +0 -0
  37. xtr_http_client-4.0.2/src/xtr_http_client/recipe/__init__.py +15 -0
  38. xtr_http_client-4.0.2/src/xtr_http_client/recipe/manifest.toml +23 -0
  39. xtr_http_client-4.0.2/src/xtr_http_client/response/__init__.py +10 -0
  40. xtr_http_client-4.0.2/src/xtr_http_client/response/http_response.py +412 -0
  41. xtr_http_client-4.0.2/src/xtr_http_client/response/httpx_response.py +173 -0
  42. xtr_http_client-4.0.2/src/xtr_http_client/response/json_mock_response.py +119 -0
  43. xtr_http_client-4.0.2/src/xtr_http_client/response/mock_response.py +375 -0
  44. xtr_http_client-4.0.2/src/xtr_http_client/retry/__init__.py +25 -0
  45. xtr_http_client-4.0.2/src/xtr_http_client/retry/generic_retry_strategy.py +215 -0
  46. xtr_http_client-4.0.2/src/xtr_http_client/retry/retry_context.py +73 -0
  47. xtr_http_client-4.0.2/src/xtr_http_client/retry/retry_strategy_interface.py +74 -0
  48. xtr_http_client-4.0.2/src/xtr_http_client/retryable_http_client.py +346 -0
  49. xtr_http_client-4.0.2/src/xtr_http_client/scoping_http_client.py +303 -0
  50. xtr_http_client-4.0.2/src/xtr_http_client/throttling_http_client.py +143 -0
  51. 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).