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