xtr-http-client 4.0.2__py3-none-any.whl
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/.agents/skills/xtr-http-client/SKILL.md +289 -0
- xtr_http_client/__init__.py +47 -0
- xtr_http_client/bundle/__init__.py +17 -0
- xtr_http_client/bundle/_clients.py +217 -0
- xtr_http_client/bundle/client_options_config.py +79 -0
- xtr_http_client/bundle/http_client_bundle.py +85 -0
- xtr_http_client/bundle/http_client_config.py +75 -0
- xtr_http_client/bundle/retry_failed_config.py +77 -0
- xtr_http_client/bundle/scoped_client_config.py +99 -0
- xtr_http_client/exception/__init__.py +40 -0
- xtr_http_client/exception/_http_error_message.py +129 -0
- xtr_http_client/http_client.py +57 -0
- xtr_http_client/http_client_mixin.py +176 -0
- xtr_http_client/httpx_http_client.py +327 -0
- xtr_http_client/internal/__init__.py +13 -0
- xtr_http_client/internal/debug_type.py +14 -0
- xtr_http_client/internal/httpx_body.py +88 -0
- xtr_http_client/internal/httpx_clients.py +203 -0
- xtr_http_client/internal/httpx_errors.py +63 -0
- xtr_http_client/internal/httpx_redirect.py +63 -0
- xtr_http_client/internal/httpx_request.py +272 -0
- xtr_http_client/internal/mock_head.py +144 -0
- xtr_http_client/internal/mock_info.py +86 -0
- xtr_http_client/internal/option_merge.py +108 -0
- xtr_http_client/internal/response_body.py +270 -0
- xtr_http_client/internal/retry_count_response.py +148 -0
- xtr_http_client/internal/retry_delay.py +45 -0
- xtr_http_client/internal/retry_options.py +98 -0
- xtr_http_client/mock_http_client.py +253 -0
- xtr_http_client/no_private_network_http_client.py +342 -0
- xtr_http_client/py.typed +0 -0
- xtr_http_client/recipe/__init__.py +15 -0
- xtr_http_client/recipe/manifest.toml +23 -0
- xtr_http_client/response/__init__.py +10 -0
- xtr_http_client/response/http_response.py +412 -0
- xtr_http_client/response/httpx_response.py +173 -0
- xtr_http_client/response/json_mock_response.py +119 -0
- xtr_http_client/response/mock_response.py +375 -0
- xtr_http_client/retry/__init__.py +25 -0
- xtr_http_client/retry/generic_retry_strategy.py +215 -0
- xtr_http_client/retry/retry_context.py +73 -0
- xtr_http_client/retry/retry_strategy_interface.py +74 -0
- xtr_http_client/retryable_http_client.py +346 -0
- xtr_http_client/scoping_http_client.py +303 -0
- xtr_http_client/throttling_http_client.py +143 -0
- xtr_http_client/traceable_http_client.py +406 -0
- xtr_http_client-4.0.2.dist-info/METADATA +505 -0
- xtr_http_client-4.0.2.dist-info/RECORD +51 -0
- xtr_http_client-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_client-4.0.2.dist-info/entry_points.txt +6 -0
- xtr_http_client-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""The client that refuses to reach an address only this network can see."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from ipaddress import ip_address
|
|
9
|
+
from typing import TYPE_CHECKING, Final, cast, final
|
|
10
|
+
from urllib.parse import urljoin
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
from typing_extensions import Unpack, override
|
|
14
|
+
from xtr_http_client_contracts import HttpClientInterface
|
|
15
|
+
from xtr_http_foundation import IpUtils
|
|
16
|
+
from xtr_http_message import Request, Stream, Uri
|
|
17
|
+
from xtr_service_contracts import ResetInterface
|
|
18
|
+
|
|
19
|
+
from .exception import InvalidArgumentError, TransportError
|
|
20
|
+
from .internal.option_merge import as_options
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
24
|
+
from typing import Self
|
|
25
|
+
|
|
26
|
+
from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
|
|
27
|
+
from xtr_http_message_contracts import RequestInterface
|
|
28
|
+
|
|
29
|
+
__all__ = ["NoPrivateNetworkHttpClient"]
|
|
30
|
+
|
|
31
|
+
_PORTS: Final[Mapping[str, int]] = {"http": 80, "https": 443}
|
|
32
|
+
_REDIRECT: Final = range(300, 400)
|
|
33
|
+
_TO_GET: Final = (301, 302)
|
|
34
|
+
_SEE_OTHER: Final = 303
|
|
35
|
+
_HOPS: Final = 20
|
|
36
|
+
_BODY: Final = ("body", "json")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@final
|
|
40
|
+
class NoPrivateNetworkHttpClient(HttpClientInterface, ResetInterface):
|
|
41
|
+
"""Wraps a client and refuses every request that leads inwards.
|
|
42
|
+
|
|
43
|
+
A URL that came from outside is data, so without this a request is a way
|
|
44
|
+
to make this process reach an address only this process can see: another
|
|
45
|
+
service on the same network, an admin port, a metadata endpoint handing
|
|
46
|
+
out credentials.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
client = NoPrivateNetworkHttpClient(inner)
|
|
50
|
+
response = await client.request("GET", "https://example.com/avatar")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The check runs on what the host *resolves to*, not on how it is written,
|
|
54
|
+
and one refused address refuses the whole request — whichever of them the
|
|
55
|
+
connection would pick, nothing is sent. The request is then pinned to the
|
|
56
|
+
address that was checked, through the ``resolve`` option, so a name whose
|
|
57
|
+
records change between the check and the connection reaches what was
|
|
58
|
+
vetted or nothing at all.
|
|
59
|
+
|
|
60
|
+
Redirects are followed here rather than by the wrapped client, because a
|
|
61
|
+
hop nobody checked would walk straight past the guard: each one is
|
|
62
|
+
resolved and checked like the request that started the chain.
|
|
63
|
+
|
|
64
|
+
What a host resolves to is remembered for as long as this client lives,
|
|
65
|
+
so a chain of hops back to the same host asks the resolver once;
|
|
66
|
+
:meth:`reset` forgets it.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
__slots__ = ("_allow_list", "_client", "_dns_cache", "_resolver", "_subnets")
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
client: HttpClientInterface,
|
|
74
|
+
subnets: str | Sequence[str] | None = None,
|
|
75
|
+
allow_list: str | Sequence[str] = (),
|
|
76
|
+
resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Guard ``client``, refusing whichever networks ``subnets`` names.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
client: The client every vetted request is handed to.
|
|
82
|
+
subnets: The networks to refuse, in CIDR notation or as single
|
|
83
|
+
addresses, replacing the default. ``None`` refuses the
|
|
84
|
+
networks only this one can reach.
|
|
85
|
+
allow_list: Networks or addresses exempt from the refusal, which
|
|
86
|
+
is how one internal receiver is opened without opening the
|
|
87
|
+
network it sits in. Addresses only, never host names: the
|
|
88
|
+
check runs on resolved addresses, and a name would itself
|
|
89
|
+
have to be resolved, and could point anywhere by then.
|
|
90
|
+
resolver: What every address of a host and port is asked of.
|
|
91
|
+
``None`` asks the platform's own resolver.
|
|
92
|
+
"""
|
|
93
|
+
self._client = client
|
|
94
|
+
self._subnets = _entries(subnets) if subnets is not None else IpUtils.PRIVATE_SUBNETS
|
|
95
|
+
self._allow_list = _entries(allow_list)
|
|
96
|
+
self._resolver = resolver if resolver is not None else _getaddrinfo
|
|
97
|
+
self._dns_cache: dict[str, Sequence[str]] = {}
|
|
98
|
+
|
|
99
|
+
@override
|
|
100
|
+
async def request(
|
|
101
|
+
self,
|
|
102
|
+
method: str,
|
|
103
|
+
url: str,
|
|
104
|
+
**options: Unpack[RequestOptions],
|
|
105
|
+
) -> HttpResponseInterface:
|
|
106
|
+
"""Make a request for ``method`` and ``url``, once where it leads is vetted.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
method: The HTTP method, in upper case.
|
|
110
|
+
url: The URL to request, absolute or relative to ``base_uri``.
|
|
111
|
+
**options: How the request is to be made.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Whatever the wrapped client answers at the end of the chain.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
InvalidArgumentError: For an invalid method, URL or option.
|
|
118
|
+
TransportError: When the host is, or resolves to, a refused
|
|
119
|
+
address, and when no answer came.
|
|
120
|
+
"""
|
|
121
|
+
return await self.send_request(Request(method, url), **options)
|
|
122
|
+
|
|
123
|
+
@override
|
|
124
|
+
async def send_request(
|
|
125
|
+
self,
|
|
126
|
+
request: RequestInterface,
|
|
127
|
+
**options: Unpack[RequestOptions],
|
|
128
|
+
) -> HttpResponseInterface:
|
|
129
|
+
"""Send ``request``, vetting every host the chain reaches.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
request: The request to send.
|
|
133
|
+
**options: What the request leaves out.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Whatever the wrapped client answers at the end of the chain.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
InvalidArgumentError: For an invalid method, URL or option, and
|
|
140
|
+
for a URL that is relative with no ``base_uri`` to resolve it.
|
|
141
|
+
TransportError: When a host is, or resolves to, a refused
|
|
142
|
+
address, and when no answer came.
|
|
143
|
+
"""
|
|
144
|
+
allowed = _hops(options.get("max_redirects"))
|
|
145
|
+
given: dict[str, object] = dict(options)
|
|
146
|
+
outgoing = _absolute(request, given.get("base_uri"))
|
|
147
|
+
hops = 0
|
|
148
|
+
|
|
149
|
+
while True:
|
|
150
|
+
response = await self._sent(outgoing, given)
|
|
151
|
+
following = _next_hop(outgoing, response) if hops < allowed else None
|
|
152
|
+
if following is None:
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
await response.cancel()
|
|
156
|
+
if following.get_method() != outgoing.get_method():
|
|
157
|
+
given = {name: value for name, value in given.items() if name not in _BODY}
|
|
158
|
+
outgoing = following
|
|
159
|
+
hops += 1
|
|
160
|
+
|
|
161
|
+
@override
|
|
162
|
+
def with_options(self, **options: Unpack[RequestOptions]) -> Self:
|
|
163
|
+
"""Return a copy around a wrapped client whose defaults have ``options`` over them.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
**options: The defaults of the wrapped client of the copy.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
A new client, guarding the same networks and sharing this one's
|
|
170
|
+
address cache, so a host resolved through either is known to both.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
InvalidArgumentError: When an option is invalid.
|
|
174
|
+
"""
|
|
175
|
+
narrowed = type(self)(
|
|
176
|
+
self._client.with_options(**options),
|
|
177
|
+
self._subnets,
|
|
178
|
+
self._allow_list,
|
|
179
|
+
self._resolver,
|
|
180
|
+
)
|
|
181
|
+
narrowed._dns_cache = self._dns_cache # noqa: SLF001 — same class, same cache by design
|
|
182
|
+
|
|
183
|
+
return narrowed
|
|
184
|
+
|
|
185
|
+
@override
|
|
186
|
+
async def reset(self) -> None:
|
|
187
|
+
"""Forget what every host resolved to, and reset the wrapped client."""
|
|
188
|
+
self._dns_cache.clear()
|
|
189
|
+
|
|
190
|
+
if isinstance(self._client, ResetInterface):
|
|
191
|
+
await self._client.reset()
|
|
192
|
+
|
|
193
|
+
async def _sent(
|
|
194
|
+
self,
|
|
195
|
+
outgoing: RequestInterface,
|
|
196
|
+
options: Mapping[str, object],
|
|
197
|
+
) -> HttpResponseInterface:
|
|
198
|
+
"""Hand one hop to the wrapped client, pinned to the address it was vetted at."""
|
|
199
|
+
uri = outgoing.get_uri()
|
|
200
|
+
host = uri.get_host()
|
|
201
|
+
url = str(uri)
|
|
202
|
+
port = uri.get_port() or _PORTS.get(uri.get_scheme(), 0)
|
|
203
|
+
address = await self._vetted(host, port, options.get("resolve"), url)
|
|
204
|
+
|
|
205
|
+
sent: Mapping[str, object] = {
|
|
206
|
+
**options,
|
|
207
|
+
# Zero hops, because the chain is walked here: a hop the wrapped
|
|
208
|
+
# client followed on its own would reach a host nobody checked.
|
|
209
|
+
"max_redirects": 0,
|
|
210
|
+
"resolve": {**_text_mapping(options.get("resolve")), host: address},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return await self._client.send_request(outgoing, **as_options(sent))
|
|
214
|
+
|
|
215
|
+
async def _vetted(self, host: str, port: int, pinned: object, url: str) -> str:
|
|
216
|
+
"""The address this request may go to, every one of the host's having passed.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
TransportError: When any address the host has is refused, since
|
|
220
|
+
whichever one the connection would pick is one of them.
|
|
221
|
+
"""
|
|
222
|
+
literal = _literal(host)
|
|
223
|
+
if literal is not None:
|
|
224
|
+
addresses: Sequence[str] = (literal,)
|
|
225
|
+
else:
|
|
226
|
+
named = _text_mapping(pinned).get(host)
|
|
227
|
+
addresses = (named,) if named else await self._addresses(host, port, url)
|
|
228
|
+
|
|
229
|
+
for address in addresses:
|
|
230
|
+
if self._refuses(address):
|
|
231
|
+
blocked = f'IP "{address}"' if literal is not None else f'Host "{host}"'
|
|
232
|
+
raise TransportError(f'{blocked} is blocked for "{url}".')
|
|
233
|
+
|
|
234
|
+
return addresses[0]
|
|
235
|
+
|
|
236
|
+
async def _addresses(self, host: str, port: int, url: str) -> Sequence[str]:
|
|
237
|
+
"""Every address ``host`` has, asked once and remembered.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
TransportError: When the host has no address at all, which is a
|
|
241
|
+
request that can reach nothing rather than one to send.
|
|
242
|
+
"""
|
|
243
|
+
cached = self._dns_cache.get(host)
|
|
244
|
+
if cached is not None:
|
|
245
|
+
return cached
|
|
246
|
+
|
|
247
|
+
resolved = tuple(await self._resolver(host, port))
|
|
248
|
+
if not resolved:
|
|
249
|
+
raise TransportError(f'Host "{host}" could not be resolved for "{url}".')
|
|
250
|
+
|
|
251
|
+
self._dns_cache[host] = resolved
|
|
252
|
+
|
|
253
|
+
return resolved
|
|
254
|
+
|
|
255
|
+
def _refuses(self, address: str) -> bool:
|
|
256
|
+
"""Whether ``address`` may not be connected to.
|
|
257
|
+
|
|
258
|
+
The allow list is read first, so one internal receiver is opened
|
|
259
|
+
without opening the network it sits in.
|
|
260
|
+
"""
|
|
261
|
+
if self._allow_list and IpUtils.check_ip(address, self._allow_list):
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
return IpUtils.check_ip(address, self._subnets)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _getaddrinfo(host: str, port: int) -> Sequence[str]:
|
|
268
|
+
"""Every address the platform's resolver gives for ``host`` at ``port``."""
|
|
269
|
+
resolved = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
270
|
+
|
|
271
|
+
return [str(sockaddr[0]) for *_, sockaddr in resolved]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _absolute(request: RequestInterface, base: object) -> RequestInterface:
|
|
275
|
+
"""``request`` carrying a URL with a host in it, resolved against ``base``.
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
InvalidArgumentError: When nothing says which host the request is
|
|
279
|
+
for, since there is then no address to check.
|
|
280
|
+
"""
|
|
281
|
+
if not request.get_uri().get_host() and isinstance(base, str):
|
|
282
|
+
request = request.with_uri(Uri(urljoin(base, str(request.get_uri()))))
|
|
283
|
+
|
|
284
|
+
if not request.get_uri().get_host():
|
|
285
|
+
raise InvalidArgumentError(
|
|
286
|
+
f'Invalid URL "{request.get_uri()}": it names no host, so there is '
|
|
287
|
+
"no address to check it against."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return request
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _next_hop(
|
|
294
|
+
outgoing: RequestInterface, response: HttpResponseInterface
|
|
295
|
+
) -> RequestInterface | None:
|
|
296
|
+
"""Where the answer points, or ``None`` when it points nowhere."""
|
|
297
|
+
status = response.get_status_code()
|
|
298
|
+
location = response.get_header_line("location")
|
|
299
|
+
if status not in _REDIRECT or not location:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
following = outgoing.with_uri(Uri(urljoin(str(outgoing.get_uri()), location)))
|
|
303
|
+
if status == _SEE_OTHER or (status in _TO_GET and following.get_method() == "POST"):
|
|
304
|
+
# What the body was sent to say has been answered; carrying it on
|
|
305
|
+
# would say it again, to a resource that was never offered it.
|
|
306
|
+
return following.with_method("GET").with_body(Stream(BytesIO(b"")))
|
|
307
|
+
|
|
308
|
+
return following
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _literal(host: str) -> str | None:
|
|
312
|
+
"""The address ``host`` writes out, or ``None`` when it is a name."""
|
|
313
|
+
text = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
|
314
|
+
try:
|
|
315
|
+
return str(ip_address(text))
|
|
316
|
+
except ValueError:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _entries(value: str | Sequence[str]) -> tuple[str, ...]:
|
|
321
|
+
"""One network or several, as several."""
|
|
322
|
+
return (value,) if isinstance(value, str) else tuple(value)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _hops(value: object) -> int:
|
|
326
|
+
"""How many redirects may be followed; zero or less follows none."""
|
|
327
|
+
if isinstance(value, int):
|
|
328
|
+
return max(0, value)
|
|
329
|
+
|
|
330
|
+
return _HOPS
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _text_mapping(value: object) -> dict[str, str]:
|
|
334
|
+
"""``value`` as a mapping of text to text, keeping only the entries that are."""
|
|
335
|
+
if not isinstance(value, Mapping):
|
|
336
|
+
return {}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
name: entry
|
|
340
|
+
for name, entry in cast("Mapping[str, object]", value).items()
|
|
341
|
+
if isinstance(entry, str)
|
|
342
|
+
}
|
xtr_http_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""This directory holds the package's recipe: its manifest.
|
|
2
|
+
|
|
3
|
+
The recipe is what *Use in an application* in the README lists, written as
|
|
4
|
+
data: the bundle to list, and the step only a person can take — which services
|
|
5
|
+
the application calls is its own decision. An application applies it with
|
|
6
|
+
``xtr-recipes recipes:sync``, which finds this module through the
|
|
7
|
+
``xtr_recipes`` entry point.
|
|
8
|
+
|
|
9
|
+
Nothing is importable from here. The manifest is read as a file, so a recipe
|
|
10
|
+
is the same from a wheel as from a working tree.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# The recipe for xtr-http-client: what *Use in an application* in the README
|
|
2
|
+
# takes, as data. Keep the two in step — the README is the source.
|
|
3
|
+
|
|
4
|
+
[bundles]
|
|
5
|
+
# "<module>:<class>" = the environments it is listed with, exactly as in BUNDLES.
|
|
6
|
+
"xtr_http_client.bundle:HttpClientBundle" = { all = true }
|
|
7
|
+
|
|
8
|
+
# No [files]: configuring is optional. With none there is one client sending
|
|
9
|
+
# requests as they are given; an application naming the services it calls
|
|
10
|
+
# writes config/http_client.py itself, which is what [notes] asks for.
|
|
11
|
+
|
|
12
|
+
# No [env]: nothing is required. A credential an application gives as env(...)
|
|
13
|
+
# is read when the client needing it is built.
|
|
14
|
+
|
|
15
|
+
[gitignore]
|
|
16
|
+
# Nothing: the bundle writes no file.
|
|
17
|
+
lines = []
|
|
18
|
+
|
|
19
|
+
[notes]
|
|
20
|
+
steps = [
|
|
21
|
+
"Name the services the application calls in config/http_client.py — a scoped client per base URI, with its headers and credentials — when one client with no defaults is not enough.",
|
|
22
|
+
]
|
|
23
|
+
check = ["<script> debug:bundles — http_client is listed and active"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The response a client hands back, and what a caller reads off it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .http_response import HttpResponse
|
|
6
|
+
from .httpx_response import to_http_response
|
|
7
|
+
from .json_mock_response import JsonMockResponse
|
|
8
|
+
from .mock_response import MockResponse
|
|
9
|
+
|
|
10
|
+
__all__ = ["HttpResponse", "JsonMockResponse", "MockResponse", "to_http_response"]
|