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,74 @@
1
+ """What decides whether a request is worth making again, and how long to wait."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from xtr_http_client_contracts import TransportError
9
+
10
+ from .retry_context import RetryContext
11
+
12
+ __all__ = ["RetryStrategyInterface"]
13
+
14
+
15
+ @runtime_checkable
16
+ class RetryStrategyInterface(Protocol):
17
+ """The two questions a retrying client asks itself after an attempt.
18
+
19
+ Whether to try again is one decision, how long to wait before doing so is
20
+ another, and they are asked separately because a strategy that answers the
21
+ first often has nothing of its own to say about the second.
22
+ :class:`RetryableHttpClient
23
+ <xtr_http_client.retryable_http_client.RetryableHttpClient>` asks both.
24
+ """
25
+
26
+ async def should_retry(
27
+ self,
28
+ context: RetryContext,
29
+ content: bytes | None,
30
+ error: TransportError | None,
31
+ ) -> bool | None:
32
+ """Say whether the attempt in ``context`` is worth making again.
33
+
34
+ ``None`` is the third answer: "not without the body". The client then
35
+ reads the whole body and asks again with it — once. Answering ``None``
36
+ a second time is a strategy that cannot decide, and is reported as
37
+ such rather than taken for a "no".
38
+
39
+ Args:
40
+ context: The attempt, and whatever came back from it.
41
+ content: The whole body, once the client has read it because this
42
+ method asked for it. ``None`` the first time round, and
43
+ whenever the attempt ended in a transport error, where there
44
+ is no body to read.
45
+ error: What went wrong when nothing came back, or ``None`` when
46
+ something did.
47
+
48
+ Returns:
49
+ ``True`` to try again, ``False`` to hand back what came back, or
50
+ ``None`` to be asked again with the body.
51
+ """
52
+ ...
53
+
54
+ def get_delay(
55
+ self,
56
+ context: RetryContext,
57
+ content: bytes | None,
58
+ error: TransportError | None,
59
+ ) -> int:
60
+ """Return how long to wait before the next attempt, in milliseconds.
61
+
62
+ Only asked when the answer has not already been given by the server: a
63
+ ``Retry-After`` header is obeyed over this.
64
+
65
+ Args:
66
+ context: The attempt just made, whose ``retry_count`` is what a
67
+ backing-off delay grows with.
68
+ content: The body, when it was read to make the decision.
69
+ error: What went wrong when nothing came back.
70
+
71
+ Returns:
72
+ The wait in milliseconds; ``0`` to try again at once.
73
+ """
74
+ ...
@@ -0,0 +1,346 @@
1
+ """The client that makes a request again when it is worth making again."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ import anyio
8
+ from typing_extensions import Unpack, override
9
+ from xtr_http_client_contracts import HttpClientInterface
10
+ from xtr_service_contracts import ResetInterface
11
+
12
+ from .exception import InvalidArgumentError, TransportError
13
+ from .internal.option_merge import as_options
14
+ from .internal.retry_count_response import with_retry_count
15
+ from .internal.retry_delay import retry_after_ms
16
+ from .internal.retry_options import (
17
+ base_uri_rotation,
18
+ replays_body,
19
+ replays_request,
20
+ retries,
21
+ with_base_uri,
22
+ )
23
+ from .retry.generic_retry_strategy import GenericRetryStrategy
24
+ from .retry.retry_context import RetryContext
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Awaitable, Callable, Mapping
28
+ from typing import Self
29
+
30
+ from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
31
+ from xtr_http_message_contracts import RequestInterface
32
+ from xtr_logging_contracts import LoggerInterface
33
+
34
+ from .retry.retry_strategy_interface import RetryStrategyInterface
35
+
36
+ _Attempt = Callable[[Mapping[str, object], int], Awaitable[HttpResponseInterface]]
37
+
38
+ __all__ = ["RetryableHttpClient"]
39
+
40
+
41
+ @final
42
+ class RetryableHttpClient(HttpClientInterface, ResetInterface):
43
+ """Wraps a client and makes the request again while a strategy says to.
44
+
45
+ A server that answered 503, or a connection that never came up, has not
46
+ answered the question that was asked — and often answers it on the next
47
+ try. Written at the call site that is a loop, a counter and a delay around
48
+ every request; here it is the client, so the code calling out is unchanged
49
+ and the decision lives in one place.
50
+
51
+ ```python
52
+ client = RetryableHttpClient(inner, max_retries=2)
53
+ async with await client.request("GET", "https://example.com/orders") as response:
54
+ orders = await response.to_array()
55
+ ```
56
+
57
+ What is retried, and how long is waited between tries, is the
58
+ :class:`RetryStrategyInterface
59
+ <xtr_http_client.retry.retry_strategy_interface.RetryStrategyInterface>`'s
60
+ — :class:`GenericRetryStrategy
61
+ <xtr_http_client.retry.generic_retry_strategy.GenericRetryStrategy>` by
62
+ default, which retries the status codes worth retrying and backs off
63
+ between attempts. A ``Retry-After`` header wins over its delay: a server
64
+ saying when to come back is better informed than a client guessing.
65
+
66
+ Two things bound it. ``max_retries`` — which a request may lower or raise
67
+ through the ``max_retries`` option — is how many *further* attempts are
68
+ allowed, so the request is made at most that many times plus one; and a
69
+ body that cannot be produced twice is sent once, because a second attempt
70
+ would send a request that is not the one that failed.
71
+
72
+ The ``base_uri`` option may name several, and then each attempt moves one
73
+ along it — the way a client is pointed at a second host rather than at the
74
+ host that has just refused. The response handed back reports how many
75
+ tries it took, under the ``retry_count`` entry of ``get_info()``.
76
+ """
77
+
78
+ __slots__ = ("_base_uris", "_client", "_logger", "_max_retries", "_sleep", "_strategy")
79
+
80
+ def __init__(
81
+ self,
82
+ client: HttpClientInterface,
83
+ strategy: RetryStrategyInterface | None = None,
84
+ max_retries: int = 3,
85
+ logger: LoggerInterface | None = None,
86
+ sleep: Callable[[float], Awaitable[None]] = anyio.sleep,
87
+ ) -> None:
88
+ """Retry what ``client`` sends, as often as ``max_retries`` allows.
89
+
90
+ Args:
91
+ client: The client every attempt is handed to.
92
+ strategy: What decides whether an attempt is worth repeating.
93
+ ``None`` takes :class:`GenericRetryStrategy
94
+ <xtr_http_client.retry.generic_retry_strategy.GenericRetryStrategy>`
95
+ as it comes.
96
+ max_retries: How many further attempts a request may have. Zero
97
+ or less is a client that passes everything straight through.
98
+ logger: Where each retry is reported, at info level. ``None``
99
+ reports none.
100
+ sleep: What waiting between attempts is done with. Only worth
101
+ passing to make a test's waits instant.
102
+ """
103
+ self._client = client
104
+ self._strategy: RetryStrategyInterface = (
105
+ GenericRetryStrategy() if strategy is None else strategy
106
+ )
107
+ self._max_retries = max_retries
108
+ self._logger = logger
109
+ self._sleep = sleep
110
+ self._base_uris: tuple[str, ...] = ()
111
+
112
+ @override
113
+ async def request(
114
+ self,
115
+ method: str,
116
+ url: str,
117
+ **options: Unpack[RequestOptions],
118
+ ) -> HttpResponseInterface:
119
+ """Make a request for ``method`` and ``url``, again while that is worth doing.
120
+
121
+ Args:
122
+ method: The HTTP method, in upper case.
123
+ url: The URL to request, absolute or relative to a ``base_uri``.
124
+ **options: How the request is to be made. ``max_retries`` is
125
+ this client's own and is not passed on.
126
+
127
+ Returns:
128
+ What the last attempt answered.
129
+
130
+ Raises:
131
+ InvalidArgumentError: For an invalid method, URL or option, and
132
+ when the strategy will not decide.
133
+ TransportError: When the last attempt brought back no answer.
134
+ """
135
+
136
+ async def attempt(sent: Mapping[str, object], count: int) -> HttpResponseInterface:
137
+ del count
138
+
139
+ return await self._client.request(method, url, **as_options(sent))
140
+
141
+ return await self._attempted(
142
+ attempt,
143
+ method,
144
+ url,
145
+ dict(options),
146
+ replayable=replays_body(options),
147
+ )
148
+
149
+ @override
150
+ async def send_request(
151
+ self,
152
+ request: RequestInterface,
153
+ **options: Unpack[RequestOptions],
154
+ ) -> HttpResponseInterface:
155
+ """Send ``request``, again while that is worth doing.
156
+
157
+ Args:
158
+ request: The request to send. Every attempt sends this very
159
+ request, its body rewound first.
160
+ **options: What the request leaves out. ``max_retries`` is this
161
+ client's own and is not passed on.
162
+
163
+ Returns:
164
+ What the last attempt answered.
165
+
166
+ Raises:
167
+ InvalidArgumentError: For an invalid method, URL or option, and
168
+ when the strategy will not decide.
169
+ TransportError: When the last attempt brought back no answer.
170
+ """
171
+
172
+ async def attempt(sent: Mapping[str, object], count: int) -> HttpResponseInterface:
173
+ body = request.get_body()
174
+ if count and body.is_seekable():
175
+ await body.rewind()
176
+
177
+ return await self._client.send_request(request, **as_options(sent))
178
+
179
+ return await self._attempted(
180
+ attempt,
181
+ request.get_method(),
182
+ str(request.get_uri()),
183
+ dict(options),
184
+ replayable=replays_body(options) and replays_request(request),
185
+ )
186
+
187
+ @override
188
+ def with_options(self, **options: Unpack[RequestOptions]) -> Self:
189
+ """Return a copy around a wrapped client whose defaults have ``options`` over them.
190
+
191
+ ``base_uri`` and ``max_retries`` stay here rather than going on: a
192
+ list of base URIs is this client's to walk, and how many attempts are
193
+ allowed is not something the client behind it can act on.
194
+
195
+ Args:
196
+ **options: The defaults of the wrapped client of the copy.
197
+
198
+ Returns:
199
+ A new client, retrying by the same strategy; this one is left as
200
+ it was.
201
+
202
+ Raises:
203
+ InvalidArgumentError: When an option is invalid.
204
+ """
205
+ given: dict[str, object] = dict(options)
206
+ base_uris = self._base_uris
207
+
208
+ if "base_uri" in given:
209
+ rotation = base_uri_rotation(given["base_uri"])
210
+ base_uris = () if rotation is None else rotation
211
+ if rotation is not None:
212
+ del given["base_uri"]
213
+
214
+ max_retries = retries(given.pop("max_retries", None), self._max_retries)
215
+ narrowed = type(self)(
216
+ self._client.with_options(**as_options(given)),
217
+ self._strategy,
218
+ max_retries,
219
+ self._logger,
220
+ self._sleep,
221
+ )
222
+ narrowed._base_uris = base_uris # noqa: SLF001 — same class, seeded as a field
223
+
224
+ return narrowed
225
+
226
+ @override
227
+ async def reset(self) -> None:
228
+ """Reset the wrapped client.
229
+
230
+ Nothing here outlives a request — how many attempts are allowed and
231
+ what decides them are configuration — so there is only the client
232
+ behind to pass this on to.
233
+ """
234
+ if isinstance(self._client, ResetInterface):
235
+ await self._client.reset()
236
+
237
+ async def _attempted(
238
+ self,
239
+ attempt: _Attempt,
240
+ method: str,
241
+ url: str,
242
+ options: dict[str, object],
243
+ *,
244
+ replayable: bool,
245
+ ) -> HttpResponseInterface:
246
+ """Make the request, and make it again while the strategy says to."""
247
+ base_uris = self._base_uris
248
+ if "base_uri" in options:
249
+ rotation = base_uri_rotation(options["base_uri"])
250
+ base_uris = () if rotation is None else rotation
251
+
252
+ max_retries = retries(options.pop("max_retries", None), self._max_retries)
253
+ if max_retries <= 0 or not replayable:
254
+ return await attempt(with_base_uri(options, base_uris, 0), 0)
255
+
256
+ count = 0
257
+ while True:
258
+ sent = with_base_uri(options, base_uris, count)
259
+ try:
260
+ response = await attempt(sent, count)
261
+ except TransportError as error:
262
+ context = RetryContext(method, url, as_options(sent), count, None)
263
+ if count >= max_retries or not await self._retries_error(context, error):
264
+ raise
265
+ count += 1
266
+ await self._paused(
267
+ count,
268
+ self._strategy.get_delay(context, None, error),
269
+ f": {error}",
270
+ )
271
+ continue
272
+
273
+ context = RetryContext(method, url, as_options(sent), count, response)
274
+ if count >= max_retries:
275
+ return _answer(response, count)
276
+
277
+ retry, content = await self._decided(context, response)
278
+ if not retry:
279
+ return _answer(response, count)
280
+
281
+ delay = retry_after_ms(response)
282
+ if delay is None:
283
+ delay = self._strategy.get_delay(context, content, None)
284
+
285
+ count += 1
286
+ await response.cancel()
287
+ await self._paused(count, delay, f", status code: {context.get_status_code()}")
288
+
289
+ async def _decided(
290
+ self,
291
+ context: RetryContext,
292
+ response: HttpResponseInterface,
293
+ ) -> tuple[bool, bytes | None]:
294
+ """Whether to try again, and the body that had to be read to say so.
295
+
296
+ Raises:
297
+ InvalidArgumentError: When the strategy asks for the body and
298
+ then will not decide with it either, which leaves nobody
299
+ having said whether to try again.
300
+ """
301
+ decision = await self._strategy.should_retry(context, None, None)
302
+ if decision is not None:
303
+ return decision, None
304
+
305
+ content = await response.get_content(throw=False)
306
+ decision = await self._strategy.should_retry(context, content, None)
307
+ if decision is None:
308
+ raise InvalidArgumentError(self._undecided("a body"))
309
+
310
+ return decision, content
311
+
312
+ async def _retries_error(self, context: RetryContext, error: TransportError) -> bool:
313
+ """Whether an attempt that brought back nothing is worth making again.
314
+
315
+ Raises:
316
+ InvalidArgumentError: When the strategy will not decide, which it
317
+ cannot defer here: there is no body to come back with.
318
+ """
319
+ decision = await self._strategy.should_retry(context, None, error)
320
+ if decision is None:
321
+ raise InvalidArgumentError(self._undecided("an error"))
322
+
323
+ return decision
324
+
325
+ async def _paused(self, count: int, delay: int, reason: str) -> None:
326
+ """Report the retry about to be made, then wait for it."""
327
+ if self._logger is not None:
328
+ message = f"Try #{{count}} after {{delay}}ms{reason}"
329
+ self._logger.info(message, {"count": count, "delay": delay})
330
+
331
+ await self._sleep(delay / 1000)
332
+
333
+ def _undecided(self, asked_with: str) -> str:
334
+ """The message for a strategy that answered neither yes nor no."""
335
+ return (
336
+ f'The "{type(self._strategy).__qualname__}.should_retry()" method must not '
337
+ f"return None when called with {asked_with}."
338
+ )
339
+
340
+
341
+ def _answer(response: HttpResponseInterface, count: int) -> HttpResponseInterface:
342
+ """The response as the caller gets it, saying how many tries it took."""
343
+ if count:
344
+ return with_retry_count(response, count)
345
+
346
+ return response