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