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,375 @@
|
|
|
1
|
+
"""A response written out in advance, for a client that answers from a script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Self
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
from xtr_http_client_contracts import HttpResponseInterface
|
|
9
|
+
|
|
10
|
+
from xtr_http_client.exception import InvalidArgumentError, TransportError
|
|
11
|
+
from xtr_http_client.internal.mock_head import scripted_message
|
|
12
|
+
from xtr_http_client.internal.mock_info import issued_info
|
|
13
|
+
from xtr_http_client.internal.response_body import ResponseBody
|
|
14
|
+
|
|
15
|
+
from .http_response import HttpResponse
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
|
|
21
|
+
from xtr_http_message import Response
|
|
22
|
+
from xtr_http_message_contracts import StreamInterface
|
|
23
|
+
|
|
24
|
+
__all__ = ["MockResponse"]
|
|
25
|
+
|
|
26
|
+
_NOT_ISSUED = "MockResponse instances must be issued by MockHttpClient before processing."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MockResponse(HttpResponseInterface):
|
|
30
|
+
"""An answer written before the question, which a client hands back as its own.
|
|
31
|
+
|
|
32
|
+
A test says what it wants to come back — a body, a status, headers, or an
|
|
33
|
+
error instead of an answer — and :class:`MockHttpClient
|
|
34
|
+
<xtr_http_client.mock_http_client.MockHttpClient>` answers with it. Nothing
|
|
35
|
+
is sent and nothing is opened.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
client = MockHttpClient(MockResponse(b'{"id": 7}', {"http_code": 201}))
|
|
39
|
+
async with await client.request("GET", "/orders") as response:
|
|
40
|
+
assert await response.to_array() == {"id": 7}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A written response is only half a response: the other half — the method,
|
|
44
|
+
the URL and the options it answers — is the client's, and is filled in when
|
|
45
|
+
the client *issues* it. Until then there is nothing to read, because what
|
|
46
|
+
came back is not yet an answer to anything; reading one raises
|
|
47
|
+
:class:`InvalidArgumentError
|
|
48
|
+
<xtr_http_client_contracts.exception.invalid_argument_error.InvalidArgumentError>`.
|
|
49
|
+
|
|
50
|
+
Once issued, it is an ordinary response of this package: the status,
|
|
51
|
+
headers, body, copies and errors are :class:`HttpResponse
|
|
52
|
+
<xtr_http_client.response.http_response.HttpResponse>`'s, over a body made
|
|
53
|
+
of the scripted pieces, so a test reads a scripted answer exactly as it
|
|
54
|
+
reads a real one.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__slots__: tuple[str, ...] = (
|
|
58
|
+
"_info",
|
|
59
|
+
"_issued",
|
|
60
|
+
"_message",
|
|
61
|
+
"_pieces",
|
|
62
|
+
"_request_method",
|
|
63
|
+
"_request_options",
|
|
64
|
+
"_request_url",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_info: dict[str, object]
|
|
68
|
+
_issued: HttpResponse | None
|
|
69
|
+
_message: Response
|
|
70
|
+
_pieces: Iterable[bytes]
|
|
71
|
+
_request_method: str
|
|
72
|
+
_request_options: Mapping[str, object]
|
|
73
|
+
_request_url: str
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
body: bytes | str | Iterable[bytes] = b"",
|
|
78
|
+
info: Mapping[str, object] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Write out an answer.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
body: What the body is to be. Bytes or text are the whole of it;
|
|
84
|
+
an iterable of bytes is handed over a piece at a time, which
|
|
85
|
+
is how a test reads a body that arrives in pieces.
|
|
86
|
+
info: What the transfer is to claim about itself — ``http_code``
|
|
87
|
+
(``200`` when left out), ``response_headers``, and ``error``
|
|
88
|
+
for a transfer that is to fail rather than answer. Any other
|
|
89
|
+
entry is carried through to ``get_info()`` untouched.
|
|
90
|
+
"""
|
|
91
|
+
script = dict(info or {})
|
|
92
|
+
self._pieces, length = _scripted(body)
|
|
93
|
+
self._message = scripted_message(script, length)
|
|
94
|
+
script["http_code"] = self._message.get_status_code()
|
|
95
|
+
|
|
96
|
+
self._info = script
|
|
97
|
+
self._issued = None
|
|
98
|
+
self._request_method = ""
|
|
99
|
+
self._request_url = ""
|
|
100
|
+
self._request_options = {}
|
|
101
|
+
|
|
102
|
+
def issue(self, method: str, url: str, options: Mapping[str, object]) -> Self:
|
|
103
|
+
"""Make this written answer the answer to a request that was just made.
|
|
104
|
+
|
|
105
|
+
What a client does to a response before handing it over: the request
|
|
106
|
+
side is filled in, and the body and the record of the transfer come
|
|
107
|
+
into being. Called by :class:`MockHttpClient
|
|
108
|
+
<xtr_http_client.mock_http_client.MockHttpClient>`.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
method: The method the request was made with.
|
|
112
|
+
url: The URL it was made to, resolved.
|
|
113
|
+
options: The options it was made with, merged.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
This response, now readable.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
TransportError: When the script says the transfer fails rather
|
|
120
|
+
than answers. Nothing is opened in that case, so there is
|
|
121
|
+
nothing left to let go of.
|
|
122
|
+
"""
|
|
123
|
+
self._request_method = method
|
|
124
|
+
self._request_url = url
|
|
125
|
+
self._request_options = dict(options)
|
|
126
|
+
|
|
127
|
+
error = self._info.get("error")
|
|
128
|
+
if isinstance(error, str) and error:
|
|
129
|
+
raise TransportError(error)
|
|
130
|
+
|
|
131
|
+
self._issued = HttpResponse(
|
|
132
|
+
self._message,
|
|
133
|
+
ResponseBody(_arriving(self._pieces)),
|
|
134
|
+
issued_info(self._info, self._message, method, url, options),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def get_request_method(self) -> str:
|
|
140
|
+
"""The method of the request this answered, empty until it answered one."""
|
|
141
|
+
return self._request_method
|
|
142
|
+
|
|
143
|
+
def get_request_url(self) -> str:
|
|
144
|
+
"""The URL of the request this answered, empty until it answered one."""
|
|
145
|
+
return self._request_url
|
|
146
|
+
|
|
147
|
+
def get_request_options(self) -> Mapping[str, object]:
|
|
148
|
+
"""The options the request was made with, merged as the client sent them."""
|
|
149
|
+
return self._request_options
|
|
150
|
+
|
|
151
|
+
@override
|
|
152
|
+
def get_status_code(self) -> int:
|
|
153
|
+
"""Return the status the script says the answer came back with."""
|
|
154
|
+
return self._delegate.get_status_code()
|
|
155
|
+
|
|
156
|
+
@override
|
|
157
|
+
def get_reason_phrase(self) -> str:
|
|
158
|
+
"""Return the reason phrase that goes with the status."""
|
|
159
|
+
return self._delegate.get_reason_phrase()
|
|
160
|
+
|
|
161
|
+
@override
|
|
162
|
+
def with_status(self, code: int, reason_phrase: str = "") -> Self:
|
|
163
|
+
"""Return a copy carrying ``code`` and, optionally, ``reason_phrase``."""
|
|
164
|
+
return self._around(self._delegate.with_status(code, reason_phrase))
|
|
165
|
+
|
|
166
|
+
@override
|
|
167
|
+
def get_protocol_version(self) -> str:
|
|
168
|
+
"""Return the HTTP version the script says the answer came back on."""
|
|
169
|
+
return self._delegate.get_protocol_version()
|
|
170
|
+
|
|
171
|
+
@override
|
|
172
|
+
def with_protocol_version(self, version: str) -> Self:
|
|
173
|
+
"""Return a copy carrying ``version``."""
|
|
174
|
+
return self._around(self._delegate.with_protocol_version(version))
|
|
175
|
+
|
|
176
|
+
@override
|
|
177
|
+
def get_headers(self) -> Mapping[str, Sequence[str]]:
|
|
178
|
+
"""Return every header, each name mapped to all of its values."""
|
|
179
|
+
return self._delegate.get_headers()
|
|
180
|
+
|
|
181
|
+
@override
|
|
182
|
+
def has_header(self, name: str) -> bool:
|
|
183
|
+
"""Tell whether a header is there, matching ``name`` without case."""
|
|
184
|
+
return self._delegate.has_header(name)
|
|
185
|
+
|
|
186
|
+
@override
|
|
187
|
+
def get_header(self, name: str) -> Sequence[str]:
|
|
188
|
+
"""Return every value recorded under a header, matched without case."""
|
|
189
|
+
return self._delegate.get_header(name)
|
|
190
|
+
|
|
191
|
+
@override
|
|
192
|
+
def get_header_line(self, name: str) -> str:
|
|
193
|
+
"""Return the values of a header joined with commas."""
|
|
194
|
+
return self._delegate.get_header_line(name)
|
|
195
|
+
|
|
196
|
+
@override
|
|
197
|
+
def with_header(self, name: str, value: str | Sequence[str]) -> Self:
|
|
198
|
+
"""Return a copy whose ``name`` header holds ``value`` and nothing else."""
|
|
199
|
+
return self._around(self._delegate.with_header(name, value))
|
|
200
|
+
|
|
201
|
+
@override
|
|
202
|
+
def with_added_header(self, name: str, value: str | Sequence[str]) -> Self:
|
|
203
|
+
"""Return a copy with ``value`` appended to the ``name`` header."""
|
|
204
|
+
return self._around(self._delegate.with_added_header(name, value))
|
|
205
|
+
|
|
206
|
+
@override
|
|
207
|
+
def without_header(self, name: str) -> Self:
|
|
208
|
+
"""Return a copy with the ``name`` header gone."""
|
|
209
|
+
return self._around(self._delegate.without_header(name))
|
|
210
|
+
|
|
211
|
+
@override
|
|
212
|
+
def get_body(self) -> StreamInterface:
|
|
213
|
+
"""Return the body, over the pieces the script was written with."""
|
|
214
|
+
return self._delegate.get_body()
|
|
215
|
+
|
|
216
|
+
@override
|
|
217
|
+
def with_body(self, body: StreamInterface) -> Self:
|
|
218
|
+
"""Return a copy reading its body from ``body`` instead."""
|
|
219
|
+
return self._around(self._delegate.with_body(body))
|
|
220
|
+
|
|
221
|
+
@override
|
|
222
|
+
def get_info(self, key: str | None = None) -> object:
|
|
223
|
+
"""Return what the transfer knows about itself, as it stands.
|
|
224
|
+
|
|
225
|
+
Readable before the response is issued, where it is the script as it
|
|
226
|
+
was written — which is what lets a cancelled response say so even
|
|
227
|
+
though it never answered anything.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
key: One entry to look up. ``None`` asks for all of them.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Every entry when ``key`` is ``None``; otherwise the value under
|
|
234
|
+
``key``, or ``None`` when there is no such entry.
|
|
235
|
+
"""
|
|
236
|
+
if self._issued is not None:
|
|
237
|
+
return self._issued.get_info(key)
|
|
238
|
+
if key is None:
|
|
239
|
+
return dict(self._info)
|
|
240
|
+
|
|
241
|
+
return self._info.get(key)
|
|
242
|
+
|
|
243
|
+
@override
|
|
244
|
+
async def cancel(self) -> None:
|
|
245
|
+
"""Abandon the transfer and let go of what it was holding."""
|
|
246
|
+
self._info["canceled"] = True
|
|
247
|
+
self._info["error"] = "Response has been canceled."
|
|
248
|
+
|
|
249
|
+
if self._issued is not None:
|
|
250
|
+
await self._issued.cancel()
|
|
251
|
+
|
|
252
|
+
@override
|
|
253
|
+
async def get_content(self, throw: bool = True) -> bytes:
|
|
254
|
+
"""Read the whole body, as the script hands it over.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
throw: Whether an unsuccessful status is to be raised rather than
|
|
258
|
+
answered with the body that came with it.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Every byte of the body, empty when the script wrote none.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
InvalidArgumentError: When the response has not been issued.
|
|
265
|
+
TransportError: When the body cannot be read.
|
|
266
|
+
RedirectionError: On a 3xx, when ``throw``.
|
|
267
|
+
ClientError: On a 4xx, when ``throw``.
|
|
268
|
+
ServerError: On a 5xx, when ``throw``.
|
|
269
|
+
"""
|
|
270
|
+
return await self._delegate.get_content(throw)
|
|
271
|
+
|
|
272
|
+
@override
|
|
273
|
+
async def to_array(self, throw: bool = True) -> dict[str, object]:
|
|
274
|
+
"""Read the whole body and decode it as a JSON object.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
throw: Whether an unsuccessful status is to be raised rather than
|
|
278
|
+
answered with the body that came with it.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
What the body decoded to.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
InvalidArgumentError: When the response has not been issued.
|
|
285
|
+
JsonError: When the body is empty, is not JSON, or is JSON that is
|
|
286
|
+
not an object.
|
|
287
|
+
TransportError: When the body cannot be read.
|
|
288
|
+
RedirectionError: On a 3xx, when ``throw``.
|
|
289
|
+
ClientError: On a 4xx, when ``throw``.
|
|
290
|
+
ServerError: On a 5xx, when ``throw``.
|
|
291
|
+
"""
|
|
292
|
+
return await self._delegate.to_array(throw)
|
|
293
|
+
|
|
294
|
+
@override
|
|
295
|
+
async def __aenter__(self) -> Self:
|
|
296
|
+
"""Enter a block that owns the scripted body.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
This response.
|
|
300
|
+
"""
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
@override
|
|
304
|
+
async def __aexit__(
|
|
305
|
+
self,
|
|
306
|
+
exc_type: type[BaseException] | None,
|
|
307
|
+
exc: BaseException | None,
|
|
308
|
+
traceback: TracebackType | None,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Close the body however the block ends, and nothing else.
|
|
311
|
+
|
|
312
|
+
A response no client issued has no body to let go of, so leaving its
|
|
313
|
+
block does nothing rather than complaining about what was never read.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
exc_type: The class of the exception the block is leaving with.
|
|
317
|
+
exc: That exception.
|
|
318
|
+
traceback: Its traceback.
|
|
319
|
+
"""
|
|
320
|
+
if self._issued is not None:
|
|
321
|
+
await self._issued.__aexit__(exc_type, exc, traceback)
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def _delegate(self) -> HttpResponse:
|
|
325
|
+
"""The response this one answers from, once a client has issued it.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
InvalidArgumentError: When no client has, since a written answer
|
|
329
|
+
is not an answer to anything until one does.
|
|
330
|
+
"""
|
|
331
|
+
if self._issued is None:
|
|
332
|
+
raise InvalidArgumentError(_NOT_ISSUED)
|
|
333
|
+
|
|
334
|
+
return self._issued
|
|
335
|
+
|
|
336
|
+
def _around(self, issued: HttpResponse) -> Self:
|
|
337
|
+
"""Return a response of this kind around a copy of the issued response.
|
|
338
|
+
|
|
339
|
+
Built field by field rather than through the constructor, because a
|
|
340
|
+
copy answers from a message that has already been made, where a new
|
|
341
|
+
response makes one out of the script it is written with.
|
|
342
|
+
"""
|
|
343
|
+
copy = type(self).__new__(type(self))
|
|
344
|
+
copy._message = self._message # noqa: SLF001 — a copy of this very class.
|
|
345
|
+
copy._pieces = self._pieces # noqa: SLF001 — likewise.
|
|
346
|
+
copy._info = self._info # noqa: SLF001 — likewise.
|
|
347
|
+
copy._issued = issued # noqa: SLF001 — likewise.
|
|
348
|
+
copy._request_method = self._request_method # noqa: SLF001 — likewise.
|
|
349
|
+
copy._request_url = self._request_url # noqa: SLF001 — likewise.
|
|
350
|
+
copy._request_options = self._request_options # noqa: SLF001 — likewise.
|
|
351
|
+
|
|
352
|
+
return copy
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _scripted(body: bytes | str | Iterable[bytes]) -> tuple[Iterable[bytes], int | None]:
|
|
356
|
+
"""The pieces a body is handed over as, and its length when it has one.
|
|
357
|
+
|
|
358
|
+
Bytes and text are the whole body, so their length is known and is what
|
|
359
|
+
``Content-Length`` is made to say. An iterable is as long as it turns out
|
|
360
|
+
to be, which nobody knows until it ends.
|
|
361
|
+
"""
|
|
362
|
+
if isinstance(body, str):
|
|
363
|
+
whole = body.encode()
|
|
364
|
+
elif isinstance(body, bytes):
|
|
365
|
+
whole = body
|
|
366
|
+
else:
|
|
367
|
+
return body, None
|
|
368
|
+
|
|
369
|
+
return ([whole] if whole else []), len(whole)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def _arriving(pieces: Iterable[bytes]) -> AsyncIterator[bytes]:
|
|
373
|
+
"""The scripted pieces, handed over as a transfer would produce them."""
|
|
374
|
+
for piece in pieces:
|
|
375
|
+
yield piece
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""What decides whether a failed request is worth making again.
|
|
2
|
+
|
|
3
|
+
:class:`RetryStrategyInterface` is the decision, asked about one attempt at a
|
|
4
|
+
time — a :class:`RetryContext` — and :class:`GenericRetryStrategy` is the
|
|
5
|
+
answer most applications want: the status codes worth retrying, for the
|
|
6
|
+
methods worth retrying them for, with a delay that grows between attempts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .generic_retry_strategy import (
|
|
12
|
+
DEFAULT_RETRY_STATUS_CODES,
|
|
13
|
+
IDEMPOTENT_METHODS,
|
|
14
|
+
GenericRetryStrategy,
|
|
15
|
+
)
|
|
16
|
+
from .retry_context import RetryContext
|
|
17
|
+
from .retry_strategy_interface import RetryStrategyInterface
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_RETRY_STATUS_CODES",
|
|
21
|
+
"IDEMPOTENT_METHODS",
|
|
22
|
+
"GenericRetryStrategy",
|
|
23
|
+
"RetryContext",
|
|
24
|
+
"RetryStrategyInterface",
|
|
25
|
+
]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The strategy that retries a listed status code, waiting longer each time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from random import uniform
|
|
7
|
+
from typing import TYPE_CHECKING, Final, final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from xtr_http_client.exception import InvalidArgumentError
|
|
12
|
+
|
|
13
|
+
from .retry_strategy_interface import RetryStrategyInterface
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from xtr_http_client_contracts import TransportError
|
|
19
|
+
|
|
20
|
+
from .retry_context import RetryContext
|
|
21
|
+
|
|
22
|
+
__all__ = ["DEFAULT_RETRY_STATUS_CODES", "IDEMPOTENT_METHODS", "GenericRetryStrategy"]
|
|
23
|
+
|
|
24
|
+
IDEMPOTENT_METHODS: Final[Sequence[str]] = (
|
|
25
|
+
"GET",
|
|
26
|
+
"HEAD",
|
|
27
|
+
"PUT",
|
|
28
|
+
"DELETE",
|
|
29
|
+
"OPTIONS",
|
|
30
|
+
"TRACE",
|
|
31
|
+
"QUERY",
|
|
32
|
+
)
|
|
33
|
+
"""The methods a server answers the same way however often they are sent."""
|
|
34
|
+
|
|
35
|
+
DEFAULT_RETRY_STATUS_CODES: Final[Mapping[int, Sequence[str] | None]] = {
|
|
36
|
+
# Nothing came back at all, which is what `0` stands for everywhere a
|
|
37
|
+
# status code is read; only a method that may be sent twice is.
|
|
38
|
+
0: IDEMPOTENT_METHODS,
|
|
39
|
+
423: None,
|
|
40
|
+
425: None,
|
|
41
|
+
429: None,
|
|
42
|
+
500: IDEMPOTENT_METHODS,
|
|
43
|
+
502: None,
|
|
44
|
+
503: None,
|
|
45
|
+
504: IDEMPOTENT_METHODS,
|
|
46
|
+
507: IDEMPOTENT_METHODS,
|
|
47
|
+
510: IDEMPOTENT_METHODS,
|
|
48
|
+
}
|
|
49
|
+
"""What is retried out of the box: each code mapped to the methods it is
|
|
50
|
+
retried for, or to ``None`` when every method is."""
|
|
51
|
+
|
|
52
|
+
_StatusCodes = Mapping[int, Sequence[str] | None] | Sequence[int]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@final
|
|
56
|
+
class GenericRetryStrategy(RetryStrategyInterface):
|
|
57
|
+
"""Retries a status code on the list, after a delay that grows each time.
|
|
58
|
+
|
|
59
|
+
The decision is the status code and, for the codes where it matters, the
|
|
60
|
+
method: a 503 is the server saying "not now", so asking again is the
|
|
61
|
+
right thing whatever was asked; a 500 after a `POST` may have changed
|
|
62
|
+
something already, so sending it again is not.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
strategy = GenericRetryStrategy([500, 502, 503], delay_ms=500)
|
|
66
|
+
client = RetryableHttpClient(inner, strategy, max_retries=2)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The delay starts at ``delay_ms`` and is multiplied by ``multiplier`` for
|
|
70
|
+
every attempt already made, so the server is given more room each time
|
|
71
|
+
rather than being asked again as fast as it refused. ``jitter`` spreads
|
|
72
|
+
that over a window around it, which is what keeps a thousand clients that
|
|
73
|
+
failed together from coming back together.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
__slots__ = (
|
|
77
|
+
"_delay_ms",
|
|
78
|
+
"_jitter",
|
|
79
|
+
"_max_delay_ms",
|
|
80
|
+
"_multiplier",
|
|
81
|
+
"_random",
|
|
82
|
+
"_status_codes",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __init__( # noqa: PLR0913, PLR0917 — the five numbers of one backing-off delay, plus what is retried
|
|
86
|
+
self,
|
|
87
|
+
status_codes: _StatusCodes = DEFAULT_RETRY_STATUS_CODES,
|
|
88
|
+
delay_ms: int = 1000,
|
|
89
|
+
multiplier: float = 2.0,
|
|
90
|
+
max_delay_ms: int = 0,
|
|
91
|
+
jitter: float = 0.1,
|
|
92
|
+
random: Callable[[float, float], float] = uniform,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Retry what ``status_codes`` lists, backing off between attempts.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
status_codes: What to retry. A sequence of codes is every method
|
|
98
|
+
for each of them; a mapping says, per code, which methods —
|
|
99
|
+
``None`` for all of them. ``0`` is the entry for an attempt
|
|
100
|
+
nothing came back from.
|
|
101
|
+
delay_ms: How long to wait before the second attempt, and what
|
|
102
|
+
the growing delay starts from.
|
|
103
|
+
multiplier: What the delay is multiplied by per attempt already
|
|
104
|
+
made. ``1`` waits the same every time.
|
|
105
|
+
max_delay_ms: The longest wait to allow. ``0`` sets no ceiling.
|
|
106
|
+
jitter: How much of the delay is randomness, from ``0`` — none —
|
|
107
|
+
to ``1``, where the wait lands anywhere from nothing to twice
|
|
108
|
+
the delay.
|
|
109
|
+
random: Where a number between two bounds comes from. Only worth
|
|
110
|
+
passing to make a test's delays repeatable.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
InvalidArgumentError: When a delay is negative, the multiplier is
|
|
114
|
+
below one, or the jitter is outside ``0`` to ``1``; each of them
|
|
115
|
+
is a strategy that would wait for a length of time nobody
|
|
116
|
+
meant.
|
|
117
|
+
"""
|
|
118
|
+
if delay_ms < 0:
|
|
119
|
+
raise InvalidArgumentError(
|
|
120
|
+
f'Delay must be greater than or equal to zero: "{delay_ms}" given.'
|
|
121
|
+
)
|
|
122
|
+
if multiplier < 1:
|
|
123
|
+
raise InvalidArgumentError(
|
|
124
|
+
f'Multiplier must be greater than or equal to one: "{multiplier}" given.'
|
|
125
|
+
)
|
|
126
|
+
if max_delay_ms < 0:
|
|
127
|
+
raise InvalidArgumentError(
|
|
128
|
+
f'Max delay must be greater than or equal to zero: "{max_delay_ms}" given.'
|
|
129
|
+
)
|
|
130
|
+
if jitter < 0 or jitter > 1:
|
|
131
|
+
raise InvalidArgumentError(f'Jitter must be between 0 and 1: "{jitter}" given.')
|
|
132
|
+
|
|
133
|
+
self._status_codes = _listed(status_codes)
|
|
134
|
+
self._delay_ms = delay_ms
|
|
135
|
+
self._multiplier = multiplier
|
|
136
|
+
self._max_delay_ms = max_delay_ms
|
|
137
|
+
self._jitter = jitter
|
|
138
|
+
self._random = random
|
|
139
|
+
|
|
140
|
+
@override
|
|
141
|
+
async def should_retry(
|
|
142
|
+
self,
|
|
143
|
+
context: RetryContext,
|
|
144
|
+
content: bytes | None,
|
|
145
|
+
error: TransportError | None,
|
|
146
|
+
) -> bool | None:
|
|
147
|
+
"""Say whether the attempt's status code is one on the list.
|
|
148
|
+
|
|
149
|
+
Never ``None``: the body says nothing about a status code, so this
|
|
150
|
+
strategy never needs it to decide.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
context: The attempt, whose status code and method are read.
|
|
154
|
+
content: Ignored.
|
|
155
|
+
error: What went wrong when nothing came back, which sends the
|
|
156
|
+
decision to the ``0`` entry.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Whether to try again.
|
|
160
|
+
"""
|
|
161
|
+
del content
|
|
162
|
+
|
|
163
|
+
code = context.get_status_code()
|
|
164
|
+
if code in self._status_codes:
|
|
165
|
+
return _allows(self._status_codes[code], context.method)
|
|
166
|
+
if error is None:
|
|
167
|
+
return False
|
|
168
|
+
if 0 in self._status_codes:
|
|
169
|
+
return _allows(self._status_codes[0], context.method)
|
|
170
|
+
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
@override
|
|
174
|
+
def get_delay(
|
|
175
|
+
self,
|
|
176
|
+
context: RetryContext,
|
|
177
|
+
content: bytes | None,
|
|
178
|
+
error: TransportError | None,
|
|
179
|
+
) -> int:
|
|
180
|
+
"""Return the delay for the next attempt, grown and spread out.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
context: The attempt just made, whose ``retry_count`` is the
|
|
184
|
+
power the multiplier is raised to.
|
|
185
|
+
content: Ignored.
|
|
186
|
+
error: Ignored.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The wait in whole milliseconds, never above ``max_delay_ms``
|
|
190
|
+
unless that is ``0``.
|
|
191
|
+
"""
|
|
192
|
+
del content, error
|
|
193
|
+
|
|
194
|
+
delay = self._delay_ms * self._multiplier**context.retry_count
|
|
195
|
+
if self._jitter > 0:
|
|
196
|
+
spread = int(delay * self._jitter)
|
|
197
|
+
delay += self._random(-spread, spread)
|
|
198
|
+
|
|
199
|
+
if self._max_delay_ms and delay > self._max_delay_ms:
|
|
200
|
+
return self._max_delay_ms
|
|
201
|
+
|
|
202
|
+
return int(delay)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _listed(status_codes: _StatusCodes) -> Mapping[int, Sequence[str] | None]:
|
|
206
|
+
"""The codes to retry as one mapping, a bare sequence meaning every method."""
|
|
207
|
+
if isinstance(status_codes, Mapping):
|
|
208
|
+
return dict(status_codes)
|
|
209
|
+
|
|
210
|
+
return dict.fromkeys(status_codes)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _allows(methods: Sequence[str] | None, method: str) -> bool:
|
|
214
|
+
"""Whether a code's entry covers the method the attempt was made with."""
|
|
215
|
+
return methods is None or method in methods
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""One attempt at a request, as whoever decides whether to repeat it sees it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, final
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
|
|
10
|
+
|
|
11
|
+
__all__ = ["RetryContext"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class RetryContext:
|
|
17
|
+
"""What one attempt was made of, and what came back from it.
|
|
18
|
+
|
|
19
|
+
Handed to a :class:`RetryStrategyInterface
|
|
20
|
+
<xtr_http_client.retry.retry_strategy_interface.RetryStrategyInterface>`
|
|
21
|
+
so it can decide on the attempt rather than on the request alone: the same
|
|
22
|
+
503 is worth repeating on the first try and not on the fourth, and a
|
|
23
|
+
method that may be sent twice is not every method.
|
|
24
|
+
|
|
25
|
+
The request side is carried as it was sent — the URL resolved against the
|
|
26
|
+
base URI this attempt used, the options merged — so a strategy reading
|
|
27
|
+
them reads what actually went out.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
method: The HTTP method the attempt was made with.
|
|
31
|
+
url: The URL it was made to, as it was given to the wrapped client.
|
|
32
|
+
options: The options it was made with, this attempt's ``base_uri``
|
|
33
|
+
among them.
|
|
34
|
+
retry_count: How many attempts came before this one, so ``0`` is the
|
|
35
|
+
first.
|
|
36
|
+
response: What came back, or ``None`` when nothing did and the
|
|
37
|
+
attempt ended in a transport error.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
method: str
|
|
41
|
+
url: str
|
|
42
|
+
options: RequestOptions
|
|
43
|
+
retry_count: int
|
|
44
|
+
response: HttpResponseInterface | None
|
|
45
|
+
|
|
46
|
+
def get_status_code(self) -> int:
|
|
47
|
+
"""Return the status the attempt was answered with.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The three-digit code, or ``0`` when nothing was answered — which
|
|
51
|
+
is the same thing a response says before its status line arrives,
|
|
52
|
+
so a strategy has one number to read either way.
|
|
53
|
+
"""
|
|
54
|
+
if self.response is None:
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
return self.response.get_status_code()
|
|
58
|
+
|
|
59
|
+
def get_info(self, key: str | None = None) -> object:
|
|
60
|
+
"""Return what the attempt's transfer knows about itself.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
key: One entry to look up. ``None`` asks for all of them.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
What the response reports; with nothing to report — an attempt
|
|
67
|
+
that ended in a transport error — every entry is empty and one
|
|
68
|
+
entry is ``None``.
|
|
69
|
+
"""
|
|
70
|
+
if self.response is None:
|
|
71
|
+
return None if key is not None else {}
|
|
72
|
+
|
|
73
|
+
return self.response.get_info(key)
|