boring-http 2.1.0__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.1.0 → boring_http-2.2.0}/PKG-INFO +2 -2
  2. {boring_http-2.1.0 → boring_http-2.2.0}/README.md +1 -1
  3. {boring_http-2.1.0 → boring_http-2.2.0}/pyproject.toml +1 -1
  4. {boring_http-2.1.0 → boring_http-2.2.0}/pyproject.toml.orig +1 -1
  5. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/client.py +28 -8
  6. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/cookies.py +9 -9
  7. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/pool.py +31 -3
  8. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/__init__.py +0 -0
  9. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/codings.py +0 -0
  10. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/errors.py +0 -0
  11. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/hpack.py +0 -0
  12. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/hpack_tables.py +0 -0
  13. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/http1.py +0 -0
  14. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/http2.py +0 -0
  15. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/http3.py +0 -0
  16. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/message.py +0 -0
  17. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/public_suffixes.py +0 -0
  18. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/py.typed +0 -0
  19. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/qpack.py +0 -0
  20. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/qpack_tables.py +0 -0
  21. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/quic/__init__.py +0 -0
  22. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/quic/connection.py +0 -0
  23. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/quic/packet.py +0 -0
  24. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/quic/protocol.py +0 -0
  25. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/quic/recovery.py +0 -0
  26. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/quic/streams.py +0 -0
  27. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/revocation.py +0 -0
  28. {boring_http-2.1.0 → boring_http-2.2.0}/src/boring_http/timing.py +0 -0
  29. {boring_http-2.1.0 → 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.1.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
 
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boring-http"
3
- version = "2.1.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.1.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 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:
@@ -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
+ )