boring-http 2.1.0__tar.gz → 2.2.1__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.1.0 → boring_http-2.2.1}/PKG-INFO +3 -3
  2. {boring_http-2.1.0 → boring_http-2.2.1}/README.md +2 -2
  3. {boring_http-2.1.0 → boring_http-2.2.1}/pyproject.toml +1 -1
  4. {boring_http-2.1.0 → boring_http-2.2.1}/pyproject.toml.orig +1 -1
  5. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/client.py +28 -8
  6. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/cookies.py +9 -9
  7. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/http2.py +5 -1
  8. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/http3.py +11 -3
  9. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/pool.py +31 -3
  10. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/__init__.py +0 -0
  11. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/codings.py +0 -0
  12. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/errors.py +0 -0
  13. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/hpack.py +0 -0
  14. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/hpack_tables.py +0 -0
  15. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/http1.py +0 -0
  16. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/message.py +0 -0
  17. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/public_suffixes.py +0 -0
  18. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/py.typed +0 -0
  19. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/qpack.py +0 -0
  20. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/qpack_tables.py +0 -0
  21. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/quic/__init__.py +0 -0
  22. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/quic/connection.py +0 -0
  23. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/quic/packet.py +0 -0
  24. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/quic/protocol.py +0 -0
  25. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/quic/recovery.py +0 -0
  26. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/quic/streams.py +0 -0
  27. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/revocation.py +0 -0
  28. {boring_http-2.1.0 → boring_http-2.2.1}/src/boring_http/timing.py +0 -0
  29. {boring_http-2.1.0 → boring_http-2.2.1}/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.1.0
3
+ Version: 2.2.1
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
 
@@ -455,7 +455,7 @@ async def main() -> None:
455
455
  asyncio.run(main())
456
456
  ```
457
457
 
458
- Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen. The `total` of `timeout` covers the handshake only; afterwards `read` and `write` still limit each wait, so `Timeout(read=None)` lets `receive()` wait through any silence.
458
+ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. Each WebSocket has a connection of its own, which `close()` closes after the closing handshake. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen. The `total` of `timeout` covers the handshake only; afterwards `read` and `write` still limit each wait, so `Timeout(read=None)` lets `receive()` wait through any silence.
459
459
 
460
460
  ## uvloop
461
461
 
@@ -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
 
@@ -445,7 +445,7 @@ async def main() -> None:
445
445
  asyncio.run(main())
446
446
  ```
447
447
 
448
- Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen. The `total` of `timeout` covers the handshake only; afterwards `read` and `write` still limit each wait, so `Timeout(read=None)` lets `receive()` wait through any silence.
448
+ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. Each WebSocket has a connection of its own, which `close()` closes after the closing handshake. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen. The `total` of `timeout` covers the handshake only; afterwards `read` and `write` still limit each wait, so `Timeout(read=None)` lets `receive()` wait through any silence.
449
449
 
450
450
  ## uvloop
451
451
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boring-http"
3
- version = "2.1.0"
3
+ version = "2.2.1"
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.1.0"
3
+ version = "2.2.1"
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:
@@ -687,4 +687,8 @@ class Http2StreamTransport:
687
687
  async def close(self) -> None:
688
688
  if not self._closed:
689
689
  self._closed = True
690
- await self._connection.write_data(self._stream, b"", end=True)
690
+ # Client.websocket opens a connection for the tunnel alone, so the connection ends with it
691
+ try:
692
+ await self._connection.write_data(self._stream, b"", end=True)
693
+ finally:
694
+ await self._connection.close()
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  import secrets
3
- from collections.abc import AsyncGenerator, Callable
3
+ from collections.abc import AsyncGenerator, Awaitable, Callable
4
4
  from dataclasses import dataclass
5
5
  from enum import IntEnum
6
6
 
@@ -311,7 +311,7 @@ class Http3Connection:
311
311
  return (
312
312
  status,
313
313
  headers,
314
- Http3StreamTransport(self._quic, stream_id, deadline, frames),
314
+ Http3StreamTransport(self._quic, stream_id, deadline, frames, self.close),
315
315
  )
316
316
 
317
317
  async def close(self) -> None:
@@ -531,12 +531,18 @@ class Http3Connection:
531
531
 
532
532
  class Http3StreamTransport:
533
533
  def __init__(
534
- self, quic: QuicClient, stream_id: int, deadline: Deadline, frames: StreamFrames
534
+ self,
535
+ quic: QuicClient,
536
+ stream_id: int,
537
+ deadline: Deadline,
538
+ frames: StreamFrames,
539
+ close_connection: Callable[[], Awaitable[None]],
535
540
  ) -> None:
536
541
  self._quic = quic
537
542
  self._stream_id = stream_id
538
543
  self._frames = frames
539
544
  self._deadline = deadline
545
+ self._close_connection = close_connection
540
546
  self._pending = b""
541
547
  self._closed = False
542
548
 
@@ -564,3 +570,5 @@ class Http3StreamTransport:
564
570
  if not self._closed:
565
571
  self._closed = True
566
572
  self._quic.send(self._stream_id, b"", end=True)
573
+ # Client.websocket opens a connection for the tunnel alone, so the connection ends with it
574
+ await self._close_connection()
@@ -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
+ )