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.
Files changed (29) hide show
  1. {boring_http-2.0.3 → boring_http-2.2.0}/PKG-INFO +4 -2
  2. {boring_http-2.0.3 → boring_http-2.2.0}/README.md +3 -1
  3. {boring_http-2.0.3 → boring_http-2.2.0}/pyproject.toml +1 -1
  4. {boring_http-2.0.3 → boring_http-2.2.0}/pyproject.toml.orig +1 -1
  5. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/client.py +28 -8
  6. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/cookies.py +9 -9
  7. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/http2.py +25 -11
  8. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/http3.py +5 -0
  9. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/pool.py +31 -3
  10. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/connection.py +23 -10
  11. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/protocol.py +19 -1
  12. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/streams.py +6 -0
  13. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/__init__.py +0 -0
  14. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/codings.py +0 -0
  15. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/errors.py +0 -0
  16. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/hpack.py +0 -0
  17. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/hpack_tables.py +0 -0
  18. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/http1.py +0 -0
  19. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/message.py +0 -0
  20. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/public_suffixes.py +0 -0
  21. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/py.typed +0 -0
  22. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/qpack.py +0 -0
  23. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/qpack_tables.py +0 -0
  24. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/__init__.py +0 -0
  25. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/packet.py +0 -0
  26. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/quic/recovery.py +0 -0
  27. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/revocation.py +0 -0
  28. {boring_http-2.0.3 → boring_http-2.2.0}/src/boring_http/timing.py +0 -0
  29. {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
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"
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"
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 AsyncGenerator, AsyncIterable, Mapping, Sequence
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(self._connect, max_connections, max_connections_per_host)
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._revocations.check(stream.buffer.getpeerchain(), authority)
491
- except RevocationError:
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._revocations.check(
523
- client.connection.peer_chain, url.authority
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 is_public_suffix(domain):
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
- self.received = 0
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
- yield stream.take()
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
- stream.received += len(payload)
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, connector: Connector, max_connections: int, max_connections_per_host: int
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.available:
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
- return chunks[0] if len(chunks) == 1 else b"".join(chunks)
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] = {}