boring-http 1.0.0__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.
- boring_http/__init__.py +58 -0
- boring_http/client.py +692 -0
- boring_http/codings.py +39 -0
- boring_http/cookies.py +127 -0
- boring_http/errors.py +49 -0
- boring_http/hpack.py +247 -0
- boring_http/hpack_tables.py +326 -0
- boring_http/http1.py +259 -0
- boring_http/http2.py +685 -0
- boring_http/http3.py +551 -0
- boring_http/message.py +598 -0
- boring_http/pool.py +96 -0
- boring_http/py.typed +0 -0
- boring_http/qpack.py +245 -0
- boring_http/qpack_tables.py +104 -0
- boring_http/quic/__init__.py +0 -0
- boring_http/quic/connection.py +1261 -0
- boring_http/quic/packet.py +655 -0
- boring_http/quic/protocol.py +474 -0
- boring_http/quic/recovery.py +303 -0
- boring_http/quic/streams.py +154 -0
- boring_http/timing.py +63 -0
- boring_http/websocket.py +216 -0
- boring_http-1.0.0.dist-info/METADATA +303 -0
- boring_http-1.0.0.dist-info/RECORD +26 -0
- boring_http-1.0.0.dist-info/WHEEL +4 -0
boring_http/client.py
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
from collections.abc import AsyncGenerator, AsyncIterable, Mapping, Sequence
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from time import monotonic
|
|
7
|
+
from typing import Final, Self, TypedDict, Unpack, final
|
|
8
|
+
|
|
9
|
+
from boring_ssl2 import ClientContext as TLSClientContext
|
|
10
|
+
from ssl2 import (
|
|
11
|
+
AsyncTransport,
|
|
12
|
+
ClientHello,
|
|
13
|
+
ConfigurationError,
|
|
14
|
+
ExtensionType,
|
|
15
|
+
NextProtocol,
|
|
16
|
+
Proxy,
|
|
17
|
+
ProxyError,
|
|
18
|
+
ProxyScheme,
|
|
19
|
+
SessionCache,
|
|
20
|
+
StreamTransport,
|
|
21
|
+
TLSClientConfiguration,
|
|
22
|
+
TLSError,
|
|
23
|
+
TLSStream,
|
|
24
|
+
TrustStore,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from boring_http.cookies import CookieJar
|
|
28
|
+
from boring_http.errors import (
|
|
29
|
+
ConnectError,
|
|
30
|
+
HTTPError,
|
|
31
|
+
ProtocolError,
|
|
32
|
+
RemoteDisconnected,
|
|
33
|
+
StreamRefused,
|
|
34
|
+
TimeoutError,
|
|
35
|
+
TooManyRedirects,
|
|
36
|
+
)
|
|
37
|
+
from boring_http.http1 import Http1Connection
|
|
38
|
+
from boring_http.http2 import CHROME, Http2Connection, Http2Profile
|
|
39
|
+
from boring_http.http3 import DEFAULT as HTTP3_DEFAULT
|
|
40
|
+
from boring_http.http3 import Http3Connection, Http3Profile
|
|
41
|
+
from boring_http.message import (
|
|
42
|
+
URL,
|
|
43
|
+
Body,
|
|
44
|
+
FileField,
|
|
45
|
+
FormFields,
|
|
46
|
+
HeaderItems,
|
|
47
|
+
Headers,
|
|
48
|
+
HTTPVersion,
|
|
49
|
+
Params,
|
|
50
|
+
Request,
|
|
51
|
+
Response,
|
|
52
|
+
)
|
|
53
|
+
from boring_http.pool import Connection, Pool
|
|
54
|
+
from boring_http.quic.protocol import OriginMemory, QuicClient
|
|
55
|
+
from boring_http.timing import Deadline, Timeout
|
|
56
|
+
from boring_http.websocket import WebSocket
|
|
57
|
+
|
|
58
|
+
IMPLEMENTED: Final = (HTTPVersion.HTTP_3, HTTPVersion.HTTP_2, HTTPVersion.HTTP_1)
|
|
59
|
+
ALPN: Final = {
|
|
60
|
+
HTTPVersion.HTTP_2: NextProtocol.H2,
|
|
61
|
+
HTTPVersion.HTTP_1: NextProtocol.HTTP1,
|
|
62
|
+
}
|
|
63
|
+
ACCEPTED_ENCODINGS: Final = "gzip, deflate, br, zstd"
|
|
64
|
+
USER_AGENT: Final = "boring-http"
|
|
65
|
+
DEFAULT_TIMEOUT: Final = Timeout()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@final
|
|
69
|
+
class Unset:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
UNSET: Final = Unset()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RequestOptions(TypedDict, total=False):
|
|
77
|
+
params: Params | None
|
|
78
|
+
headers: HeaderItems | None
|
|
79
|
+
json: object
|
|
80
|
+
data: FormFields | bytes | AsyncIterable[bytes] | None
|
|
81
|
+
content: bytes | str | AsyncIterable[bytes] | None
|
|
82
|
+
files: Mapping[str, FileField] | None
|
|
83
|
+
auth: tuple[str, str] | None
|
|
84
|
+
cookies: Mapping[str, str] | None
|
|
85
|
+
timeout: float | Timeout | None | Unset
|
|
86
|
+
allow_redirects: bool
|
|
87
|
+
proxy: Proxy | str | None | Unset
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@final
|
|
91
|
+
class Client:
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
headers: HeaderItems | None = None,
|
|
96
|
+
timeout: float | Timeout | None = DEFAULT_TIMEOUT,
|
|
97
|
+
proxy: Proxy | str | None = None,
|
|
98
|
+
tls: ClientHello | None = None,
|
|
99
|
+
http2: Http2Profile = CHROME,
|
|
100
|
+
http3: Http3Profile = HTTP3_DEFAULT,
|
|
101
|
+
versions: Sequence[HTTPVersion | str] = (
|
|
102
|
+
HTTPVersion.HTTP_2,
|
|
103
|
+
HTTPVersion.HTTP_1,
|
|
104
|
+
),
|
|
105
|
+
trust_store: TrustStore | None = None,
|
|
106
|
+
verify: bool = True,
|
|
107
|
+
cookies: CookieJar | None = None,
|
|
108
|
+
base_url: str | None = None,
|
|
109
|
+
max_connections: int = 100,
|
|
110
|
+
max_connections_per_host: int = 10,
|
|
111
|
+
max_redirects: int = 20,
|
|
112
|
+
max_message_size: int = 16 * 1024 * 1024,
|
|
113
|
+
decode: bool = True,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._headers = Headers(headers)
|
|
116
|
+
self._timeout = Timeout.of(timeout)
|
|
117
|
+
self._proxy = Proxy.parse(proxy) if isinstance(proxy, str) else proxy
|
|
118
|
+
self._tls = tls
|
|
119
|
+
self._http2 = http2
|
|
120
|
+
self._http3 = http3
|
|
121
|
+
self._alt_svc: dict[tuple[str, int], float] = {}
|
|
122
|
+
self._versions = tuple(HTTPVersion(version) for version in versions)
|
|
123
|
+
self._trust_store = trust_store
|
|
124
|
+
self._verify = verify
|
|
125
|
+
self._base_url = base_url
|
|
126
|
+
self._max_redirects = max_redirects
|
|
127
|
+
self._max_message_size = max_message_size
|
|
128
|
+
self._decode = decode
|
|
129
|
+
self._contexts: dict[tuple[NextProtocol, ...] | None, TLSClientContext] = {}
|
|
130
|
+
self._memory: dict[tuple[str, int], OriginMemory] = {}
|
|
131
|
+
self._pool = Pool(self._connect, max_connections, max_connections_per_host)
|
|
132
|
+
self.cookies = CookieJar() if cookies is None else cookies
|
|
133
|
+
|
|
134
|
+
async def __aenter__(self) -> Self:
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
async def __aexit__(self, *_: object) -> None:
|
|
138
|
+
await self.close()
|
|
139
|
+
|
|
140
|
+
async def close(self) -> None:
|
|
141
|
+
await self._pool.close()
|
|
142
|
+
|
|
143
|
+
async def request(
|
|
144
|
+
self, method: str, url: str, **options: Unpack[RequestOptions]
|
|
145
|
+
) -> Response:
|
|
146
|
+
request = self.build(
|
|
147
|
+
method,
|
|
148
|
+
url,
|
|
149
|
+
params=options.get("params"),
|
|
150
|
+
headers=options.get("headers"),
|
|
151
|
+
json=options.get("json"),
|
|
152
|
+
data=options.get("data"),
|
|
153
|
+
content=options.get("content"),
|
|
154
|
+
files=options.get("files"),
|
|
155
|
+
auth=options.get("auth"),
|
|
156
|
+
)
|
|
157
|
+
return await self.send(
|
|
158
|
+
request,
|
|
159
|
+
cookies=options.get("cookies"),
|
|
160
|
+
timeout=options.get("timeout", UNSET),
|
|
161
|
+
allow_redirects=options.get("allow_redirects", True),
|
|
162
|
+
proxy=options.get("proxy", UNSET),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def get(self, url: str, **options: Unpack[RequestOptions]) -> Response:
|
|
166
|
+
return await self.request("GET", url, **options)
|
|
167
|
+
|
|
168
|
+
async def post(self, url: str, **options: Unpack[RequestOptions]) -> Response:
|
|
169
|
+
return await self.request("POST", url, **options)
|
|
170
|
+
|
|
171
|
+
async def put(self, url: str, **options: Unpack[RequestOptions]) -> Response:
|
|
172
|
+
return await self.request("PUT", url, **options)
|
|
173
|
+
|
|
174
|
+
async def patch(self, url: str, **options: Unpack[RequestOptions]) -> Response:
|
|
175
|
+
return await self.request("PATCH", url, **options)
|
|
176
|
+
|
|
177
|
+
async def delete(self, url: str, **options: Unpack[RequestOptions]) -> Response:
|
|
178
|
+
return await self.request("DELETE", url, **options)
|
|
179
|
+
|
|
180
|
+
async def head(self, url: str, **options: Unpack[RequestOptions]) -> Response:
|
|
181
|
+
return await self.request("HEAD", url, **options)
|
|
182
|
+
|
|
183
|
+
async def options(self, url: str, **options: Unpack[RequestOptions]) -> Response:
|
|
184
|
+
return await self.request("OPTIONS", url, **options)
|
|
185
|
+
|
|
186
|
+
@asynccontextmanager
|
|
187
|
+
async def stream(
|
|
188
|
+
self, method: str, url: str, **options: Unpack[RequestOptions]
|
|
189
|
+
) -> AsyncGenerator[Response]:
|
|
190
|
+
request = self.build(
|
|
191
|
+
method,
|
|
192
|
+
url,
|
|
193
|
+
params=options.get("params"),
|
|
194
|
+
headers=options.get("headers"),
|
|
195
|
+
json=options.get("json"),
|
|
196
|
+
data=options.get("data"),
|
|
197
|
+
content=options.get("content"),
|
|
198
|
+
files=options.get("files"),
|
|
199
|
+
auth=options.get("auth"),
|
|
200
|
+
)
|
|
201
|
+
response = await self.send(
|
|
202
|
+
request,
|
|
203
|
+
cookies=options.get("cookies"),
|
|
204
|
+
timeout=options.get("timeout", UNSET),
|
|
205
|
+
allow_redirects=options.get("allow_redirects", True),
|
|
206
|
+
proxy=options.get("proxy", UNSET),
|
|
207
|
+
stream=True,
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
yield response
|
|
211
|
+
finally:
|
|
212
|
+
await response.close()
|
|
213
|
+
|
|
214
|
+
def build(
|
|
215
|
+
self,
|
|
216
|
+
method: str,
|
|
217
|
+
url: str,
|
|
218
|
+
*,
|
|
219
|
+
params: Params | None = None,
|
|
220
|
+
headers: HeaderItems | None = None,
|
|
221
|
+
json: object = None,
|
|
222
|
+
data: FormFields | bytes | AsyncIterable[bytes] | None = None,
|
|
223
|
+
content: bytes | str | AsyncIterable[bytes] | None = None,
|
|
224
|
+
files: Mapping[str, FileField] | None = None,
|
|
225
|
+
auth: tuple[str, str] | None = None,
|
|
226
|
+
) -> Request:
|
|
227
|
+
target = URL.parse(
|
|
228
|
+
url if self._base_url is None else URL.parse(self._base_url).join(url).text
|
|
229
|
+
)
|
|
230
|
+
if params is not None:
|
|
231
|
+
target = target.with_params(params)
|
|
232
|
+
body = self._body(json, data, content, files)
|
|
233
|
+
merged = self._headers.merged(Headers(headers))
|
|
234
|
+
if (
|
|
235
|
+
body is not None
|
|
236
|
+
and body.content_type is not None
|
|
237
|
+
and "content-type" not in merged
|
|
238
|
+
):
|
|
239
|
+
merged.add("Content-Type", body.content_type)
|
|
240
|
+
if auth is not None:
|
|
241
|
+
merged.set(
|
|
242
|
+
"Authorization",
|
|
243
|
+
"Basic "
|
|
244
|
+
+ base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode("ascii"),
|
|
245
|
+
)
|
|
246
|
+
if "user-agent" not in merged:
|
|
247
|
+
merged.insert(0, "User-Agent", USER_AGENT)
|
|
248
|
+
if self._decode and "accept-encoding" not in merged:
|
|
249
|
+
merged.add("Accept-Encoding", ACCEPTED_ENCODINGS)
|
|
250
|
+
return Request(method.upper(), target, merged, body)
|
|
251
|
+
|
|
252
|
+
async def send(
|
|
253
|
+
self,
|
|
254
|
+
request: Request,
|
|
255
|
+
*,
|
|
256
|
+
cookies: Mapping[str, str] | None = None,
|
|
257
|
+
timeout: float | Timeout | None | Unset = UNSET,
|
|
258
|
+
allow_redirects: bool = True,
|
|
259
|
+
proxy: Proxy | str | None | Unset = UNSET,
|
|
260
|
+
stream: bool = False,
|
|
261
|
+
) -> Response:
|
|
262
|
+
deadline = Deadline(
|
|
263
|
+
self._timeout if isinstance(timeout, Unset) else Timeout.of(timeout)
|
|
264
|
+
)
|
|
265
|
+
route = (
|
|
266
|
+
self._proxy
|
|
267
|
+
if isinstance(proxy, Unset)
|
|
268
|
+
else Proxy.parse(proxy)
|
|
269
|
+
if isinstance(proxy, str)
|
|
270
|
+
else proxy
|
|
271
|
+
)
|
|
272
|
+
history: list[Response] = []
|
|
273
|
+
while True:
|
|
274
|
+
response = await self._once(
|
|
275
|
+
self._with_cookies(request, cookies), deadline, route
|
|
276
|
+
)
|
|
277
|
+
self.cookies.store(response.cookies)
|
|
278
|
+
self._learn_alt_svc(response)
|
|
279
|
+
if not allow_redirects or not response.is_redirect:
|
|
280
|
+
response.history = tuple(history)
|
|
281
|
+
if not stream:
|
|
282
|
+
try:
|
|
283
|
+
_ = await response.read()
|
|
284
|
+
except BaseException:
|
|
285
|
+
await response.close()
|
|
286
|
+
raise
|
|
287
|
+
return response
|
|
288
|
+
if len(history) >= self._max_redirects:
|
|
289
|
+
await response.close()
|
|
290
|
+
raise TooManyRedirects(
|
|
291
|
+
f"more than {self._max_redirects} redirects from {history[0].url if history else request.url}"
|
|
292
|
+
)
|
|
293
|
+
_ = await response.read()
|
|
294
|
+
history.append(response)
|
|
295
|
+
request = self._redirect(request, response)
|
|
296
|
+
|
|
297
|
+
async def websocket(
|
|
298
|
+
self,
|
|
299
|
+
url: str,
|
|
300
|
+
*,
|
|
301
|
+
headers: HeaderItems | None = None,
|
|
302
|
+
subprotocols: Sequence[str] = (),
|
|
303
|
+
timeout: float | Timeout | None | Unset = UNSET,
|
|
304
|
+
proxy: Proxy | str | None | Unset = UNSET,
|
|
305
|
+
) -> WebSocket:
|
|
306
|
+
request = self.build("GET", url, headers=headers)
|
|
307
|
+
if request.url.scheme not in ("ws", "wss"):
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"websocket needs a ws or wss URL, got {request.url.scheme}"
|
|
310
|
+
)
|
|
311
|
+
deadline = Deadline(
|
|
312
|
+
self._timeout if isinstance(timeout, Unset) else Timeout.of(timeout)
|
|
313
|
+
)
|
|
314
|
+
route = (
|
|
315
|
+
self._proxy
|
|
316
|
+
if isinstance(proxy, Unset)
|
|
317
|
+
else Proxy.parse(proxy)
|
|
318
|
+
if isinstance(proxy, str)
|
|
319
|
+
else proxy
|
|
320
|
+
)
|
|
321
|
+
request = Request("GET", request.url, request.headers)
|
|
322
|
+
request.headers.remove("Accept-Encoding")
|
|
323
|
+
async with deadline.phase("connect"):
|
|
324
|
+
connection = await self._connect(
|
|
325
|
+
request.url, route, self._prefers_quic(request.url, route)
|
|
326
|
+
)
|
|
327
|
+
if (
|
|
328
|
+
isinstance(connection, (Http2Connection, Http3Connection))
|
|
329
|
+
and connection.connect_protocol
|
|
330
|
+
):
|
|
331
|
+
return await self._websocket_tunnel(
|
|
332
|
+
connection,
|
|
333
|
+
self._with_cookies(request, None),
|
|
334
|
+
deadline,
|
|
335
|
+
tuple(subprotocols),
|
|
336
|
+
)
|
|
337
|
+
if isinstance(connection, (Http2Connection, Http3Connection)):
|
|
338
|
+
await connection.close()
|
|
339
|
+
async with deadline.phase("connect"):
|
|
340
|
+
connection = await self._connect(request.url, route, http1_only=True)
|
|
341
|
+
if not isinstance(connection, Http1Connection):
|
|
342
|
+
raise ProtocolError(
|
|
343
|
+
"websocket needs an HTTP/1.1 or Extended CONNECT capable connection"
|
|
344
|
+
)
|
|
345
|
+
handshake, key = WebSocket.handshake(request.headers, tuple(subprotocols))
|
|
346
|
+
request = Request("GET", request.url, handshake)
|
|
347
|
+
try:
|
|
348
|
+
response = await connection.send(
|
|
349
|
+
self._with_cookies(request, None), deadline, decode=False
|
|
350
|
+
)
|
|
351
|
+
self.cookies.store(response.cookies)
|
|
352
|
+
if response.status != 101:
|
|
353
|
+
_ = await response.read()
|
|
354
|
+
raise ProtocolError(
|
|
355
|
+
f"websocket handshake answered {response.status} {response.reason}: {response.text[:200]}"
|
|
356
|
+
)
|
|
357
|
+
if response.headers.get("sec-websocket-accept") != WebSocket.accept(key):
|
|
358
|
+
raise ProtocolError(
|
|
359
|
+
"websocket handshake answered with a wrong Sec-WebSocket-Accept"
|
|
360
|
+
)
|
|
361
|
+
chosen = response.headers.get("sec-websocket-protocol")
|
|
362
|
+
if chosen is not None and chosen not in subprotocols:
|
|
363
|
+
raise ProtocolError(
|
|
364
|
+
f"websocket handshake chose an unoffered subprotocol {chosen!r}"
|
|
365
|
+
)
|
|
366
|
+
except BaseException:
|
|
367
|
+
await connection.close()
|
|
368
|
+
raise
|
|
369
|
+
transport, leftover = connection.detach()
|
|
370
|
+
return WebSocket(
|
|
371
|
+
transport,
|
|
372
|
+
leftover,
|
|
373
|
+
deadline,
|
|
374
|
+
subprotocol=chosen,
|
|
375
|
+
max_message_size=self._max_message_size,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
async def _websocket_tunnel(
|
|
379
|
+
self,
|
|
380
|
+
connection: Http2Connection | Http3Connection,
|
|
381
|
+
request: Request,
|
|
382
|
+
deadline: Deadline,
|
|
383
|
+
subprotocols: tuple[str, ...],
|
|
384
|
+
) -> WebSocket:
|
|
385
|
+
headers = request.headers.copy()
|
|
386
|
+
for name in ("Upgrade", "Connection", "Sec-WebSocket-Key"):
|
|
387
|
+
headers.remove(name)
|
|
388
|
+
headers.set("Sec-WebSocket-Version", "13")
|
|
389
|
+
if subprotocols:
|
|
390
|
+
headers.set("Sec-WebSocket-Protocol", ", ".join(subprotocols))
|
|
391
|
+
status, response_headers, transport = await connection.tunnel(
|
|
392
|
+
Request("CONNECT", request.url, headers), deadline
|
|
393
|
+
)
|
|
394
|
+
if status != 200:
|
|
395
|
+
await transport.close()
|
|
396
|
+
raise ProtocolError(f"websocket Extended CONNECT answered {status}")
|
|
397
|
+
chosen = response_headers.get("sec-websocket-protocol")
|
|
398
|
+
if chosen is not None and chosen not in subprotocols:
|
|
399
|
+
await transport.close()
|
|
400
|
+
raise ProtocolError(
|
|
401
|
+
f"websocket handshake chose an unoffered subprotocol {chosen!r}"
|
|
402
|
+
)
|
|
403
|
+
return WebSocket(
|
|
404
|
+
transport,
|
|
405
|
+
b"",
|
|
406
|
+
deadline,
|
|
407
|
+
subprotocol=chosen,
|
|
408
|
+
max_message_size=self._max_message_size,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def _context(
|
|
412
|
+
self, protocols: tuple[NextProtocol, ...] | None = None
|
|
413
|
+
) -> TLSClientContext:
|
|
414
|
+
if protocols not in self._contexts:
|
|
415
|
+
hello = (
|
|
416
|
+
self._http3.tls
|
|
417
|
+
if protocols == (NextProtocol.H3,)
|
|
418
|
+
else self._adapted(protocols)
|
|
419
|
+
)
|
|
420
|
+
if hello is None:
|
|
421
|
+
offered = protocols or tuple(
|
|
422
|
+
ALPN[version] for version in self._versions if version in ALPN
|
|
423
|
+
)
|
|
424
|
+
configuration = TLSClientConfiguration(
|
|
425
|
+
trust_store=self._trust_store,
|
|
426
|
+
inner_protocols=offered,
|
|
427
|
+
verify_certificates=self._verify,
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
configuration = TLSClientConfiguration(
|
|
431
|
+
trust_store=self._trust_store,
|
|
432
|
+
client_hello=hello,
|
|
433
|
+
verify_certificates=self._verify,
|
|
434
|
+
)
|
|
435
|
+
cache = (
|
|
436
|
+
SessionCache()
|
|
437
|
+
if hello is None or hello.offers(ExtensionType.pre_shared_key)
|
|
438
|
+
else None
|
|
439
|
+
)
|
|
440
|
+
self._contexts[protocols] = TLSClientContext(
|
|
441
|
+
configuration, session_cache=cache
|
|
442
|
+
)
|
|
443
|
+
return self._contexts[protocols]
|
|
444
|
+
|
|
445
|
+
def _adapted(
|
|
446
|
+
self, protocols: tuple[NextProtocol, ...] | None
|
|
447
|
+
) -> ClientHello | None:
|
|
448
|
+
if self._tls is None or protocols is None:
|
|
449
|
+
return self._tls
|
|
450
|
+
# an HTTP/1.1-only connection keeps the profile but can offer only its own protocol
|
|
451
|
+
names = tuple(protocol.value for protocol in protocols)
|
|
452
|
+
return replace(
|
|
453
|
+
self._tls,
|
|
454
|
+
application_protocols=names,
|
|
455
|
+
application_settings=tuple(
|
|
456
|
+
name for name in self._tls.application_settings if name in names
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
async def _connect(
|
|
461
|
+
self,
|
|
462
|
+
url: URL,
|
|
463
|
+
proxy: Proxy | None,
|
|
464
|
+
quic: bool = False,
|
|
465
|
+
*,
|
|
466
|
+
http1_only: bool = False,
|
|
467
|
+
) -> Connection:
|
|
468
|
+
try:
|
|
469
|
+
if quic:
|
|
470
|
+
memory = self._memory.setdefault(url.address, OriginMemory())
|
|
471
|
+
client = await QuicClient.connect(
|
|
472
|
+
self._context((NextProtocol.H3,)),
|
|
473
|
+
url.host,
|
|
474
|
+
url.address,
|
|
475
|
+
self._http3.transport,
|
|
476
|
+
version=self._http3.quic_version,
|
|
477
|
+
proxy=proxy,
|
|
478
|
+
memory=memory,
|
|
479
|
+
)
|
|
480
|
+
connection = Http3Connection(client, self._http3, memory)
|
|
481
|
+
await connection.start()
|
|
482
|
+
return connection
|
|
483
|
+
if url.secure:
|
|
484
|
+
stream = await TLSStream.connect(
|
|
485
|
+
self._context((NextProtocol.HTTP1,) if http1_only else None),
|
|
486
|
+
url.address,
|
|
487
|
+
proxy=proxy,
|
|
488
|
+
)
|
|
489
|
+
if stream.negotiated_protocol() is NextProtocol.H2:
|
|
490
|
+
connection = Http2Connection(stream, self._http2)
|
|
491
|
+
await connection.start()
|
|
492
|
+
return connection
|
|
493
|
+
return Http1Connection(stream)
|
|
494
|
+
if proxy is not None and proxy.scheme in (
|
|
495
|
+
ProxyScheme.HTTP,
|
|
496
|
+
ProxyScheme.HTTPS,
|
|
497
|
+
):
|
|
498
|
+
transport: AsyncTransport
|
|
499
|
+
if proxy.scheme is ProxyScheme.HTTPS:
|
|
500
|
+
transport = await TLSStream.connect(
|
|
501
|
+
self._context(), (proxy.host, proxy.port)
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
transport = StreamTransport(
|
|
505
|
+
*await asyncio.open_connection(proxy.host, proxy.port)
|
|
506
|
+
)
|
|
507
|
+
authorization = None
|
|
508
|
+
if proxy.credentials is not None:
|
|
509
|
+
authorization = "Basic " + base64.b64encode(
|
|
510
|
+
f"{proxy.credentials.username}:{proxy.credentials.password}".encode()
|
|
511
|
+
).decode("ascii")
|
|
512
|
+
return Http1Connection(
|
|
513
|
+
transport, absolute_form=True, proxy_authorization=authorization
|
|
514
|
+
)
|
|
515
|
+
transport, leftover = await TLSStream.tunnel(
|
|
516
|
+
self._context(), url.address, proxy
|
|
517
|
+
)
|
|
518
|
+
return Http1Connection(transport, leftover=leftover)
|
|
519
|
+
except ProxyError, ConfigurationError:
|
|
520
|
+
raise
|
|
521
|
+
except TLSError as error:
|
|
522
|
+
raise ConnectError(
|
|
523
|
+
f"TLS handshake with {url.authority} failed: {error}"
|
|
524
|
+
) from error
|
|
525
|
+
except OSError as error:
|
|
526
|
+
raise ConnectError(f"cannot connect to {url.authority}: {error}") from error
|
|
527
|
+
|
|
528
|
+
async def _once(
|
|
529
|
+
self, request: Request, deadline: Deadline, proxy: Proxy | None
|
|
530
|
+
) -> Response:
|
|
531
|
+
quic = self._prefers_quic(request.url, proxy)
|
|
532
|
+
for attempt in (1, 2):
|
|
533
|
+
try:
|
|
534
|
+
connection = await self._pool.acquire(
|
|
535
|
+
request.url, proxy, deadline, quic=quic
|
|
536
|
+
)
|
|
537
|
+
except ConnectError, TimeoutError:
|
|
538
|
+
if quic and self._versions != (HTTPVersion.HTTP_3,):
|
|
539
|
+
# the learned HTTP/3 route is unreachable, so this origin goes back to TCP
|
|
540
|
+
_ = self._alt_svc.pop(request.url.address, None)
|
|
541
|
+
quic = False
|
|
542
|
+
continue
|
|
543
|
+
raise
|
|
544
|
+
started = monotonic()
|
|
545
|
+
replayable = request.body is None or request.body.replayable
|
|
546
|
+
try:
|
|
547
|
+
response = await connection.send(request, deadline, decode=self._decode)
|
|
548
|
+
except StreamRefused:
|
|
549
|
+
if attempt == 1 and replayable:
|
|
550
|
+
continue
|
|
551
|
+
raise
|
|
552
|
+
except RemoteDisconnected:
|
|
553
|
+
if attempt == 1 and connection.reused and replayable:
|
|
554
|
+
continue
|
|
555
|
+
raise
|
|
556
|
+
response.started = started
|
|
557
|
+
response.elapsed = monotonic() - started
|
|
558
|
+
return response
|
|
559
|
+
raise HTTPError("unreachable")
|
|
560
|
+
|
|
561
|
+
def _prefers_quic(self, url: URL, proxy: Proxy | None) -> bool:
|
|
562
|
+
if (
|
|
563
|
+
HTTPVersion.HTTP_3 not in self._versions
|
|
564
|
+
or not url.secure
|
|
565
|
+
or (
|
|
566
|
+
proxy is not None
|
|
567
|
+
and proxy.scheme not in (ProxyScheme.SOCKS5, ProxyScheme.SOCKS5H)
|
|
568
|
+
)
|
|
569
|
+
):
|
|
570
|
+
return False
|
|
571
|
+
if self._versions == (HTTPVersion.HTTP_3,):
|
|
572
|
+
return True
|
|
573
|
+
expiry = self._alt_svc.get(url.address)
|
|
574
|
+
return expiry is not None and expiry > monotonic()
|
|
575
|
+
|
|
576
|
+
def _learn_alt_svc(self, response: Response) -> None:
|
|
577
|
+
if "alt-svc" not in response.headers:
|
|
578
|
+
return
|
|
579
|
+
for alternative in response.headers.get_all("alt-svc"):
|
|
580
|
+
for entry in alternative.split(","):
|
|
581
|
+
protocol, _, rest = entry.strip().partition("=")
|
|
582
|
+
if protocol != "h3":
|
|
583
|
+
continue
|
|
584
|
+
authority, *parameters = (part.strip() for part in rest.split(";"))
|
|
585
|
+
if (
|
|
586
|
+
authority.strip('"')
|
|
587
|
+
!= f":{response.url.port or response.url.default_port}"
|
|
588
|
+
):
|
|
589
|
+
continue
|
|
590
|
+
age = next(
|
|
591
|
+
(
|
|
592
|
+
int(parameter[3:])
|
|
593
|
+
for parameter in parameters
|
|
594
|
+
if parameter.startswith("ma=")
|
|
595
|
+
and parameter[3:].isascii()
|
|
596
|
+
and parameter[3:].isdigit()
|
|
597
|
+
),
|
|
598
|
+
86400,
|
|
599
|
+
)
|
|
600
|
+
self._alt_svc[response.url.address] = monotonic() + age
|
|
601
|
+
|
|
602
|
+
def _with_cookies(
|
|
603
|
+
self, request: Request, extra: Mapping[str, str] | None
|
|
604
|
+
) -> Request:
|
|
605
|
+
parts = [
|
|
606
|
+
value
|
|
607
|
+
for value in (
|
|
608
|
+
request.headers.get("cookie"),
|
|
609
|
+
self.cookies.header(
|
|
610
|
+
request.url.host, request.url.path or "/", request.url.secure
|
|
611
|
+
),
|
|
612
|
+
)
|
|
613
|
+
if value
|
|
614
|
+
]
|
|
615
|
+
if extra:
|
|
616
|
+
parts.append("; ".join(f"{name}={value}" for name, value in extra.items()))
|
|
617
|
+
if not parts:
|
|
618
|
+
return request
|
|
619
|
+
headers = request.headers.copy()
|
|
620
|
+
headers.set("Cookie", "; ".join(parts))
|
|
621
|
+
return Request(request.method, request.url, headers, request.body)
|
|
622
|
+
|
|
623
|
+
def _redirect(self, request: Request, response: Response) -> Request:
|
|
624
|
+
location = response.location
|
|
625
|
+
if location is None:
|
|
626
|
+
raise ProtocolError("redirect without a Location header")
|
|
627
|
+
if not location.fragment and request.url.fragment:
|
|
628
|
+
location = URL(
|
|
629
|
+
location.scheme,
|
|
630
|
+
location.host,
|
|
631
|
+
location.port,
|
|
632
|
+
location.path,
|
|
633
|
+
location.query,
|
|
634
|
+
request.url.fragment,
|
|
635
|
+
location.username,
|
|
636
|
+
location.password,
|
|
637
|
+
)
|
|
638
|
+
method, body = request.method, request.body
|
|
639
|
+
headers = request.headers.copy()
|
|
640
|
+
if response.status == 303 or (
|
|
641
|
+
response.status in (301, 302) and method == "POST"
|
|
642
|
+
):
|
|
643
|
+
method, body = ("HEAD" if method == "HEAD" else "GET"), None
|
|
644
|
+
for name in ("Content-Type", "Content-Length", "Transfer-Encoding"):
|
|
645
|
+
headers.remove(name)
|
|
646
|
+
elif body is not None and not body.replayable:
|
|
647
|
+
raise HTTPError(
|
|
648
|
+
f"cannot repeat a streamed body for the {response.status} redirect to {location}"
|
|
649
|
+
)
|
|
650
|
+
if location.origin != request.url.origin:
|
|
651
|
+
headers.remove("Authorization")
|
|
652
|
+
headers.remove("Cookie")
|
|
653
|
+
return Request(method, location, headers, body)
|
|
654
|
+
|
|
655
|
+
@staticmethod
|
|
656
|
+
def _body(
|
|
657
|
+
json: object,
|
|
658
|
+
data: FormFields | bytes | AsyncIterable[bytes] | None,
|
|
659
|
+
content: bytes | str | AsyncIterable[bytes] | None,
|
|
660
|
+
files: Mapping[str, FileField] | None,
|
|
661
|
+
) -> Body | None:
|
|
662
|
+
given = [
|
|
663
|
+
name
|
|
664
|
+
for name, value in (
|
|
665
|
+
("json", json),
|
|
666
|
+
("data", data),
|
|
667
|
+
("content", content),
|
|
668
|
+
("files", files),
|
|
669
|
+
)
|
|
670
|
+
if value is not None
|
|
671
|
+
]
|
|
672
|
+
if files is not None:
|
|
673
|
+
if json is not None or content is not None:
|
|
674
|
+
raise ValueError("files= cannot be combined with json= or content=")
|
|
675
|
+
if isinstance(data, (bytes, AsyncIterable)):
|
|
676
|
+
raise ValueError("files= takes form fields in data=, not a raw body")
|
|
677
|
+
return Body.multipart(data, files)
|
|
678
|
+
if len(given) > 1:
|
|
679
|
+
raise ValueError(f"give one body, not {', '.join(given)}")
|
|
680
|
+
if json is not None:
|
|
681
|
+
return Body.json(json)
|
|
682
|
+
if content is not None:
|
|
683
|
+
return Body.content(content)
|
|
684
|
+
match data:
|
|
685
|
+
case None:
|
|
686
|
+
return None
|
|
687
|
+
case bytes():
|
|
688
|
+
return Body.content(data)
|
|
689
|
+
case Mapping() | Sequence():
|
|
690
|
+
return Body.form(data)
|
|
691
|
+
case _:
|
|
692
|
+
return Body.content(data)
|