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,176 @@
1
+ """The work every client here does before a connection is opened."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import re
8
+ from collections.abc import Sequence
9
+ from typing import TYPE_CHECKING, Final, Self
10
+
11
+ from typing_extensions import Unpack
12
+ from xtr_http_client_contracts import OPTIONS_DEFAULTS
13
+
14
+ from xtr_http_client.exception import InvalidArgumentError
15
+ from xtr_http_client.internal import option_merge
16
+ from xtr_http_client.internal.debug_type import debug_type
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping
20
+
21
+ from xtr_http_client_contracts import RequestOptions
22
+
23
+ __all__ = ["HttpClientMixin"]
24
+
25
+ _AUTH_BASIC_PARTS: Final = (1, 2)
26
+ _UNPRINTABLE: Final = re.compile(r"[^\x21-\x7E]")
27
+ _NO_BODY: Final = (None, b"", "")
28
+
29
+
30
+ class HttpClientMixin:
31
+ """What a client refuses, and the options it starts every request from.
32
+
33
+ Mixed into every client here, so all of them keep their defaults the same
34
+ way and refuse the same requests — which is what lets one be swapped for
35
+ another without a caller noticing. A client is then only the part that
36
+ opens connections.
37
+
38
+ Its own state is the one thing a client needs from it: the options every
39
+ request starts from. ``__init__`` takes them so a subclass can hand over
40
+ what it was built with and get back the merged defaults.
41
+
42
+ The checks are exposed one by one because a client makes them one at a
43
+ time: the method is known before the options are read, and a client
44
+ preparing its own defaults has no method to check at all.
45
+ """
46
+
47
+ __slots__: tuple[str, ...] = ("_default_options",)
48
+
49
+ _default_options: dict[str, object]
50
+
51
+ def __init__(self, default_options: RequestOptions | None = None) -> None:
52
+ """Start from the contract's defaults, with ``default_options`` over them.
53
+
54
+ Raises:
55
+ InvalidArgumentError: When an option is not one this client knows.
56
+ """
57
+ self._default_options = dict(OPTIONS_DEFAULTS)
58
+ if default_options:
59
+ self._default_options = self.merge_default_options(
60
+ default_options,
61
+ self._default_options,
62
+ )
63
+
64
+ def with_options(self, **options: Unpack[RequestOptions]) -> Self:
65
+ """A copy of this client whose defaults have ``options`` over them.
66
+
67
+ A copy rather than a change, so a caller can narrow a client for one
68
+ job — a header, a timeout, a base URI — without the client everyone
69
+ else holds changing under them.
70
+
71
+ Raises:
72
+ InvalidArgumentError: When an option is not one this client knows.
73
+ """
74
+ clone = copy.copy(self)
75
+ # The clone is this very class, and its defaults are the one thing
76
+ # about it that has to change; there is no other way to seed them.
77
+ clone._default_options = self.merge_default_options( # noqa: SLF001 — same class
78
+ options,
79
+ self._default_options,
80
+ )
81
+
82
+ return clone
83
+
84
+ @classmethod
85
+ def merge_default_options(
86
+ cls,
87
+ options: Mapping[str, object],
88
+ default_options: Mapping[str, object],
89
+ ) -> dict[str, object]:
90
+ """Merge what a request asked for over what the client always asks for.
91
+
92
+ See :func:`xtr_http_client.internal.option_merge.merge_default_options`.
93
+ """
94
+ return option_merge.merge_default_options(
95
+ cls.__qualname__,
96
+ options,
97
+ default_options,
98
+ )
99
+
100
+ def validate_options(self, options: Mapping[str, object]) -> None:
101
+ """Refuse an option this client does not know, naming the closest it does.
102
+
103
+ See :func:`xtr_http_client.internal.option_merge.validate_options`.
104
+ """
105
+ option_merge.validate_options(type(self).__qualname__, options, self._default_options)
106
+
107
+ @staticmethod
108
+ def validate_method(method: str) -> None:
109
+ """Refuse a method no server would read as the one that was meant.
110
+
111
+ Raises:
112
+ InvalidArgumentError: When the method is empty, or is anything
113
+ other than uppercase ASCII letters.
114
+ """
115
+ if method == "":
116
+ raise InvalidArgumentError("The HTTP method cannot be empty.")
117
+ if not (method.isascii() and method.isalpha() and method.isupper()):
118
+ raise InvalidArgumentError(
119
+ f'Invalid HTTP method "{method}", only uppercase letters are accepted.'
120
+ )
121
+
122
+ @staticmethod
123
+ def validate_auth(options: Mapping[str, object]) -> None:
124
+ """Refuse credentials this client cannot send, or two sets of them.
125
+
126
+ Raises:
127
+ InvalidArgumentError: When ``auth_basic`` or ``auth_bearer`` is
128
+ not of a shape credentials can be read from, or both are set.
129
+ """
130
+ basic = options.get("auth_basic")
131
+ if isinstance(basic, Sequence) and not isinstance(basic, str):
132
+ if len(basic) not in _AUTH_BASIC_PARTS:
133
+ raise InvalidArgumentError(
134
+ f'Option "auth_basic" must contain 1 or 2 elements, "{len(basic)}" given.'
135
+ )
136
+ elif basic is not None and not isinstance(basic, str):
137
+ raise InvalidArgumentError(
138
+ f'Option "auth_basic" must be a string or a tuple, "{debug_type(basic)}" given.'
139
+ )
140
+
141
+ bearer = options.get("auth_bearer")
142
+ if bearer is not None:
143
+ HttpClientMixin._validate_bearer(bearer)
144
+ if basic is not None and bearer is not None:
145
+ raise InvalidArgumentError(
146
+ 'Define either the "auth_basic" or the "auth_bearer" option, '
147
+ "setting both is not supported."
148
+ )
149
+
150
+ @staticmethod
151
+ def validate_body(options: Mapping[str, object]) -> None:
152
+ """Refuse a request that says what to send twice.
153
+
154
+ A JSON document and a body are both the whole body, so a request
155
+ carrying each has not said which one to send.
156
+
157
+ Raises:
158
+ InvalidArgumentError: When ``json`` and ``body`` are both set.
159
+ """
160
+ if options.get("json") is None:
161
+ return
162
+ if options.get("body") not in _NO_BODY:
163
+ raise InvalidArgumentError(
164
+ 'Define either the "json" or the "body" option, setting both is not supported.'
165
+ )
166
+
167
+ @staticmethod
168
+ def _validate_bearer(bearer: object) -> None:
169
+ if not isinstance(bearer, str):
170
+ raise InvalidArgumentError(
171
+ f'Option "auth_bearer" must be a string, "{debug_type(bearer)}" given.'
172
+ )
173
+ if _UNPRINTABLE.search(bearer):
174
+ raise InvalidArgumentError(
175
+ f'Invalid character found in option "auth_bearer": {json.dumps(bearer)}.'
176
+ )
@@ -0,0 +1,327 @@
1
+ """The client that opens the connections and streams what comes back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import TYPE_CHECKING, cast, final
7
+
8
+ import anyio
9
+ import httpx
10
+ from typing_extensions import Unpack, override
11
+ from xtr_http_client_contracts import HttpClientInterface
12
+ from xtr_http_message import Request
13
+
14
+ from .http_client_mixin import HttpClientMixin
15
+ from .internal.httpx_clients import HttpxClients
16
+ from .internal.httpx_errors import timed_out, transport_error
17
+ from .internal.httpx_redirect import next_hop, same_origin
18
+ from .internal.httpx_request import (
19
+ assemble_headers,
20
+ assemble_payload,
21
+ carries,
22
+ pin_host,
23
+ target_url,
24
+ )
25
+ from .response.httpx_response import to_http_response
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Mapping, Sequence
29
+ from types import TracebackType
30
+ from typing import Self
31
+
32
+ from xtr_http_client_contracts import RequestOptions
33
+ from xtr_http_message_contracts import RequestInterface
34
+
35
+ from .response import HttpResponse
36
+
37
+ __all__ = ["HttpxHttpClient"]
38
+
39
+
40
+ @final
41
+ class HttpxHttpClient(HttpClientMixin, HttpClientInterface):
42
+ """Sends requests over real connections, and hands back answers as they arrive.
43
+
44
+ A request returns once the status line and the headers are in, whatever
45
+ the status; the body is read off the connection when the caller asks for
46
+ it, and the connection is held until the body is done with or the
47
+ response is left.
48
+
49
+ The request is authoritative and the options fill what it leaves out — a
50
+ header it already carries is the one sent, credentials apply only where it
51
+ has no ``Authorization`` of its own — which is what lets one set of
52
+ client-wide defaults sit under requests that each know their own mind.
53
+ What each part of an outgoing request is assembled from is in
54
+ :mod:`xtr_http_client.internal.httpx_request`; what is here is the
55
+ exchange, and the redirect chain it may turn into.
56
+
57
+ Connections are pooled by how they connect rather than by host: a proxy,
58
+ a trust store, a protocol version and the deadlines all decide what a
59
+ connection is, so each combination of them gets a pool, and
60
+ ``max_host_connections`` is each pool's ceiling across every host it
61
+ reaches.
62
+
63
+ ```python
64
+ async with HttpxHttpClient(max_host_connections=4) as client:
65
+ response = await client.request("GET", "https://example.com/orders")
66
+ orders = await response.to_array()
67
+ ```
68
+
69
+ Redirects are followed here rather than by the backend, so that an
70
+ ``Authorization`` header is dropped the moment a chain leaves the host it
71
+ was meant for, and comes back only when the chain returns to it.
72
+ """
73
+
74
+ __slots__ = ("_clients",)
75
+
76
+ def __init__(
77
+ self,
78
+ default_options: RequestOptions | None = None,
79
+ max_host_connections: int = 6,
80
+ ) -> None:
81
+ """Start from ``default_options``, opening nothing until a request is made.
82
+
83
+ Args:
84
+ default_options: What every request this client makes starts
85
+ from, over the contract's own defaults.
86
+ max_host_connections: How many connections one pool may hold at
87
+ once.
88
+
89
+ Raises:
90
+ InvalidArgumentError: When a default option is not one this
91
+ client knows.
92
+ """
93
+ super().__init__(default_options)
94
+ self._clients = HttpxClients(max_host_connections)
95
+
96
+ @override
97
+ async def request(
98
+ self,
99
+ method: str,
100
+ url: str,
101
+ **options: Unpack[RequestOptions],
102
+ ) -> HttpResponse:
103
+ """Make a request for ``method`` and ``url``, and return once its head is in.
104
+
105
+ Args:
106
+ method: The HTTP method, in upper case.
107
+ url: The URL to request, absolute or relative to ``base_uri``.
108
+ **options: How the request is to be made.
109
+
110
+ Returns:
111
+ The response, with its status and headers already in.
112
+
113
+ Raises:
114
+ TransportTimeoutError: When no answer came in time.
115
+ InvalidArgumentError: For an invalid method, URL or option.
116
+ TransportError: When no answer came.
117
+ """
118
+ self.validate_method(method)
119
+
120
+ return await self.send_request(Request(method, _reachable(url)), **options)
121
+
122
+ @override
123
+ async def send_request(
124
+ self,
125
+ request: RequestInterface,
126
+ **options: Unpack[RequestOptions],
127
+ ) -> HttpResponse:
128
+ """Send ``request``, the options filling what it leaves out.
129
+
130
+ Args:
131
+ request: The request to send.
132
+ **options: What the request leaves out.
133
+
134
+ Returns:
135
+ The response, with its status and headers already in.
136
+
137
+ Raises:
138
+ TransportTimeoutError: When no answer came in time.
139
+ InvalidArgumentError: For an invalid method, URL or option, and
140
+ for a body given both with the request and as an option.
141
+ TransportError: When no answer came.
142
+ """
143
+ self.validate_options(options)
144
+ self.validate_method(request.get_method())
145
+ merged = self.merge_default_options(options, self._default_options)
146
+ self.validate_auth(merged)
147
+ self.validate_body(merged)
148
+
149
+ client, resolved = self._clients.for_options(merged)
150
+ headers = assemble_headers(request, merged)
151
+ payload = await assemble_payload(request, merged, headers)
152
+ url, extensions = pin_host(target_url(request, merged), merged, resolved, headers)
153
+
154
+ outgoing = client.build_request(
155
+ request.get_method(),
156
+ url,
157
+ content=payload.content,
158
+ data=payload.form,
159
+ json=payload.document,
160
+ headers=headers,
161
+ extensions=extensions,
162
+ )
163
+
164
+ return await self._send(client, outgoing, merged, headers)
165
+
166
+ async def aclose(self) -> None:
167
+ """Close every pool, and with it every connection this client holds."""
168
+ await self._clients.aclose()
169
+
170
+ async def __aenter__(self) -> Self:
171
+ """Enter a block that owns this client's connections.
172
+
173
+ Returns:
174
+ This client.
175
+ """
176
+ return self
177
+
178
+ async def __aexit__(
179
+ self,
180
+ exc_type: type[BaseException] | None,
181
+ exc: BaseException | None,
182
+ traceback: TracebackType | None,
183
+ ) -> None:
184
+ """Close every connection however the block ends.
185
+
186
+ Args:
187
+ exc_type: The class of the exception the block is leaving with.
188
+ exc: That exception.
189
+ traceback: Its traceback.
190
+ """
191
+ del exc_type, exc, traceback
192
+ await self.aclose()
193
+
194
+ async def _send(
195
+ self,
196
+ client: httpx.AsyncClient,
197
+ outgoing: httpx.Request,
198
+ options: Mapping[str, object],
199
+ headers: Sequence[tuple[str, str]],
200
+ ) -> HttpResponse:
201
+ """Make the exchange, under whatever deadline the whole request was given."""
202
+ url = str(outgoing.url)
203
+ limit = _seconds(options.get("max_duration"))
204
+ start_time = time.time()
205
+ deadline = None if limit is None else anyio.current_time() + limit
206
+
207
+ try:
208
+ with anyio.fail_after(limit):
209
+ response, history = await _exchange(
210
+ client,
211
+ outgoing,
212
+ _basic_credentials(options, headers),
213
+ _hops(options.get("max_redirects")),
214
+ )
215
+ except TimeoutError as error:
216
+ raise timed_out(url) from error
217
+ except (httpx.HTTPError, httpx.InvalidURL) as error:
218
+ raise transport_error(error, url) from error
219
+
220
+ return await to_http_response(
221
+ response,
222
+ history,
223
+ response.request.method,
224
+ start_time,
225
+ options.get("user_data"),
226
+ deadline=deadline,
227
+ decode=not carries(headers, "accept-encoding"),
228
+ )
229
+
230
+
231
+ async def _exchange(
232
+ client: httpx.AsyncClient,
233
+ outgoing: httpx.Request,
234
+ auth: tuple[str, str] | None,
235
+ max_redirects: int,
236
+ ) -> tuple[httpx.Response, list[httpx.Response]]:
237
+ """Send, then follow where each answer points until one points nowhere.
238
+
239
+ Following is done here, a hop at a time, for the one thing the backend
240
+ cannot be told: credentials belong to the host they were meant for, so an
241
+ ``Authorization`` the backend dropped on the way out of that host is put
242
+ back the moment the chain returns to it, and nowhere else.
243
+ """
244
+ response = await client.send(outgoing, auth=auth, stream=True, follow_redirects=False)
245
+ history: list[httpx.Response] = []
246
+ origin = response.request.url
247
+ authorization = cast("str | None", response.request.headers.get("authorization"))
248
+
249
+ while len(history) < max_redirects:
250
+ following = next_hop(response)
251
+ if following is None:
252
+ break
253
+
254
+ if (
255
+ authorization is not None
256
+ and "authorization" not in following.headers
257
+ and same_origin(following.url, origin)
258
+ ):
259
+ following.headers["Authorization"] = authorization
260
+
261
+ history.append(response)
262
+ await response.aclose()
263
+ response = await client.send(following, stream=True, follow_redirects=False)
264
+
265
+ return response, history
266
+
267
+
268
+ def _reachable(url: str) -> str:
269
+ """``url`` with every part of it written in the form that reaches it.
270
+
271
+ A host named in another script is looked up in its ``xn--`` form, which
272
+ is also the only form a message request will hold, so the conversion
273
+ happens here — while the URL is still text — rather than further in,
274
+ where it would surface as a URL nobody can parse.
275
+
276
+ Raises:
277
+ InvalidArgumentError: When the URL cannot be read at all.
278
+ """
279
+ if url.isascii():
280
+ return url
281
+
282
+ try:
283
+ parsed = httpx.URL(url)
284
+
285
+ return str(parsed.copy_with(host=parsed.raw_host.decode("ascii")))
286
+ except (httpx.InvalidURL, UnicodeError) as error:
287
+ raise transport_error(httpx.InvalidURL(str(error)), url) from error
288
+
289
+
290
+ def _basic_credentials(
291
+ options: Mapping[str, object],
292
+ headers: Sequence[tuple[str, str]],
293
+ ) -> tuple[str, str] | None:
294
+ """The user and password to authenticate with, when the request has no say.
295
+
296
+ Credentials written into the URL are left where they are: the backend
297
+ reads them off it, which is also what keeps them out of a redirect that
298
+ leaves the host.
299
+ """
300
+ basic = options.get("auth_basic")
301
+ if basic is None or carries(headers, "authorization"):
302
+ return None
303
+
304
+ if isinstance(basic, str):
305
+ user, _, password = basic.partition(":")
306
+
307
+ return user, password
308
+
309
+ parts = [str(part) for part in cast("Sequence[object]", basic)]
310
+
311
+ return parts[0], parts[1] if len(parts) > 1 else ""
312
+
313
+
314
+ def _seconds(value: object) -> float | None:
315
+ """A limit in seconds, or ``None`` when zero said there is none."""
316
+ if isinstance(value, (int, float)) and value > 0:
317
+ return float(value)
318
+
319
+ return None
320
+
321
+
322
+ def _hops(value: object) -> int:
323
+ """How many redirects may be followed; zero or less follows none."""
324
+ if isinstance(value, int):
325
+ return max(0, value)
326
+
327
+ return 0
@@ -0,0 +1,13 @@
1
+ """What the clients of this package share, and nobody outside it should import.
2
+
3
+ The option handling a client does before a connection is opened — merging a
4
+ request's options over the client's defaults and refusing a name no client
5
+ knows — reaches callers through
6
+ :class:`HttpClientMixin <xtr_http_client.http_client_mixin.HttpClientMixin>`,
7
+ which is the one supported way in. These modules are where it is written; they
8
+ move without notice.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __all__: list[str] = []
@@ -0,0 +1,14 @@
1
+ """How a value is named in the message that refuses it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["debug_type"]
6
+
7
+
8
+ def debug_type(value: object) -> str:
9
+ """Name the type of ``value`` the way an error message should.
10
+
11
+ ``None`` is named ``null`` because that is what a caller wrote, or what a
12
+ JSON document carried; everything else is named by its class.
13
+ """
14
+ return "null" if value is None else type(value).__name__
@@ -0,0 +1,88 @@
1
+ """The pieces of a response body, as they come off the backend transfer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ import anyio
8
+ import httpx
9
+
10
+ from .httpx_errors import timed_out, transport_error
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import AsyncIterator
14
+ from typing import Self
15
+
16
+ __all__ = ["HttpxBody"]
17
+
18
+
19
+ @final
20
+ class HttpxBody:
21
+ """The backend's body, with the two things a response body of this package needs.
22
+
23
+ A deadline for the whole request covers the body as well as the head, and
24
+ the body is read long after the request was made — wherever the caller
25
+ asked for it — so the deadline has to travel with the body rather than
26
+ stay where the request was. It is checked on every piece.
27
+
28
+ And a transfer that breaks half way raises the backend's own error, which
29
+ a caller holding the client interface has never heard of. Translating it
30
+ here means every piece passes one place where that cannot escape.
31
+ """
32
+
33
+ __slots__ = ("_deadline", "_pieces", "_response", "_url")
34
+
35
+ def __init__(
36
+ self,
37
+ response: httpx.Response,
38
+ pieces: AsyncIterator[bytes],
39
+ deadline: float | None = None,
40
+ ) -> None:
41
+ """Hold the transfer the body comes out of.
42
+
43
+ Args:
44
+ response: The transfer, which closing the body lets go of.
45
+ pieces: The body, piece by piece, as the backend produces it.
46
+ deadline: When the whole request runs out of time, on anyio's
47
+ clock. ``None`` is no limit, which is what ``max_duration``
48
+ of zero means.
49
+ """
50
+ self._response = response
51
+ self._pieces = pieces
52
+ self._deadline = deadline
53
+ self._url = str(response.url)
54
+
55
+ def __aiter__(self) -> Self:
56
+ """Return this iterator, which is the body itself."""
57
+ return self
58
+
59
+ async def __anext__(self) -> bytes:
60
+ """Return the next piece of the body, waiting for it to arrive.
61
+
62
+ Returns:
63
+ The piece, which may be of any size the transfer arrived in.
64
+
65
+ Raises:
66
+ StopAsyncIteration: Once the whole body has arrived.
67
+ TransportTimeoutError: When the request ran out of time.
68
+ TransportError: When the transfer broke.
69
+ """
70
+ try:
71
+ with anyio.fail_after(self._remaining()):
72
+ return await self._pieces.__anext__()
73
+ except StopAsyncIteration:
74
+ raise
75
+ except TimeoutError as error:
76
+ raise timed_out(self._url) from error
77
+ except (httpx.HTTPError, httpx.InvalidURL) as error:
78
+ raise transport_error(error, self._url) from error
79
+
80
+ async def aclose(self) -> None:
81
+ """Let go of the transfer, which frees the connection it was holding."""
82
+ await self._response.aclose()
83
+
84
+ def _remaining(self) -> float | None:
85
+ """How long the request has left, or ``None`` when it was given no limit."""
86
+ if self._deadline is None:
87
+ return None
88
+ return self._deadline - anyio.current_time()