boring-http 2.0.3__tar.gz → 2.2.0__tar.gz
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-2.0.3 → boring_http-2.2.0}/PKG-INFO +4 -2
- {boring_http-2.0.3 → boring_http-2.2.0}/README.md +3 -1
- {boring_http-2.0.3 → boring_http-2.2.0}/pyproject.toml +1 -1
- {boring_http-2.0.3 → boring_http-2.2.0}/pyproject.toml.orig +1 -1
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/client.py +28 -8
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/cookies.py +9 -9
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/http2.py +25 -11
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/http3.py +5 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/pool.py +31 -3
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/connection.py +23 -10
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/protocol.py +19 -1
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/streams.py +6 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/__init__.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/codings.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/errors.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/hpack.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/hpack_tables.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/http1.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/message.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/public_suffixes.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/py.typed +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/qpack.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/qpack_tables.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/__init__.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/packet.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/recovery.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/revocation.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/timing.py +0 -0
- {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/websocket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: boring-http
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order
|
|
5
5
|
Requires-Dist: boring-ssl2>=1.2.0
|
|
6
6
|
Requires-Dist: ssl2>=2.0.1
|
|
@@ -42,7 +42,7 @@ async def main() -> None:
|
|
|
42
42
|
asyncio.run(main())
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Certificates are checked against the system trust store, or against `trust_store=TrustStore.from_file(...)` from `ssl2`; `verify=False` turns the check off for a test server. Revocation is checked against the CRL the certificate names: fetched over HTTP, verified with the issuer from the chain, cached until its next update, and a listed certificate is refused with `RevocationError`. `revocation=Revocation.STRICT` also refuses when no valid CRL can be had, `Revocation.NONE` skips the check, and the default `Revocation.SOFT` lets such a connection through, as browsers do. The check covers the origin's certificate and an HTTPS proxy's own certificate on a plain-HTTP request through it; the proxy's certificate on a TLS tunnel is verified but not checked for revocation. Headers go on the wire in the order given, client headers first and then the request's own. `Host` leads an HTTP/1.1 request; HTTP/2 and HTTP/3 lowercase the names and drop the connection-specific ones, nothing else changes. `params`, `json`, `data`, `content`, `files`, `auth` and `cookies` are accepted by `get`, `post`, `put`, `patch`, `delete`, `head`, `options` and `request`.
|
|
45
|
+
Certificates are checked against the system trust store, or against `trust_store=TrustStore.from_file(...)` from `ssl2`; `verify=False` turns the check off for a test server. Revocation is checked against the CRL the certificate names: fetched over HTTP, verified with the issuer from the chain, cached until its next update, and a listed certificate is refused with `RevocationError`. `revocation=Revocation.STRICT` also refuses when no valid CRL can be had, `Revocation.NONE` skips the check, and the default `Revocation.SOFT` lets such a connection through, as browsers do. The check covers the origin's certificate and an HTTPS proxy's own certificate on a plain-HTTP request through it; the proxy's certificate on a TLS tunnel is verified but not checked for revocation. `certificate_chain=SigningChain(...)` from `ssl2` presents a client certificate over TCP and QUIC. `check_peer=` is awaited with the peer's chain in DER, leaf first, and its authority after the same checks; raising from it refuses the connection, which is where a key pin goes. Connections are pooled per origin and proxy under `max_connections` and `max_connections_per_host`; one that has served nothing for `max_idle_time` seconds, 90 by default, is closed instead of reused, and `None` keeps it until the server closes it. Headers go on the wire in the order given, client headers first and then the request's own. `Host` leads an HTTP/1.1 request; HTTP/2 and HTTP/3 lowercase the names and drop the connection-specific ones, nothing else changes. `params`, `json`, `data`, `content`, `files`, `auth` and `cookies` are accepted by `get`, `post`, `put`, `patch`, `delete`, `head`, `options` and `request`.
|
|
46
46
|
|
|
47
47
|
## Responses
|
|
48
48
|
|
|
@@ -100,6 +100,8 @@ asyncio.run(main())
|
|
|
100
100
|
|
|
101
101
|
A single number sets every phase and the total, so the request gives up after that many seconds whatever it was doing. With a `Timeout` the phases are separate: `connect` covers opening a connection, the proxy, TCP and the TLS or QUIC handshake included, `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
|
|
102
102
|
|
|
103
|
+
A body is held to what the application reads: over HTTP/2 and HTTP/3 the stream window opens again only as chunks are taken, so a response nobody reads stops at one window (`INITIAL_WINDOW_SIZE` or `initial_max_stream_data_bidi_local`), while the connection window keeps opening on arrival so other streams go on. An HTTP/3 upload keeps at most 1 MiB of the body unsent before it asks the body for the next chunk; HTTP/1.1 and HTTP/2 uploads wait on the socket and the peer's window.
|
|
104
|
+
|
|
103
105
|
## HTTP/3
|
|
104
106
|
|
|
105
107
|
```python
|
|
@@ -32,7 +32,7 @@ async def main() -> None:
|
|
|
32
32
|
asyncio.run(main())
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Certificates are checked against the system trust store, or against `trust_store=TrustStore.from_file(...)` from `ssl2`; `verify=False` turns the check off for a test server. Revocation is checked against the CRL the certificate names: fetched over HTTP, verified with the issuer from the chain, cached until its next update, and a listed certificate is refused with `RevocationError`. `revocation=Revocation.STRICT` also refuses when no valid CRL can be had, `Revocation.NONE` skips the check, and the default `Revocation.SOFT` lets such a connection through, as browsers do. The check covers the origin's certificate and an HTTPS proxy's own certificate on a plain-HTTP request through it; the proxy's certificate on a TLS tunnel is verified but not checked for revocation. Headers go on the wire in the order given, client headers first and then the request's own. `Host` leads an HTTP/1.1 request; HTTP/2 and HTTP/3 lowercase the names and drop the connection-specific ones, nothing else changes. `params`, `json`, `data`, `content`, `files`, `auth` and `cookies` are accepted by `get`, `post`, `put`, `patch`, `delete`, `head`, `options` and `request`.
|
|
35
|
+
Certificates are checked against the system trust store, or against `trust_store=TrustStore.from_file(...)` from `ssl2`; `verify=False` turns the check off for a test server. Revocation is checked against the CRL the certificate names: fetched over HTTP, verified with the issuer from the chain, cached until its next update, and a listed certificate is refused with `RevocationError`. `revocation=Revocation.STRICT` also refuses when no valid CRL can be had, `Revocation.NONE` skips the check, and the default `Revocation.SOFT` lets such a connection through, as browsers do. The check covers the origin's certificate and an HTTPS proxy's own certificate on a plain-HTTP request through it; the proxy's certificate on a TLS tunnel is verified but not checked for revocation. `certificate_chain=SigningChain(...)` from `ssl2` presents a client certificate over TCP and QUIC. `check_peer=` is awaited with the peer's chain in DER, leaf first, and its authority after the same checks; raising from it refuses the connection, which is where a key pin goes. Connections are pooled per origin and proxy under `max_connections` and `max_connections_per_host`; one that has served nothing for `max_idle_time` seconds, 90 by default, is closed instead of reused, and `None` keeps it until the server closes it. Headers go on the wire in the order given, client headers first and then the request's own. `Host` leads an HTTP/1.1 request; HTTP/2 and HTTP/3 lowercase the names and drop the connection-specific ones, nothing else changes. `params`, `json`, `data`, `content`, `files`, `auth` and `cookies` are accepted by `get`, `post`, `put`, `patch`, `delete`, `head`, `options` and `request`.
|
|
36
36
|
|
|
37
37
|
## Responses
|
|
38
38
|
|
|
@@ -90,6 +90,8 @@ asyncio.run(main())
|
|
|
90
90
|
|
|
91
91
|
A single number sets every phase and the total, so the request gives up after that many seconds whatever it was doing. With a `Timeout` the phases are separate: `connect` covers opening a connection, the proxy, TCP and the TLS or QUIC handshake included, `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
|
|
92
92
|
|
|
93
|
+
A body is held to what the application reads: over HTTP/2 and HTTP/3 the stream window opens again only as chunks are taken, so a response nobody reads stops at one window (`INITIAL_WINDOW_SIZE` or `initial_max_stream_data_bidi_local`), while the connection window keeps opening on arrival so other streams go on. An HTTP/3 upload keeps at most 1 MiB of the body unsent before it asks the body for the next chunk; HTTP/1.1 and HTTP/2 uploads wait on the socket and the peer's window.
|
|
94
|
+
|
|
93
95
|
## HTTP/3
|
|
94
96
|
|
|
95
97
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boring-http"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.2.0"
|
|
4
4
|
description = "Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boring-http"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.2.0"
|
|
4
4
|
description = "Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import base64
|
|
2
|
-
from collections.abc import
|
|
2
|
+
from collections.abc import (
|
|
3
|
+
AsyncGenerator,
|
|
4
|
+
AsyncIterable,
|
|
5
|
+
Awaitable,
|
|
6
|
+
Callable,
|
|
7
|
+
Mapping,
|
|
8
|
+
Sequence,
|
|
9
|
+
)
|
|
3
10
|
from contextlib import asynccontextmanager
|
|
4
11
|
from dataclasses import replace
|
|
5
12
|
from time import monotonic
|
|
@@ -16,6 +23,7 @@ from ssl2 import (
|
|
|
16
23
|
ProxyError,
|
|
17
24
|
ProxyScheme,
|
|
18
25
|
SessionCache,
|
|
26
|
+
SigningChain,
|
|
19
27
|
SocketTransport,
|
|
20
28
|
TLSClientConfiguration,
|
|
21
29
|
TLSError,
|
|
@@ -107,10 +115,13 @@ class Client:
|
|
|
107
115
|
trust_store: TrustStore | None = None,
|
|
108
116
|
verify: bool = True,
|
|
109
117
|
revocation: Revocation = Revocation.SOFT,
|
|
118
|
+
certificate_chain: SigningChain | None = None,
|
|
119
|
+
check_peer: Callable[[tuple[bytes, ...], str], Awaitable[None]] | None = None,
|
|
110
120
|
cookies: CookieJar | None = None,
|
|
111
121
|
base_url: str | None = None,
|
|
112
122
|
max_connections: int = 100,
|
|
113
123
|
max_connections_per_host: int = 10,
|
|
124
|
+
max_idle_time: float | None = 90.0,
|
|
114
125
|
max_redirects: int = 20,
|
|
115
126
|
max_message_size: int = 16 * 1024 * 1024,
|
|
116
127
|
decode: bool = True,
|
|
@@ -138,13 +149,17 @@ class Client:
|
|
|
138
149
|
self._trust_store = trust_store
|
|
139
150
|
self._verify = verify
|
|
140
151
|
self._revocations = Revocations(revocation, self._fetch_crl)
|
|
152
|
+
self._certificate_chain = certificate_chain
|
|
153
|
+
self._check_peer = check_peer
|
|
141
154
|
self._base_url = base_url
|
|
142
155
|
self._max_redirects = max_redirects
|
|
143
156
|
self._max_message_size = max_message_size
|
|
144
157
|
self._decode = decode
|
|
145
158
|
self._contexts: dict[tuple[NextProtocol, ...] | None, ClientContext] = {}
|
|
146
159
|
self._memory: dict[tuple[str, int], OriginMemory] = {}
|
|
147
|
-
self._pool = Pool(
|
|
160
|
+
self._pool = Pool(
|
|
161
|
+
self._connect, max_connections, max_connections_per_host, max_idle_time
|
|
162
|
+
)
|
|
148
163
|
self.cookies = CookieJar() if cookies is None else cookies
|
|
149
164
|
|
|
150
165
|
async def __aenter__(self) -> Self:
|
|
@@ -442,12 +457,14 @@ class Client:
|
|
|
442
457
|
ALPN[version] for version in self._versions if version in ALPN
|
|
443
458
|
)
|
|
444
459
|
configuration = TLSClientConfiguration(
|
|
460
|
+
certificate_chain=self._certificate_chain,
|
|
445
461
|
trust_store=self._trust_store,
|
|
446
462
|
inner_protocols=offered,
|
|
447
463
|
verify_certificates=self._verify,
|
|
448
464
|
)
|
|
449
465
|
else:
|
|
450
466
|
configuration = TLSClientConfiguration(
|
|
467
|
+
certificate_chain=self._certificate_chain,
|
|
451
468
|
trust_store=self._trust_store,
|
|
452
469
|
client_hello=hello,
|
|
453
470
|
verify_certificates=self._verify,
|
|
@@ -487,11 +504,16 @@ class Client:
|
|
|
487
504
|
|
|
488
505
|
async def _checked(self, stream: TLSStream, authority: str) -> None:
|
|
489
506
|
try:
|
|
490
|
-
await self.
|
|
491
|
-
except
|
|
507
|
+
await self._verified(stream.buffer.getpeerchain(), authority)
|
|
508
|
+
except BaseException:
|
|
492
509
|
await stream.close()
|
|
493
510
|
raise
|
|
494
511
|
|
|
512
|
+
async def _verified(self, chain: tuple[bytes, ...], authority: str) -> None:
|
|
513
|
+
await self._revocations.check(chain, authority)
|
|
514
|
+
if self._check_peer is not None:
|
|
515
|
+
await self._check_peer(chain, authority)
|
|
516
|
+
|
|
495
517
|
async def _fetch_crl(self, url: str) -> bytes:
|
|
496
518
|
async with Client(
|
|
497
519
|
versions=(HTTPVersion.HTTP_1,), revocation=Revocation.NONE, timeout=10
|
|
@@ -519,10 +541,8 @@ class Client:
|
|
|
519
541
|
memory=memory,
|
|
520
542
|
)
|
|
521
543
|
try:
|
|
522
|
-
await self.
|
|
523
|
-
|
|
524
|
-
)
|
|
525
|
-
except RevocationError:
|
|
544
|
+
await self._verified(client.connection.peer_chain, url.authority)
|
|
545
|
+
except BaseException:
|
|
526
546
|
client.close()
|
|
527
547
|
raise
|
|
528
548
|
connection = Http3Connection(client, self._http3, memory)
|
|
@@ -7,14 +7,6 @@ from typing import Self
|
|
|
7
7
|
from boring_http.public_suffixes import EXCEPTIONS, RULES
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def is_public_suffix(domain: str) -> bool:
|
|
11
|
-
# publicsuffix.org's algorithm: an exception rule wins, then a listed rule or a wildcard over the parent, and an unlisted top-level label is one
|
|
12
|
-
if domain in EXCEPTIONS:
|
|
13
|
-
return False
|
|
14
|
-
parent = domain.partition(".")[2]
|
|
15
|
-
return domain in RULES or f"*.{parent}" in RULES or not parent
|
|
16
|
-
|
|
17
|
-
|
|
18
10
|
@dataclass(frozen=True, slots=True)
|
|
19
11
|
class Cookie:
|
|
20
12
|
name: str
|
|
@@ -57,7 +49,7 @@ class Cookie:
|
|
|
57
49
|
if domain and not (host == domain or host.endswith("." + domain)):
|
|
58
50
|
return None
|
|
59
51
|
# RFC 6265 section 5.3 step 5: a public suffix as Domain is refused, unless it is the host itself, which keeps the cookie to that host
|
|
60
|
-
if domain and
|
|
52
|
+
if domain and cls._public_suffix(domain):
|
|
61
53
|
if domain != host:
|
|
62
54
|
return None
|
|
63
55
|
domain = ""
|
|
@@ -92,6 +84,14 @@ class Cookie:
|
|
|
92
84
|
domain_ok and path_ok and (secure or not self.secure) and not self.expired
|
|
93
85
|
)
|
|
94
86
|
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _public_suffix(domain: str) -> bool:
|
|
89
|
+
# publicsuffix.org's algorithm: an exception rule wins, then a listed rule or a wildcard over the parent, and an unlisted top-level label is one
|
|
90
|
+
if domain in EXCEPTIONS:
|
|
91
|
+
return False
|
|
92
|
+
parent = domain.partition(".")[2]
|
|
93
|
+
return domain in RULES or f"*.{parent}" in RULES or not parent
|
|
94
|
+
|
|
95
95
|
@staticmethod
|
|
96
96
|
def _date(text: str) -> float | None:
|
|
97
97
|
try:
|
|
@@ -105,7 +105,8 @@ class Http2Stream:
|
|
|
105
105
|
self.waiter: asyncio.Future[None] | None = None
|
|
106
106
|
self.trailers = Headers()
|
|
107
107
|
self.block = bytearray()
|
|
108
|
-
|
|
108
|
+
# what the application took since the last WINDOW_UPDATE for this stream
|
|
109
|
+
self.consumed = 0
|
|
109
110
|
self.finished = False
|
|
110
111
|
|
|
111
112
|
def fail(self, error: BaseException) -> None:
|
|
@@ -382,7 +383,9 @@ class Http2Connection:
|
|
|
382
383
|
try:
|
|
383
384
|
while True:
|
|
384
385
|
if stream.chunks:
|
|
385
|
-
|
|
386
|
+
data = stream.take()
|
|
387
|
+
await self.credit(stream, len(data))
|
|
388
|
+
yield data
|
|
386
389
|
elif stream.error is not None:
|
|
387
390
|
raise stream.error
|
|
388
391
|
elif stream.finished:
|
|
@@ -409,6 +412,24 @@ class Http2Connection:
|
|
|
409
412
|
)
|
|
410
413
|
)
|
|
411
414
|
|
|
415
|
+
async def credit(self, stream: Http2Stream, size: int) -> None:
|
|
416
|
+
# the stream window opens again as the application takes data, so a reader that stops holds the server at one window
|
|
417
|
+
stream.consumed += size
|
|
418
|
+
if (
|
|
419
|
+
stream.consumed > self._local_window // 2
|
|
420
|
+
and not stream.finished
|
|
421
|
+
and not self.closed
|
|
422
|
+
):
|
|
423
|
+
increment, stream.consumed = stream.consumed, 0
|
|
424
|
+
await self._write(
|
|
425
|
+
self._frame(
|
|
426
|
+
FrameType.WINDOW_UPDATE,
|
|
427
|
+
0,
|
|
428
|
+
stream.identifier,
|
|
429
|
+
struct.pack("!I", increment),
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
|
|
412
433
|
async def _write(self, data: bytes) -> None:
|
|
413
434
|
async with self._write_lock:
|
|
414
435
|
await self._transport.write(data)
|
|
@@ -512,15 +533,7 @@ class Http2Connection:
|
|
|
512
533
|
if flags & END_STREAM:
|
|
513
534
|
stream.finished = True
|
|
514
535
|
stream.wake()
|
|
515
|
-
|
|
516
|
-
if stream.received > self._local_window // 2 and not stream.finished:
|
|
517
|
-
updates = self._frame(
|
|
518
|
-
FrameType.WINDOW_UPDATE,
|
|
519
|
-
0,
|
|
520
|
-
identifier,
|
|
521
|
-
struct.pack("!I", stream.received),
|
|
522
|
-
)
|
|
523
|
-
stream.received = 0
|
|
536
|
+
# the connection window opens on arrival, so one stream that is not read leaves the others their share
|
|
524
537
|
self._connection_received += len(payload)
|
|
525
538
|
if self._connection_received > self._connection_window // 2:
|
|
526
539
|
updates += self._frame(
|
|
@@ -657,6 +670,7 @@ class Http2StreamTransport:
|
|
|
657
670
|
while not self._pending:
|
|
658
671
|
if stream.chunks:
|
|
659
672
|
self._pending = stream.take()
|
|
673
|
+
await self._connection.credit(stream, len(self._pending))
|
|
660
674
|
elif stream.error is not None:
|
|
661
675
|
raise stream.error
|
|
662
676
|
elif stream.finished:
|
|
@@ -94,6 +94,8 @@ class Http3Settings:
|
|
|
94
94
|
|
|
95
95
|
# RFC 8470 section 5.2: the requests a client may send before the handshake, since a server may see them twice
|
|
96
96
|
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
97
|
+
# how much of a body may wait unsent in its QUIC stream before the next chunk is taken from the caller
|
|
98
|
+
SEND_BUFFER = 1 << 20
|
|
97
99
|
|
|
98
100
|
|
|
99
101
|
class StreamFrames:
|
|
@@ -266,6 +268,7 @@ class Http3Connection:
|
|
|
266
268
|
async with deadline.phase("write"):
|
|
267
269
|
async for chunk in request.body.chunks():
|
|
268
270
|
self._quic.send(stream_id, self._frame(FrameType.DATA, chunk))
|
|
271
|
+
await self._quic.drain(stream_id, SEND_BUFFER)
|
|
269
272
|
self._quic.send(stream_id, b"", end=True)
|
|
270
273
|
frames = StreamFrames(self._quic, stream_id, deadline)
|
|
271
274
|
status, headers = await self._response_head(frames, stream_id)
|
|
@@ -554,6 +557,8 @@ class Http3StreamTransport:
|
|
|
554
557
|
+ Writer.encode_varint(len(data))
|
|
555
558
|
+ data,
|
|
556
559
|
)
|
|
560
|
+
async with self._deadline.phase("write"):
|
|
561
|
+
await self._quic.drain(self._stream_id, SEND_BUFFER)
|
|
557
562
|
|
|
558
563
|
async def close(self) -> None:
|
|
559
564
|
if not self._closed:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from collections.abc import Awaitable, Callable
|
|
3
|
+
from time import monotonic
|
|
3
4
|
from typing import Protocol
|
|
4
5
|
|
|
5
6
|
from ssl2 import Proxy
|
|
@@ -35,15 +36,23 @@ type Connector = Callable[[URL, Proxy | None, bool], Awaitable[Connection]]
|
|
|
35
36
|
|
|
36
37
|
class Pool:
|
|
37
38
|
def __init__(
|
|
38
|
-
self,
|
|
39
|
+
self,
|
|
40
|
+
connector: Connector,
|
|
41
|
+
max_connections: int,
|
|
42
|
+
max_connections_per_host: int,
|
|
43
|
+
max_idle_time: float | None,
|
|
39
44
|
) -> None:
|
|
40
45
|
self._connector = connector
|
|
41
46
|
self._per_host = max_connections_per_host
|
|
42
47
|
self._total = asyncio.Semaphore(max_connections)
|
|
48
|
+
self._max_idle_time = max_idle_time
|
|
43
49
|
self._limits: dict[Key, asyncio.Semaphore] = {}
|
|
44
50
|
self._locks: dict[Key, asyncio.Lock] = {}
|
|
45
51
|
self._changes: dict[Key, asyncio.Event] = {}
|
|
46
52
|
self._connections: dict[Key, list[Connection]] = {}
|
|
53
|
+
# the requests each connection is serving, and when it last became idle
|
|
54
|
+
self._in_use: dict[Connection, int] = {}
|
|
55
|
+
self._idle_since: dict[Connection, float] = {}
|
|
47
56
|
|
|
48
57
|
async def acquire(
|
|
49
58
|
self, url: URL, proxy: Proxy | None, deadline: Deadline, *, quic: bool = False
|
|
@@ -53,10 +62,13 @@ class Pool:
|
|
|
53
62
|
changed = self._changes.setdefault(key, asyncio.Event())
|
|
54
63
|
async with self._locks.setdefault(key, asyncio.Lock()):
|
|
55
64
|
while True:
|
|
56
|
-
for connection in self._connections.get(key, []):
|
|
57
|
-
if connection
|
|
65
|
+
for connection in list(self._connections.get(key, [])):
|
|
66
|
+
if self._expired(connection):
|
|
67
|
+
await connection.close()
|
|
68
|
+
elif connection.available:
|
|
58
69
|
connection.take()
|
|
59
70
|
connection.reused = True
|
|
71
|
+
self._in_use[connection] += 1
|
|
60
72
|
return connection
|
|
61
73
|
if not limit.locked() and not self._total.locked():
|
|
62
74
|
break
|
|
@@ -77,6 +89,7 @@ class Pool:
|
|
|
77
89
|
connection.take()
|
|
78
90
|
connection.released = lambda: self._changed(key, connection, limit)
|
|
79
91
|
self._connections.setdefault(key, []).append(connection)
|
|
92
|
+
self._in_use[connection] = 1
|
|
80
93
|
return connection
|
|
81
94
|
|
|
82
95
|
async def close(self) -> None:
|
|
@@ -91,6 +104,21 @@ class Pool:
|
|
|
91
104
|
connections = self._connections.get(key, [])
|
|
92
105
|
if connection.closed and connection in connections:
|
|
93
106
|
connections.remove(connection)
|
|
107
|
+
del self._in_use[connection]
|
|
108
|
+
self._idle_since.pop(connection, None)
|
|
94
109
|
limit.release()
|
|
95
110
|
self._total.release()
|
|
111
|
+
elif connection in self._in_use:
|
|
112
|
+
self._in_use[connection] = max(self._in_use[connection] - 1, 0)
|
|
113
|
+
if not self._in_use[connection]:
|
|
114
|
+
self._idle_since[connection] = monotonic()
|
|
96
115
|
self._changes[key].set()
|
|
116
|
+
|
|
117
|
+
def _expired(self, connection: Connection) -> bool:
|
|
118
|
+
idle_since = self._idle_since.get(connection)
|
|
119
|
+
return (
|
|
120
|
+
self._max_idle_time is not None
|
|
121
|
+
and idle_since is not None
|
|
122
|
+
and not self._in_use[connection]
|
|
123
|
+
and monotonic() - idle_since > self._max_idle_time
|
|
124
|
+
)
|
|
@@ -499,6 +499,29 @@ class QuicConnection:
|
|
|
499
499
|
if end:
|
|
500
500
|
stream.end()
|
|
501
501
|
|
|
502
|
+
def unsent(self, stream_id: int) -> int:
|
|
503
|
+
stream = self._send_streams.get(stream_id)
|
|
504
|
+
return 0 if stream is None else stream.unsent
|
|
505
|
+
|
|
506
|
+
def stream_read(self, stream_id: int, size: int) -> bool:
|
|
507
|
+
# the stream window opens again as the application takes data, so a reader that stops holds the server at one window
|
|
508
|
+
receive = self._receive_streams.get(stream_id)
|
|
509
|
+
if receive is None or receive.final_size is not None:
|
|
510
|
+
return False
|
|
511
|
+
receive.read += size
|
|
512
|
+
window = (
|
|
513
|
+
self._parameters.initial_max_stream_data_bidi_local
|
|
514
|
+
if stream_id % 4 < 2
|
|
515
|
+
else self._parameters.initial_max_stream_data_uni
|
|
516
|
+
)
|
|
517
|
+
if receive.max_data - receive.read >= window // 2:
|
|
518
|
+
return False
|
|
519
|
+
receive.max_data = receive.read + window
|
|
520
|
+
self._pending[EncryptionLevel.APPLICATION].append(
|
|
521
|
+
MaxStreamData(stream_id, receive.max_data)
|
|
522
|
+
)
|
|
523
|
+
return True
|
|
524
|
+
|
|
502
525
|
def reset_stream(self, stream_id: int, error: int) -> None:
|
|
503
526
|
stream = self._send_streams.get(stream_id)
|
|
504
527
|
if stream is not None and not stream.reset_sent:
|
|
@@ -935,16 +958,6 @@ class QuicConnection:
|
|
|
935
958
|
if data:
|
|
936
959
|
receive.chunks.append(data)
|
|
937
960
|
self._readable[stream_id] = receive
|
|
938
|
-
window = (
|
|
939
|
-
self._parameters.initial_max_stream_data_bidi_local
|
|
940
|
-
if bidirectional
|
|
941
|
-
else self._parameters.initial_max_stream_data_uni
|
|
942
|
-
)
|
|
943
|
-
if receive.max_data - receive.consumed < window // 2 and not end:
|
|
944
|
-
receive.max_data = receive.consumed + window
|
|
945
|
-
self._pending[EncryptionLevel.APPLICATION].append(
|
|
946
|
-
MaxStreamData(stream_id, receive.max_data)
|
|
947
|
-
)
|
|
948
961
|
if end:
|
|
949
962
|
del self._receive_streams[stream_id]
|
|
950
963
|
if (
|
|
@@ -135,6 +135,8 @@ class QuicClient:
|
|
|
135
135
|
)
|
|
136
136
|
self._inboxes: dict[int, Inbox] = {}
|
|
137
137
|
self._unsent: deque[bytes] = deque()
|
|
138
|
+
# set after every flush, so a writer waiting for its stream to drain looks again
|
|
139
|
+
self._progress = asyncio.Event()
|
|
138
140
|
self._closed = False
|
|
139
141
|
self.incoming: asyncio.Queue[int] = asyncio.Queue()
|
|
140
142
|
self.datagrams: asyncio.Queue[bytes] = asyncio.Queue()
|
|
@@ -261,7 +263,10 @@ class QuicClient:
|
|
|
261
263
|
while True:
|
|
262
264
|
if inbox.chunks:
|
|
263
265
|
chunks, inbox.chunks = inbox.chunks, []
|
|
264
|
-
|
|
266
|
+
data = chunks[0] if len(chunks) == 1 else b"".join(chunks)
|
|
267
|
+
if self._connection.stream_read(stream_id, len(data)):
|
|
268
|
+
self._flush_soon()
|
|
269
|
+
return data
|
|
265
270
|
if inbox.error is not None:
|
|
266
271
|
raise inbox.error
|
|
267
272
|
if inbox.ended:
|
|
@@ -270,6 +275,17 @@ class QuicClient:
|
|
|
270
275
|
await inbox.waiter
|
|
271
276
|
inbox.waiter = None
|
|
272
277
|
|
|
278
|
+
async def drain(self, stream_id: int, limit: int) -> None:
|
|
279
|
+
# a writer waits while more than limit bytes of its stream are unsent, so a large body is never queued whole
|
|
280
|
+
while self._connection.unsent(stream_id) > limit:
|
|
281
|
+
inbox = self._inboxes.get(stream_id)
|
|
282
|
+
if inbox is not None and inbox.error is not None:
|
|
283
|
+
raise inbox.error
|
|
284
|
+
if self._terminated.done():
|
|
285
|
+
raise RemoteDisconnected("QUIC connection closed with data unsent")
|
|
286
|
+
self._progress.clear()
|
|
287
|
+
await self._progress.wait()
|
|
288
|
+
|
|
273
289
|
def close(self, error: int = 0, reason: str = "") -> None:
|
|
274
290
|
if not self._terminated.done():
|
|
275
291
|
self._connection.close(error, reason)
|
|
@@ -385,6 +401,7 @@ class QuicClient:
|
|
|
385
401
|
# these count as lost, and the connection goes back to the size every path carries
|
|
386
402
|
self._connection.datagram_too_long()
|
|
387
403
|
index = end
|
|
404
|
+
self._progress.set()
|
|
388
405
|
self._dispatch()
|
|
389
406
|
self._arm_timer()
|
|
390
407
|
if len(datagrams) >= MAX_BURST and not self._unsent and self._pacer is None:
|
|
@@ -492,3 +509,4 @@ class QuicClient:
|
|
|
492
509
|
inbox.wake()
|
|
493
510
|
if not self._terminated.done():
|
|
494
511
|
self._terminated.set_result(ConnectionTerminated(0, str(error), False))
|
|
512
|
+
self._progress.set()
|
|
@@ -24,6 +24,10 @@ class SendStream:
|
|
|
24
24
|
def active(self) -> bool:
|
|
25
25
|
return bool(self.pending) or (self.ended and not self.fin_sent)
|
|
26
26
|
|
|
27
|
+
@property
|
|
28
|
+
def unsent(self) -> int:
|
|
29
|
+
return sum(len(data) for _, data in self.pending) - self.position
|
|
30
|
+
|
|
27
31
|
def write(self, data: bytes) -> None:
|
|
28
32
|
if self.ended:
|
|
29
33
|
raise ProtocolError(f"stream {self.stream_id} already ended")
|
|
@@ -90,6 +94,8 @@ class ReceiveStream:
|
|
|
90
94
|
self.stream_id = stream_id
|
|
91
95
|
self.max_data = max_data
|
|
92
96
|
self.consumed = 0
|
|
97
|
+
# what the application has taken, which is what moves max_data
|
|
98
|
+
self.read = 0
|
|
93
99
|
# in-order data the application has not been handed yet
|
|
94
100
|
self.chunks: list[bytes] = []
|
|
95
101
|
self.fragments: dict[int, bytes] = {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|