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,406 @@
|
|
|
1
|
+
"""The client that writes down every request made through it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Final, TypedDict, cast, final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import Unpack, override
|
|
9
|
+
from xtr_http_client_contracts import HttpClientInterface, HttpResponseInterface
|
|
10
|
+
from xtr_service_contracts import ResetInterface
|
|
11
|
+
|
|
12
|
+
from .exception import TransportError
|
|
13
|
+
from .internal.option_merge import as_options
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Self
|
|
19
|
+
|
|
20
|
+
from xtr_http_client_contracts import RequestOptions
|
|
21
|
+
from xtr_http_message_contracts import RequestInterface, StreamInterface
|
|
22
|
+
|
|
23
|
+
__all__ = ["TraceableHttpClient", "TracedRequest"]
|
|
24
|
+
|
|
25
|
+
# The options that carry what was sent, which a request may ask to be left
|
|
26
|
+
# out of its record.
|
|
27
|
+
_BODIES: Final = frozenset({"body", "json"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TracedRequest(TypedDict):
|
|
31
|
+
"""The record of one request made through a tracing client.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
method: The HTTP method the request was made with.
|
|
35
|
+
url: The URL it was made to, as it was asked for.
|
|
36
|
+
options: The options it was made with, as they were given — without
|
|
37
|
+
``body`` and ``json`` when ``extra.trace_content`` turned the
|
|
38
|
+
bodies off.
|
|
39
|
+
info: What the transfer knew about itself when the response arrived.
|
|
40
|
+
Empty until it arrives, and carrying ``error`` alone when nothing
|
|
41
|
+
does.
|
|
42
|
+
content: Every byte of the body, once the caller has read it;
|
|
43
|
+
``None`` while nobody has, and ``False`` when
|
|
44
|
+
``extra.trace_content`` turned the bodies off.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
method: str
|
|
48
|
+
url: str
|
|
49
|
+
options: RequestOptions
|
|
50
|
+
info: Mapping[str, object]
|
|
51
|
+
content: bytes | bool | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@final
|
|
55
|
+
class TraceableHttpClient(HttpClientInterface, ResetInterface):
|
|
56
|
+
"""Wraps a client and writes down every request made through it.
|
|
57
|
+
|
|
58
|
+
"Which calls did this go on to make, with what, and what came back" is
|
|
59
|
+
asked of a running application constantly — by a profiler, by a test
|
|
60
|
+
asserting on a call it has no other sight of, by whoever is reading a log
|
|
61
|
+
that stops short of the call. Answered at the call site that is logging
|
|
62
|
+
around every request; here it is the client, so the code calling out is
|
|
63
|
+
unchanged and the record is one list.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
client = TraceableHttpClient(inner)
|
|
67
|
+
async with await client.request("GET", "https://example.com/orders") as response:
|
|
68
|
+
orders = await response.to_array()
|
|
69
|
+
|
|
70
|
+
traced = client.get_traced_requests()
|
|
71
|
+
assert traced[0]["method"] == "GET"
|
|
72
|
+
assert traced[0]["content"] == b'{"orders": []}'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A record is added the moment a request is made and filled in as that
|
|
76
|
+
request goes on: ``info`` when the response arrives — or the error alone,
|
|
77
|
+
when nothing arrives and the error is raised on — and ``content`` when the
|
|
78
|
+
caller reads the body. So a trace read while a request is still being
|
|
79
|
+
answered says how far it has got, rather than nothing until it is over.
|
|
80
|
+
|
|
81
|
+
The bodies are the costly half of a record and the half most likely to
|
|
82
|
+
carry a credential, so ``extra={"trace_content": False}`` leaves them out:
|
|
83
|
+
the ``body`` and ``json`` that were sent are dropped from ``options``, and
|
|
84
|
+
``content`` is ``False`` rather than the bytes. Nothing is recorded at all
|
|
85
|
+
for a request made while ``disabled`` says so, which is how a profiler
|
|
86
|
+
that collects per request stops collecting between them.
|
|
87
|
+
|
|
88
|
+
The record is the point, so a copy made by :meth:`with_options` shares it:
|
|
89
|
+
narrowing a client for one job hands out another way of making requests,
|
|
90
|
+
all of them written into the one list.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
__slots__ = ("_client", "_disabled", "_traces")
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
client: HttpClientInterface,
|
|
98
|
+
disabled: Callable[[], bool] | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Write down what ``client`` is asked to send, unless ``disabled`` says not to.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
client: The client every request is handed to.
|
|
104
|
+
disabled: Asked before each request whether to record it. ``None``
|
|
105
|
+
records every one.
|
|
106
|
+
"""
|
|
107
|
+
self._client = client
|
|
108
|
+
self._disabled = disabled
|
|
109
|
+
self._traces: list[TracedRequest] = []
|
|
110
|
+
|
|
111
|
+
@override
|
|
112
|
+
async def request(
|
|
113
|
+
self,
|
|
114
|
+
method: str,
|
|
115
|
+
url: str,
|
|
116
|
+
**options: Unpack[RequestOptions],
|
|
117
|
+
) -> HttpResponseInterface:
|
|
118
|
+
"""Make a request for ``method`` and ``url``, and write it down.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
method: The HTTP method, in upper case.
|
|
122
|
+
url: The URL to request, absolute or relative to a ``base_uri``.
|
|
123
|
+
**options: How the request is to be made.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
What the wrapped client answers, reading its body into the record
|
|
127
|
+
as the caller reads it.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
InvalidArgumentError: For an invalid method, URL or option.
|
|
131
|
+
TransportError: When no answer came, which is written down before
|
|
132
|
+
it is raised on.
|
|
133
|
+
"""
|
|
134
|
+
if self._skipped():
|
|
135
|
+
return await self._client.request(method, url, **options)
|
|
136
|
+
|
|
137
|
+
trace = self._written(method, url, options)
|
|
138
|
+
|
|
139
|
+
return await self._answered(trace, self._client.request(method, url, **options))
|
|
140
|
+
|
|
141
|
+
@override
|
|
142
|
+
async def send_request(
|
|
143
|
+
self,
|
|
144
|
+
request: RequestInterface,
|
|
145
|
+
**options: Unpack[RequestOptions],
|
|
146
|
+
) -> HttpResponseInterface:
|
|
147
|
+
"""Send ``request``, and write it down.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
request: The request to send, whose method and URI are what the
|
|
151
|
+
record names it by.
|
|
152
|
+
**options: What the request leaves out.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
What the wrapped client answers, reading its body into the record
|
|
156
|
+
as the caller reads it.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
InvalidArgumentError: For an invalid method, URL or option.
|
|
160
|
+
TransportError: When no answer came, which is written down before
|
|
161
|
+
it is raised on.
|
|
162
|
+
"""
|
|
163
|
+
if self._skipped():
|
|
164
|
+
return await self._client.send_request(request, **options)
|
|
165
|
+
|
|
166
|
+
trace = self._written(request.get_method(), str(request.get_uri()), options)
|
|
167
|
+
|
|
168
|
+
return await self._answered(trace, self._client.send_request(request, **options))
|
|
169
|
+
|
|
170
|
+
def get_traced_requests(self) -> list[TracedRequest]:
|
|
171
|
+
"""Return the record of every request made through this client so far.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A list of its own, so taking the record does not hand over the
|
|
175
|
+
one the client goes on appending to. The records in it are the
|
|
176
|
+
live ones: a request still being answered fills in the record
|
|
177
|
+
this list holds.
|
|
178
|
+
"""
|
|
179
|
+
return list(self._traces)
|
|
180
|
+
|
|
181
|
+
@override
|
|
182
|
+
def with_options(self, **options: Unpack[RequestOptions]) -> Self:
|
|
183
|
+
"""Return a copy around a wrapped client whose defaults have ``options`` over them.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
**options: The defaults of the wrapped client of the copy.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
A new client writing into this one's record, so a request made
|
|
190
|
+
through either is traced in one place; this one is left as it was.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
InvalidArgumentError: When an option is invalid.
|
|
194
|
+
"""
|
|
195
|
+
narrowed = type(self)(self._client.with_options(**options), self._disabled)
|
|
196
|
+
narrowed._traces = self._traces # noqa: SLF001 — same class, sharing the one record
|
|
197
|
+
|
|
198
|
+
return narrowed
|
|
199
|
+
|
|
200
|
+
@override
|
|
201
|
+
async def reset(self) -> None:
|
|
202
|
+
"""Forget every request written down, and reset the wrapped client.
|
|
203
|
+
|
|
204
|
+
The record goes for every client sharing it, copies included: there is
|
|
205
|
+
one record, and this ends the unit of work it was kept for.
|
|
206
|
+
"""
|
|
207
|
+
self._traces.clear()
|
|
208
|
+
|
|
209
|
+
if isinstance(self._client, ResetInterface):
|
|
210
|
+
await self._client.reset()
|
|
211
|
+
|
|
212
|
+
def _skipped(self) -> bool:
|
|
213
|
+
"""Whether the request about to be made is one nobody is recording."""
|
|
214
|
+
return self._disabled is not None and self._disabled()
|
|
215
|
+
|
|
216
|
+
def _written(self, method: str, url: str, options: RequestOptions) -> TracedRequest:
|
|
217
|
+
"""Add the record of a request about to be made, for the rest to be filled into."""
|
|
218
|
+
kept = _keeps_content(options)
|
|
219
|
+
trace: TracedRequest = {
|
|
220
|
+
"method": method,
|
|
221
|
+
"url": url,
|
|
222
|
+
"options": options if kept else _without_bodies(options),
|
|
223
|
+
"info": {},
|
|
224
|
+
"content": None if kept else False,
|
|
225
|
+
}
|
|
226
|
+
self._traces.append(trace)
|
|
227
|
+
|
|
228
|
+
return trace
|
|
229
|
+
|
|
230
|
+
async def _answered(
|
|
231
|
+
self,
|
|
232
|
+
trace: TracedRequest,
|
|
233
|
+
made: Awaitable[HttpResponseInterface],
|
|
234
|
+
) -> HttpResponseInterface:
|
|
235
|
+
"""Wait for a request, write down what came back, and keep reading recorded.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
trace: The record of the request being made.
|
|
239
|
+
made: That request, as the wrapped client is making it.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The response wrapped so that reading its body writes the bytes
|
|
243
|
+
down, or the response as it came when the bodies are not kept.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
TransportError: When no answer came, with what went wrong written
|
|
247
|
+
down first — the record of a request that failed is the half
|
|
248
|
+
worth having.
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
response = await made
|
|
252
|
+
except TransportError as error:
|
|
253
|
+
trace["info"] = {"error": str(error)}
|
|
254
|
+
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
trace["info"] = _recorded_info(response)
|
|
258
|
+
|
|
259
|
+
if trace["content"] is False:
|
|
260
|
+
return response
|
|
261
|
+
|
|
262
|
+
return _TracedResponse(response, trace)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _keeps_content(options: RequestOptions) -> bool:
|
|
266
|
+
"""Whether the bodies of a request made with ``options`` are written down.
|
|
267
|
+
|
|
268
|
+
They are, unless ``extra.trace_content`` is exactly ``False``: an entry
|
|
269
|
+
meaning "do not keep this" is the one thing it says, and anything else
|
|
270
|
+
there is a value for some other client to read.
|
|
271
|
+
"""
|
|
272
|
+
extra = options.get("extra")
|
|
273
|
+
|
|
274
|
+
return extra is None or extra.get("trace_content", True) is not False
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _without_bodies(options: RequestOptions) -> RequestOptions:
|
|
278
|
+
"""``options`` with what was sent dropped, for a request whose bodies are not kept."""
|
|
279
|
+
return as_options({name: value for name, value in options.items() if name not in _BODIES})
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _recorded_info(response: HttpResponseInterface) -> Mapping[str, object]:
|
|
283
|
+
"""What a response's transfer knows about itself, as a mapping of its own."""
|
|
284
|
+
whole = response.get_info()
|
|
285
|
+
if isinstance(whole, Mapping):
|
|
286
|
+
return dict(cast("Mapping[str, object]", whole))
|
|
287
|
+
|
|
288
|
+
return {}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@final
|
|
292
|
+
class _TracedResponse(HttpResponseInterface):
|
|
293
|
+
"""A response that writes its body into a record once the caller reads it.
|
|
294
|
+
|
|
295
|
+
A record is written as the request goes on, and the body is the part that
|
|
296
|
+
arrives last — when the caller asks for it, which may be long after the
|
|
297
|
+
response was handed over. Nothing but the caller's own read can say what
|
|
298
|
+
the body was, so the read is what writes it down: everything else here is
|
|
299
|
+
the wrapped response's, which keeps the one body and the one transfer
|
|
300
|
+
rather than copying either.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
__slots__ = ("_inner", "_trace")
|
|
304
|
+
|
|
305
|
+
def __init__(self, inner: HttpResponseInterface, trace: TracedRequest) -> None:
|
|
306
|
+
self._inner = inner
|
|
307
|
+
self._trace = trace
|
|
308
|
+
|
|
309
|
+
@override
|
|
310
|
+
def get_status_code(self) -> int:
|
|
311
|
+
return self._inner.get_status_code()
|
|
312
|
+
|
|
313
|
+
@override
|
|
314
|
+
def get_reason_phrase(self) -> str:
|
|
315
|
+
return self._inner.get_reason_phrase()
|
|
316
|
+
|
|
317
|
+
@override
|
|
318
|
+
def with_status(self, code: int, reason_phrase: str = "") -> Self:
|
|
319
|
+
return self._around(self._inner.with_status(code, reason_phrase))
|
|
320
|
+
|
|
321
|
+
@override
|
|
322
|
+
def get_protocol_version(self) -> str:
|
|
323
|
+
return self._inner.get_protocol_version()
|
|
324
|
+
|
|
325
|
+
@override
|
|
326
|
+
def with_protocol_version(self, version: str) -> Self:
|
|
327
|
+
return self._around(self._inner.with_protocol_version(version))
|
|
328
|
+
|
|
329
|
+
@override
|
|
330
|
+
def get_headers(self) -> Mapping[str, Sequence[str]]:
|
|
331
|
+
return self._inner.get_headers()
|
|
332
|
+
|
|
333
|
+
@override
|
|
334
|
+
def has_header(self, name: str) -> bool:
|
|
335
|
+
return self._inner.has_header(name)
|
|
336
|
+
|
|
337
|
+
@override
|
|
338
|
+
def get_header(self, name: str) -> Sequence[str]:
|
|
339
|
+
return self._inner.get_header(name)
|
|
340
|
+
|
|
341
|
+
@override
|
|
342
|
+
def get_header_line(self, name: str) -> str:
|
|
343
|
+
return self._inner.get_header_line(name)
|
|
344
|
+
|
|
345
|
+
@override
|
|
346
|
+
def with_header(self, name: str, value: str | Sequence[str]) -> Self:
|
|
347
|
+
return self._around(self._inner.with_header(name, value))
|
|
348
|
+
|
|
349
|
+
@override
|
|
350
|
+
def with_added_header(self, name: str, value: str | Sequence[str]) -> Self:
|
|
351
|
+
return self._around(self._inner.with_added_header(name, value))
|
|
352
|
+
|
|
353
|
+
@override
|
|
354
|
+
def without_header(self, name: str) -> Self:
|
|
355
|
+
return self._around(self._inner.without_header(name))
|
|
356
|
+
|
|
357
|
+
@override
|
|
358
|
+
def get_body(self) -> StreamInterface:
|
|
359
|
+
return self._inner.get_body()
|
|
360
|
+
|
|
361
|
+
@override
|
|
362
|
+
def with_body(self, body: StreamInterface) -> Self:
|
|
363
|
+
return self._around(self._inner.with_body(body))
|
|
364
|
+
|
|
365
|
+
@override
|
|
366
|
+
def get_info(self, key: str | None = None) -> object:
|
|
367
|
+
return self._inner.get_info(key)
|
|
368
|
+
|
|
369
|
+
@override
|
|
370
|
+
async def cancel(self) -> None:
|
|
371
|
+
await self._inner.cancel()
|
|
372
|
+
|
|
373
|
+
@override
|
|
374
|
+
async def get_content(self, throw: bool = True) -> bytes:
|
|
375
|
+
content = await self._inner.get_content(throw)
|
|
376
|
+
self._trace["content"] = content
|
|
377
|
+
|
|
378
|
+
return content
|
|
379
|
+
|
|
380
|
+
@override
|
|
381
|
+
async def to_array(self, throw: bool = True) -> dict[str, object]:
|
|
382
|
+
# The bytes are written down before they are decoded, because they are
|
|
383
|
+
# what was read whether or not they turn out to be the JSON the caller
|
|
384
|
+
# read them as — and a body that would not decode is the one a trace is
|
|
385
|
+
# being read to explain.
|
|
386
|
+
self._trace["content"] = await self._inner.get_content(throw)
|
|
387
|
+
|
|
388
|
+
return await self._inner.to_array(throw)
|
|
389
|
+
|
|
390
|
+
@override
|
|
391
|
+
async def __aenter__(self) -> Self:
|
|
392
|
+
_ = await self._inner.__aenter__()
|
|
393
|
+
|
|
394
|
+
return self
|
|
395
|
+
|
|
396
|
+
@override
|
|
397
|
+
async def __aexit__(
|
|
398
|
+
self,
|
|
399
|
+
exc_type: type[BaseException] | None,
|
|
400
|
+
exc: BaseException | None,
|
|
401
|
+
traceback: TracebackType | None,
|
|
402
|
+
) -> None:
|
|
403
|
+
await self._inner.__aexit__(exc_type, exc, traceback)
|
|
404
|
+
|
|
405
|
+
def _around(self, inner: HttpResponseInterface) -> Self:
|
|
406
|
+
return type(self)(inner, self._trace)
|