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,303 @@
|
|
|
1
|
+
"""The client that picks a request's defaults from the URL it is for."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import TYPE_CHECKING, cast, final
|
|
8
|
+
from urllib.parse import urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
from typing_extensions import Unpack, override
|
|
11
|
+
from xtr_http_client_contracts import HttpClientInterface
|
|
12
|
+
from xtr_http_message import Uri
|
|
13
|
+
from xtr_service_contracts import ResetInterface
|
|
14
|
+
|
|
15
|
+
from .exception import InvalidArgumentError
|
|
16
|
+
from .internal.option_merge import as_options
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import Self
|
|
21
|
+
|
|
22
|
+
from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
|
|
23
|
+
from xtr_http_message_contracts import RequestInterface
|
|
24
|
+
|
|
25
|
+
__all__ = ["ScopingHttpClient"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class ScopingHttpClient(HttpClientInterface, ResetInterface):
|
|
30
|
+
r"""Wraps a client and gives each request the options its URL falls under.
|
|
31
|
+
|
|
32
|
+
One application talks to several services, and each wants its own
|
|
33
|
+
credentials, headers and deadlines. Written at the call sites, that is the
|
|
34
|
+
same four options repeated wherever a service is reached, and one place
|
|
35
|
+
that forgot them. Here it is said once: a scope is a regular expression
|
|
36
|
+
the start of a URL is matched against, and the options every request
|
|
37
|
+
landing in it starts from.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
client = ScopingHttpClient(
|
|
41
|
+
inner,
|
|
42
|
+
{r"https://api\.example\.com/": {"auth_bearer": "t0ken"}},
|
|
43
|
+
)
|
|
44
|
+
response = await client.request("GET", "https://api.example.com/orders")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
What the request itself asked for always wins — a scope fills in what was
|
|
48
|
+
left out, it never overrides. ``headers`` is the one option merged rather
|
|
49
|
+
than replaced, name by name, so a scope-wide ``Accept`` survives a request
|
|
50
|
+
setting a ``Content-Type`` of its own.
|
|
51
|
+
|
|
52
|
+
With a ``default_regexp``, that scope's ``base_uri`` also resolves a
|
|
53
|
+
relative URL, which is what makes :meth:`for_base_uri` read as "this
|
|
54
|
+
client is that service".
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("_by_regexp", "_client", "_default_regexp")
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
client: HttpClientInterface,
|
|
62
|
+
default_options_by_regexp: Mapping[str, RequestOptions],
|
|
63
|
+
default_regexp: str | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Scope ``client``, matching a URL against each regexp in turn.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
client: The client every vetted request is handed to.
|
|
69
|
+
default_options_by_regexp: Each scope's pattern mapped to the
|
|
70
|
+
options a request falling in it starts from. The first
|
|
71
|
+
pattern matching the start of a URL is the one that applies,
|
|
72
|
+
so order them from the most specific to the least.
|
|
73
|
+
default_regexp: Which of those scopes a relative URL belongs to,
|
|
74
|
+
and whose ``base_uri`` resolves it. ``None`` leaves a
|
|
75
|
+
relative URL to the wrapped client.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
InvalidArgumentError: When ``default_regexp`` names a scope that
|
|
79
|
+
is not in ``default_options_by_regexp`` — a default scope
|
|
80
|
+
with no options is a client whose relative URLs quietly
|
|
81
|
+
resolve against nothing.
|
|
82
|
+
"""
|
|
83
|
+
if default_regexp is not None and default_regexp not in default_options_by_regexp:
|
|
84
|
+
raise InvalidArgumentError(
|
|
85
|
+
f'No options are mapped to the provided "{default_regexp}" default regexp.'
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._client = client
|
|
89
|
+
self._by_regexp = dict(default_options_by_regexp)
|
|
90
|
+
self._default_regexp = default_regexp
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def for_base_uri(
|
|
94
|
+
cls,
|
|
95
|
+
client: HttpClientInterface,
|
|
96
|
+
base_uri: str,
|
|
97
|
+
default_options: RequestOptions | None = None,
|
|
98
|
+
regexp: str | None = None,
|
|
99
|
+
) -> Self:
|
|
100
|
+
"""Scope ``client`` to one service, reached at ``base_uri``.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
client: The client every request is handed to.
|
|
104
|
+
base_uri: What a relative URL resolves against, and — unless
|
|
105
|
+
``regexp`` says otherwise — what a URL must start with to be
|
|
106
|
+
in scope.
|
|
107
|
+
default_options: What a request in scope starts from.
|
|
108
|
+
regexp: The scope's pattern, for a service whose URLs are not all
|
|
109
|
+
under one base.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A client scoped to that one base URI.
|
|
113
|
+
"""
|
|
114
|
+
return cls._scoped(client, [base_uri], default_options, regexp)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def for_base_uris(
|
|
118
|
+
cls,
|
|
119
|
+
client: HttpClientInterface,
|
|
120
|
+
base_uris: Sequence[str],
|
|
121
|
+
default_options: RequestOptions | None = None,
|
|
122
|
+
regexp: str | None = None,
|
|
123
|
+
) -> Self:
|
|
124
|
+
"""Scope ``client`` to one service reachable at several base URIs.
|
|
125
|
+
|
|
126
|
+
The scope matches all of them, so the options keep applying whichever
|
|
127
|
+
one a request is resolved against; the first is what a relative URL
|
|
128
|
+
resolves against.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
client: The client every request is handed to.
|
|
132
|
+
base_uris: Where the service answers, the first being the default.
|
|
133
|
+
default_options: What a request in scope starts from.
|
|
134
|
+
regexp: The scope's pattern, replacing the one built from them.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
A client scoped to all of those base URIs.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
InvalidArgumentError: When no base URI is given, since a scope
|
|
141
|
+
over nothing would match nothing and resolve nothing.
|
|
142
|
+
"""
|
|
143
|
+
uris = list(base_uris)
|
|
144
|
+
if not uris:
|
|
145
|
+
raise InvalidArgumentError("At least one base URI must be provided.")
|
|
146
|
+
|
|
147
|
+
return cls._scoped(client, uris, default_options, regexp)
|
|
148
|
+
|
|
149
|
+
@override
|
|
150
|
+
async def request(
|
|
151
|
+
self,
|
|
152
|
+
method: str,
|
|
153
|
+
url: str,
|
|
154
|
+
**options: Unpack[RequestOptions],
|
|
155
|
+
) -> HttpResponseInterface:
|
|
156
|
+
"""Make a request for ``method`` and ``url``, under the scope it falls in.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
method: The HTTP method, in upper case.
|
|
160
|
+
url: The URL to request, absolute or relative to a ``base_uri``.
|
|
161
|
+
**options: How the request is to be made; the scope fills in what
|
|
162
|
+
is left out.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Whatever the wrapped client answers.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
InvalidArgumentError: For an invalid method, URL or option.
|
|
169
|
+
TransportError: When no answer came.
|
|
170
|
+
"""
|
|
171
|
+
target, scoped = self._scope(url, options)
|
|
172
|
+
|
|
173
|
+
return await self._client.request(method, target, **scoped)
|
|
174
|
+
|
|
175
|
+
@override
|
|
176
|
+
async def send_request(
|
|
177
|
+
self,
|
|
178
|
+
request: RequestInterface,
|
|
179
|
+
**options: Unpack[RequestOptions],
|
|
180
|
+
) -> HttpResponseInterface:
|
|
181
|
+
"""Send ``request`` under the scope the URI it carries falls in.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
request: The request to send; it goes on carrying the URL this
|
|
185
|
+
client resolved for it.
|
|
186
|
+
**options: What the request leaves out; the scope fills in what
|
|
187
|
+
both leave out.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Whatever the wrapped client answers.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
InvalidArgumentError: For an invalid method, URL or option.
|
|
194
|
+
TransportError: When no answer came.
|
|
195
|
+
"""
|
|
196
|
+
target, scoped = self._scope(str(request.get_uri()), options)
|
|
197
|
+
|
|
198
|
+
return await self._client.send_request(request.with_uri(Uri(target)), **scoped)
|
|
199
|
+
|
|
200
|
+
@override
|
|
201
|
+
def with_options(self, **options: Unpack[RequestOptions]) -> Self:
|
|
202
|
+
"""Return a copy around a wrapped client whose defaults have ``options`` over them.
|
|
203
|
+
|
|
204
|
+
The scopes are kept as they are: they say what belongs to which
|
|
205
|
+
service, which is not something a caller narrowing a client for one
|
|
206
|
+
job is changing.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
**options: The defaults of the wrapped client of the copy.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A new client; this one is left as it was.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
InvalidArgumentError: When an option is invalid.
|
|
216
|
+
"""
|
|
217
|
+
return type(self)(
|
|
218
|
+
self._client.with_options(**options),
|
|
219
|
+
self._by_regexp,
|
|
220
|
+
self._default_regexp,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@override
|
|
224
|
+
async def reset(self) -> None:
|
|
225
|
+
"""Reset the wrapped client, if it holds state between units of work.
|
|
226
|
+
|
|
227
|
+
The scopes are configuration, not state, so they are kept.
|
|
228
|
+
"""
|
|
229
|
+
if isinstance(self._client, ResetInterface):
|
|
230
|
+
await self._client.reset()
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def _scoped(
|
|
234
|
+
cls,
|
|
235
|
+
client: HttpClientInterface,
|
|
236
|
+
base_uris: list[str],
|
|
237
|
+
default_options: RequestOptions | None,
|
|
238
|
+
regexp: str | None,
|
|
239
|
+
) -> Self:
|
|
240
|
+
"""One scope over ``base_uris``, which is also the default one."""
|
|
241
|
+
pattern = regexp if regexp is not None else "|".join(_in_scope(uri) for uri in base_uris)
|
|
242
|
+
options: Mapping[str, object] = {**(default_options or {}), "base_uri": base_uris[0]}
|
|
243
|
+
|
|
244
|
+
return cls(client, {pattern: as_options(options)}, pattern)
|
|
245
|
+
|
|
246
|
+
def _scope(
|
|
247
|
+
self,
|
|
248
|
+
url: str,
|
|
249
|
+
options: Mapping[str, object],
|
|
250
|
+
) -> tuple[str, RequestOptions]:
|
|
251
|
+
"""The URL to request and the options to make it with, scope included."""
|
|
252
|
+
given = dict(options)
|
|
253
|
+
base = given.get("base_uri")
|
|
254
|
+
|
|
255
|
+
if isinstance(base, str):
|
|
256
|
+
url = urljoin(base, url)
|
|
257
|
+
del given["base_uri"]
|
|
258
|
+
elif not urlsplit(url).scheme and self._default_regexp is not None:
|
|
259
|
+
default = self._by_regexp[self._default_regexp].get("base_uri")
|
|
260
|
+
if isinstance(default, str):
|
|
261
|
+
url = urljoin(default, url)
|
|
262
|
+
|
|
263
|
+
for pattern, defaults in self._by_regexp.items():
|
|
264
|
+
if re.match(pattern, url):
|
|
265
|
+
given = _under(given, defaults)
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
return url, as_options(given)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _in_scope(base_uri: str) -> str:
|
|
272
|
+
"""The pattern matching every URL under ``base_uri``, taken literally."""
|
|
273
|
+
return re.escape(urljoin(base_uri, "."))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _under(given: dict[str, object], defaults: Mapping[str, object]) -> dict[str, object]:
|
|
277
|
+
"""``defaults`` under ``given``: an option the request named is the one used."""
|
|
278
|
+
merged = dict(given)
|
|
279
|
+
for name, value in defaults.items():
|
|
280
|
+
_ = merged.setdefault(name, value)
|
|
281
|
+
|
|
282
|
+
headers = _headers(given.get("headers"))
|
|
283
|
+
scoped = _headers(defaults.get("headers"))
|
|
284
|
+
if headers is not None and scoped is not None:
|
|
285
|
+
# Name by name rather than whole, so a request setting one header
|
|
286
|
+
# still sends the scope's others; the request's come first, as they
|
|
287
|
+
# are the ones that were asked for here. Names are case-insensitive
|
|
288
|
+
# on the wire, so ``accept`` from the request hides a scope's ``Accept``.
|
|
289
|
+
taken = {name.lower() for name in headers}
|
|
290
|
+
merged["headers"] = {
|
|
291
|
+
**headers,
|
|
292
|
+
**{n: v for n, v in scoped.items() if n.lower() not in taken},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return merged
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _headers(value: object) -> Mapping[str, object] | None:
|
|
299
|
+
"""``value`` as the ``headers`` option, or ``None`` when it is not one."""
|
|
300
|
+
if isinstance(value, Mapping):
|
|
301
|
+
return cast("Mapping[str, object]", value)
|
|
302
|
+
|
|
303
|
+
return None
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The client that holds a request back until the rate it is under allows it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Unpack, override
|
|
8
|
+
from xtr_http_client_contracts import HttpClientInterface
|
|
9
|
+
from xtr_service_contracts import ResetInterface
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
|
|
15
|
+
from xtr_http_message_contracts import RequestInterface
|
|
16
|
+
from xtr_rate_limiter import LimiterInterface
|
|
17
|
+
|
|
18
|
+
__all__ = ["ThrottlingHttpClient"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
class ThrottlingHttpClient(HttpClientInterface, ResetInterface):
|
|
23
|
+
"""Wraps a client and lets each request go only once the rate allows it.
|
|
24
|
+
|
|
25
|
+
An API that answers a hundred calls a minute answers the hundred and
|
|
26
|
+
first with a refusal, and a caller that keeps asking is throttled by the
|
|
27
|
+
service it is calling rather than by itself. Written at the call site
|
|
28
|
+
that is a counter and a sleep around every request; here it is the client,
|
|
29
|
+
so the code calling out is unchanged and the pace is kept in one place —
|
|
30
|
+
including between several callers, when the limiter they share counts
|
|
31
|
+
where they can all see it.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
client = ThrottlingHttpClient(inner, limiter)
|
|
35
|
+
async with await client.request("GET", "https://api.example.com/orders") as response:
|
|
36
|
+
orders = await response.to_array()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A token is booked before each request, and the request waits for the
|
|
40
|
+
moment that booking is due. Booking first and waiting after is what makes
|
|
41
|
+
the pace hold under concurrency: the limiter counts the token as spent as
|
|
42
|
+
soon as it is reserved, so requests leave in the order they were asked
|
|
43
|
+
for, each at its own turn, rather than all at once as a limiter merely
|
|
44
|
+
asked whether it may go ahead would allow.
|
|
45
|
+
|
|
46
|
+
The limiter is the pace, so a copy made by :meth:`with_options` shares it:
|
|
47
|
+
narrowing a client for one job hands out another way of making requests,
|
|
48
|
+
not another allowance to make them.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
__slots__ = ("_client", "_limiter")
|
|
52
|
+
|
|
53
|
+
def __init__(self, client: HttpClientInterface, limiter: LimiterInterface) -> None:
|
|
54
|
+
"""Pace what ``client`` sends by ``limiter``.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
client: The client every request is handed to, once it is due.
|
|
58
|
+
limiter: What a token is booked from before each request. Which
|
|
59
|
+
requests share a pace is decided by which of them are given
|
|
60
|
+
the same limiter — one per host, one per account, one for
|
|
61
|
+
the whole application.
|
|
62
|
+
"""
|
|
63
|
+
self._client = client
|
|
64
|
+
self._limiter = limiter
|
|
65
|
+
|
|
66
|
+
@override
|
|
67
|
+
async def request(
|
|
68
|
+
self,
|
|
69
|
+
method: str,
|
|
70
|
+
url: str,
|
|
71
|
+
**options: Unpack[RequestOptions],
|
|
72
|
+
) -> HttpResponseInterface:
|
|
73
|
+
"""Make a request for ``method`` and ``url``, once a token is due.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
method: The HTTP method, in upper case.
|
|
77
|
+
url: The URL to request, absolute or relative to a ``base_uri``.
|
|
78
|
+
**options: How the request is to be made.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Whatever the wrapped client answers.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
InvalidArgumentError: For an invalid method, URL or option.
|
|
85
|
+
TransportError: When no answer came.
|
|
86
|
+
"""
|
|
87
|
+
await self._due()
|
|
88
|
+
|
|
89
|
+
return await self._client.request(method, url, **options)
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
async def send_request(
|
|
93
|
+
self,
|
|
94
|
+
request: RequestInterface,
|
|
95
|
+
**options: Unpack[RequestOptions],
|
|
96
|
+
) -> HttpResponseInterface:
|
|
97
|
+
"""Send ``request``, once a token is due.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
request: The request to send.
|
|
101
|
+
**options: What the request leaves out.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Whatever the wrapped client answers.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
InvalidArgumentError: For an invalid method, URL or option.
|
|
108
|
+
TransportError: When no answer came.
|
|
109
|
+
"""
|
|
110
|
+
await self._due()
|
|
111
|
+
|
|
112
|
+
return await self._client.send_request(request, **options)
|
|
113
|
+
|
|
114
|
+
@override
|
|
115
|
+
def with_options(self, **options: Unpack[RequestOptions]) -> Self:
|
|
116
|
+
"""Return a copy around a wrapped client whose defaults have ``options`` over them.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
**options: The defaults of the wrapped client of the copy.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A new client, paced by this one's limiter, so requests made
|
|
123
|
+
through either count against the same rate; this one is left as
|
|
124
|
+
it was.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
InvalidArgumentError: When an option is invalid.
|
|
128
|
+
"""
|
|
129
|
+
return type(self)(self._client.with_options(**options), self._limiter)
|
|
130
|
+
|
|
131
|
+
@override
|
|
132
|
+
async def reset(self) -> None:
|
|
133
|
+
"""Forget every request counted against the rate, and reset the wrapped client."""
|
|
134
|
+
await self._limiter.reset()
|
|
135
|
+
|
|
136
|
+
if isinstance(self._client, ResetInterface):
|
|
137
|
+
await self._client.reset()
|
|
138
|
+
|
|
139
|
+
async def _due(self) -> None:
|
|
140
|
+
"""Book a token and wait for the moment it may be spent."""
|
|
141
|
+
reservation = await self._limiter.reserve()
|
|
142
|
+
|
|
143
|
+
await reservation.wait()
|