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,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
|