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