boring-http 1.1.0__tar.gz → 1.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-1.1.0 → boring_http-1.2.0}/PKG-INFO +23 -23
  2. {boring_http-1.1.0 → boring_http-1.2.0}/README.md +20 -20
  3. {boring_http-1.1.0 → boring_http-1.2.0}/pyproject.toml +9 -6
  4. {boring_http-1.1.0 → boring_http-1.2.0}/pyproject.toml.orig +6 -3
  5. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/__init__.py +11 -6
  6. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/client.py +57 -34
  7. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/codings.py +0 -2
  8. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/cookies.py +2 -4
  9. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/errors.py +4 -0
  10. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/hpack.py +7 -12
  11. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/hpack_tables.py +2 -4
  12. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/http1.py +12 -6
  13. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/http2.py +101 -89
  14. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/http3.py +8 -12
  15. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/message.py +24 -25
  16. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/pool.py +4 -5
  17. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/qpack.py +4 -6
  18. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/qpack_tables.py +1 -3
  19. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/connection.py +226 -102
  20. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/packet.py +75 -80
  21. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/protocol.py +64 -47
  22. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/recovery.py +28 -21
  23. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/streams.py +23 -39
  24. boring_http-1.2.0/src/boring_http/revocation.py +56 -0
  25. boring_http-1.2.0/src/boring_http/timing.py +99 -0
  26. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/websocket.py +3 -4
  27. boring_http-1.1.0/src/boring_http/timing.py +0 -63
  28. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/py.typed +0 -0
  29. {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/__init__.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: boring-http
3
- Version: 1.1.0
3
+ Version: 1.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
- Requires-Dist: boring-ssl2
6
- Requires-Dist: ssl2
5
+ Requires-Dist: boring-ssl2>=1.2.0
6
+ Requires-Dist: ssl2>=1.2.0
7
7
  Requires-Python: >=3.14
8
8
  Project-URL: Repository, https://github.com/baobien-dev/boring-http
9
9
  Description-Content-Type: text/markdown
@@ -39,7 +39,7 @@ async def main() -> None:
39
39
  asyncio.run(main())
40
40
  ```
41
41
 
42
- 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. 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`.
42
+ 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`.
43
43
 
44
44
  ## Responses
45
45
 
@@ -96,7 +96,7 @@ A single number sets every phase and the total, so the request gives up after th
96
96
  ```python
97
97
  import asyncio
98
98
 
99
- from boring_http import QUIC_V2, Client, Http3Profile
99
+ from boring_http import Client, Http3Profile, QuicVersion
100
100
 
101
101
 
102
102
  async def main() -> None:
@@ -105,7 +105,7 @@ async def main() -> None:
105
105
  second = await client.get("https://pingly.us.kg/api/all")
106
106
  print(first.version, second.version) # h2 h3, the Alt-Svc header taught the client that this origin speaks HTTP/3
107
107
 
108
- async with Client(versions=("h3",), http3=Http3Profile(quic_version=QUIC_V2)) as client:
108
+ async with Client(versions=("h3",), http3=Http3Profile(quic_version=QuicVersion.V2)) as client:
109
109
  response = await client.get("https://pingly.us.kg/api/all")
110
110
  print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
111
111
 
@@ -113,7 +113,7 @@ async def main() -> None:
113
113
  asyncio.run(main())
114
114
  ```
115
115
 
116
- The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, QPACK with a 64 KiB dynamic table on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. The transport parameters are Chrome 155's set, in a fresh random order with a GREASE entry, a GREASE version, Chrome's connection options and, on a resumed connection, the round trip time measured last time; `TransportParameters` on the `Http3Profile` changes any of it. The control stream carries SETTINGS with a GREASE entry, a GREASE frame, and a PRIORITY_UPDATE for every request that has a `priority` header, as Chrome sends them.
116
+ The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with a 64 KiB dynamic table on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. The transport parameters are Chrome 155's set, in a fresh random order with a GREASE entry, a GREASE version, Chrome's connection options and, on a resumed connection, the round trip time measured last time; `TransportParameters` on the `Http3Profile` changes any of it. The control stream carries SETTINGS with a GREASE entry, a GREASE frame, and a PRIORITY_UPDATE for every request that has a `priority` header, as Chrome sends them.
117
117
 
118
118
  ## Proxies
119
119
 
@@ -152,7 +152,7 @@ asyncio.run(main())
152
152
  ```python
153
153
  import asyncio
154
154
 
155
- from boring_http import CHROME, Client
155
+ from boring_http import CHROME_HTTP2, Client
156
156
  from ssl2 import (
157
157
  JA4,
158
158
  CertificateCompressionAlgorithm,
@@ -227,10 +227,10 @@ profile = (
227
227
 
228
228
 
229
229
  async def main() -> None:
230
- async with Client(tls=profile, http2=CHROME) as client:
230
+ async with Client(tls=profile, http2=CHROME_HTTP2) as client:
231
231
  report = (await client.get("https://pingly.us.kg/api/all")).json()
232
232
  print(report["tls"]["ja4"] == JA4.of(profile).text, report["tls"]["ja4"]) # True t13d1516h2_8daaf6152771_d8a2da3f94cd
233
- print(report["http2"]["akamai_fingerprint"] == CHROME.fingerprint.text, report["http2"]["akamai_fingerprint"]) # True 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
233
+ print(report["http2"]["akamai_fingerprint"] == CHROME_HTTP2.fingerprint.text, report["http2"]["akamai_fingerprint"]) # True 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
234
234
 
235
235
 
236
236
  asyncio.run(main())
@@ -312,7 +312,7 @@ async def main() -> None:
312
312
  asyncio.run(main())
313
313
  ```
314
314
 
315
- `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Profile` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order; `CHROME` is the default. `http3` is an `Http3Profile` with the HTTP/3 SETTINGS, QUIC transport parameters and QUIC version.
315
+ `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Profile` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order; `CHROME_HTTP2` is the default. `http3` is an `Http3Profile` with the HTTP/3 SETTINGS, QUIC transport parameters and QUIC version; `CHROME_HTTP3` is the default.
316
316
 
317
317
  ## WebSocket
318
318
 
@@ -363,17 +363,17 @@ Against a Go server (net/http and quic-go) on loopback, so the server is never t
363
363
 
364
364
  | scenario | boring-http | boring-http+uvloop | wreq | curl_cffi | pycurl | httpx | aiohttp | requests |
365
365
  | ----------- | ----------: | -----------------: | ----: | --------: | -----: | ----: | ------: | -------: |
366
- | h1-seq | 9079 | 13405 | 17190 | 6116 | 28775 | 2171 | 11796 | 3287 |
367
- | h2-seq | 5744 | 5931 | 12384 | 4767 | 15384 | 1808 | - | - |
368
- | h3-seq | 4021 | 4537 | - | 4595 | 12410 | - | - | - |
369
- | h1-conc | 8956 | 12148 | 53500 | 7324 | 31170 | 934 | 23259 | 2361 |
370
- | h2-conc | 14846 | 15584 | 48788 | 10683 | 28764 | 2066 | - | - |
371
- | h3-conc | 6855 | 6901 | - | 11079 | 29305 | - | - | - |
372
- | h1-download | 658 | 645 | 689 | 793 | 1019 | 264 | 1117 | 658 |
373
- | h2-download | 472 | 497 | 426 | 468 | 479 | 123 | - | - |
374
- | h3-download | 105 | 107 | - | 517 | 492 | - | - | - |
375
- | h1-upload | 633 | 687 | 3687 | 2002 | 2124 | 675 | 749 | 2296 |
376
- | h2-upload | 447 | 480 | 2309 | 1375 | 1440 | 197 | - | - |
377
- | h3-upload | 63 | 67 | - | 906 | 411 | - | - | - |
366
+ | h1-seq | 14713 | 14354 | 20116 | 6042 | 29137 | 2248 | 11831 | 3539 |
367
+ | h2-seq | 7307 | 7359 | 13404 | 5096 | 16023 | 1880 | - | - |
368
+ | h3-seq | 4679 | 4308 | - | 4725 | 14041 | - | - | - |
369
+ | h1-conc | 11594 | 12391 | 61212 | 7443 | 33861 | 939 | 24836 | 2438 |
370
+ | h2-conc | 19234 | 20085 | 41075 | 11354 | 30053 | 2164 | - | - |
371
+ | h3-conc | 10121 | 9788 | - | 11447 | 29598 | - | - | - |
372
+ | h1-download | 734 | 772 | 823 | 767 | 1229 | 257 | 1081 | 594 |
373
+ | h2-download | 319 | 303 | 443 | 466 | 484 | 120 | - | - |
374
+ | h3-download | 199 | 207 | - | 542 | 491 | - | - | - |
375
+ | h1-upload | 2513 | 3093 | 3276 | 2106 | 1998 | 694 | 713 | 2376 |
376
+ | h2-upload | 786 | 839 | 2206 | 1085 | 1323 | 186 | - | - |
377
+ | h3-upload | 132 | 132 | - | 819 | 434 | - | - | - |
378
378
 
379
379
  HTTP/1.1 and HTTP/2 are Python framing over BoringSSL records; HTTP/3 is a QUIC stack written in Python with BoringSSL doing the cryptography, which is where the gap to libcurl's HTTP/3 comes from. The same code holds 1000 open clients per protocol, 20000 requests with 256 in flight, parallel multi-megabyte transfers and 5% datagram loss on the QUIC path.
@@ -29,7 +29,7 @@ async def main() -> None:
29
29
  asyncio.run(main())
30
30
  ```
31
31
 
32
- 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. 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`.
32
+ 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`.
33
33
 
34
34
  ## Responses
35
35
 
@@ -86,7 +86,7 @@ A single number sets every phase and the total, so the request gives up after th
86
86
  ```python
87
87
  import asyncio
88
88
 
89
- from boring_http import QUIC_V2, Client, Http3Profile
89
+ from boring_http import Client, Http3Profile, QuicVersion
90
90
 
91
91
 
92
92
  async def main() -> None:
@@ -95,7 +95,7 @@ async def main() -> None:
95
95
  second = await client.get("https://pingly.us.kg/api/all")
96
96
  print(first.version, second.version) # h2 h3, the Alt-Svc header taught the client that this origin speaks HTTP/3
97
97
 
98
- async with Client(versions=("h3",), http3=Http3Profile(quic_version=QUIC_V2)) as client:
98
+ async with Client(versions=("h3",), http3=Http3Profile(quic_version=QuicVersion.V2)) as client:
99
99
  response = await client.get("https://pingly.us.kg/api/all")
100
100
  print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
101
101
 
@@ -103,7 +103,7 @@ async def main() -> None:
103
103
  asyncio.run(main())
104
104
  ```
105
105
 
106
- The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, QPACK with a 64 KiB dynamic table on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. The transport parameters are Chrome 155's set, in a fresh random order with a GREASE entry, a GREASE version, Chrome's connection options and, on a resumed connection, the round trip time measured last time; `TransportParameters` on the `Http3Profile` changes any of it. The control stream carries SETTINGS with a GREASE entry, a GREASE frame, and a PRIORITY_UPDATE for every request that has a `priority` header, as Chrome sends them.
106
+ The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with a 64 KiB dynamic table on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. The transport parameters are Chrome 155's set, in a fresh random order with a GREASE entry, a GREASE version, Chrome's connection options and, on a resumed connection, the round trip time measured last time; `TransportParameters` on the `Http3Profile` changes any of it. The control stream carries SETTINGS with a GREASE entry, a GREASE frame, and a PRIORITY_UPDATE for every request that has a `priority` header, as Chrome sends them.
107
107
 
108
108
  ## Proxies
109
109
 
@@ -142,7 +142,7 @@ asyncio.run(main())
142
142
  ```python
143
143
  import asyncio
144
144
 
145
- from boring_http import CHROME, Client
145
+ from boring_http import CHROME_HTTP2, Client
146
146
  from ssl2 import (
147
147
  JA4,
148
148
  CertificateCompressionAlgorithm,
@@ -217,10 +217,10 @@ profile = (
217
217
 
218
218
 
219
219
  async def main() -> None:
220
- async with Client(tls=profile, http2=CHROME) as client:
220
+ async with Client(tls=profile, http2=CHROME_HTTP2) as client:
221
221
  report = (await client.get("https://pingly.us.kg/api/all")).json()
222
222
  print(report["tls"]["ja4"] == JA4.of(profile).text, report["tls"]["ja4"]) # True t13d1516h2_8daaf6152771_d8a2da3f94cd
223
- print(report["http2"]["akamai_fingerprint"] == CHROME.fingerprint.text, report["http2"]["akamai_fingerprint"]) # True 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
223
+ print(report["http2"]["akamai_fingerprint"] == CHROME_HTTP2.fingerprint.text, report["http2"]["akamai_fingerprint"]) # True 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
224
224
 
225
225
 
226
226
  asyncio.run(main())
@@ -302,7 +302,7 @@ async def main() -> None:
302
302
  asyncio.run(main())
303
303
  ```
304
304
 
305
- `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Profile` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order; `CHROME` is the default. `http3` is an `Http3Profile` with the HTTP/3 SETTINGS, QUIC transport parameters and QUIC version.
305
+ `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Profile` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order; `CHROME_HTTP2` is the default. `http3` is an `Http3Profile` with the HTTP/3 SETTINGS, QUIC transport parameters and QUIC version; `CHROME_HTTP3` is the default.
306
306
 
307
307
  ## WebSocket
308
308
 
@@ -353,17 +353,17 @@ Against a Go server (net/http and quic-go) on loopback, so the server is never t
353
353
 
354
354
  | scenario | boring-http | boring-http+uvloop | wreq | curl_cffi | pycurl | httpx | aiohttp | requests |
355
355
  | ----------- | ----------: | -----------------: | ----: | --------: | -----: | ----: | ------: | -------: |
356
- | h1-seq | 9079 | 13405 | 17190 | 6116 | 28775 | 2171 | 11796 | 3287 |
357
- | h2-seq | 5744 | 5931 | 12384 | 4767 | 15384 | 1808 | - | - |
358
- | h3-seq | 4021 | 4537 | - | 4595 | 12410 | - | - | - |
359
- | h1-conc | 8956 | 12148 | 53500 | 7324 | 31170 | 934 | 23259 | 2361 |
360
- | h2-conc | 14846 | 15584 | 48788 | 10683 | 28764 | 2066 | - | - |
361
- | h3-conc | 6855 | 6901 | - | 11079 | 29305 | - | - | - |
362
- | h1-download | 658 | 645 | 689 | 793 | 1019 | 264 | 1117 | 658 |
363
- | h2-download | 472 | 497 | 426 | 468 | 479 | 123 | - | - |
364
- | h3-download | 105 | 107 | - | 517 | 492 | - | - | - |
365
- | h1-upload | 633 | 687 | 3687 | 2002 | 2124 | 675 | 749 | 2296 |
366
- | h2-upload | 447 | 480 | 2309 | 1375 | 1440 | 197 | - | - |
367
- | h3-upload | 63 | 67 | - | 906 | 411 | - | - | - |
356
+ | h1-seq | 14713 | 14354 | 20116 | 6042 | 29137 | 2248 | 11831 | 3539 |
357
+ | h2-seq | 7307 | 7359 | 13404 | 5096 | 16023 | 1880 | - | - |
358
+ | h3-seq | 4679 | 4308 | - | 4725 | 14041 | - | - | - |
359
+ | h1-conc | 11594 | 12391 | 61212 | 7443 | 33861 | 939 | 24836 | 2438 |
360
+ | h2-conc | 19234 | 20085 | 41075 | 11354 | 30053 | 2164 | - | - |
361
+ | h3-conc | 10121 | 9788 | - | 11447 | 29598 | - | - | - |
362
+ | h1-download | 734 | 772 | 823 | 767 | 1229 | 257 | 1081 | 594 |
363
+ | h2-download | 319 | 303 | 443 | 466 | 484 | 120 | - | - |
364
+ | h3-download | 199 | 207 | - | 542 | 491 | - | - | - |
365
+ | h1-upload | 2513 | 3093 | 3276 | 2106 | 1998 | 694 | 713 | 2376 |
366
+ | h2-upload | 786 | 839 | 2206 | 1085 | 1323 | 186 | - | - |
367
+ | h3-upload | 132 | 132 | - | 819 | 434 | - | - | - |
368
368
 
369
369
  HTTP/1.1 and HTTP/2 are Python framing over BoringSSL records; HTTP/3 is a QUIC stack written in Python with BoringSSL doing the cryptography, which is where the gap to libcurl's HTTP/3 comes from. The same code holds 1000 open clients per protocol, 20000 requests with 256 in flight, parallel multi-megabyte transfers and 5% datagram loss on the QUIC path.
@@ -1,20 +1,19 @@
1
1
  [project]
2
2
  name = "boring-http"
3
- version = "1.1.0"
3
+ version = "1.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"
7
7
  dependencies = [
8
- "boring-ssl2",
9
- "ssl2",
8
+ "boring-ssl2>=1.2.0",
9
+ "ssl2>=1.2.0",
10
10
  ]
11
11
 
12
12
  [project.urls]
13
13
  Repository = "https://github.com/baobien-dev/boring-http"
14
14
 
15
- [build-system]
16
- requires = ["uv_build>=0.13.0,<0.14.0"]
17
- build-backend = "uv_build"
15
+ [tool.basedpyright]
16
+ typeCheckingMode = "strict"
18
17
 
19
18
  [tool.uv.sources.ssl2]
20
19
  path = "../ssl2"
@@ -23,3 +22,7 @@ editable = true
23
22
  [tool.uv.sources.boring-ssl2]
24
23
  path = "../boring-ssl2"
25
24
  editable = true
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.13.0,<0.14.0"]
28
+ build-backend = "uv_build"
@@ -1,17 +1,20 @@
1
1
  [project]
2
2
  name = "boring-http"
3
- version = "1.1.0"
3
+ version = "1.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"
7
7
  dependencies = [
8
- "boring-ssl2",
9
- "ssl2",
8
+ "boring-ssl2>=1.2.0",
9
+ "ssl2>=1.2.0",
10
10
  ]
11
11
 
12
12
  [project.urls]
13
13
  Repository = "https://github.com/baobien-dev/boring-http"
14
14
 
15
+ [tool.basedpyright]
16
+ typeCheckingMode = "strict"
17
+
15
18
  [build-system]
16
19
  requires = ["uv_build>=0.13.0,<0.14.0"]
17
20
  build-backend = "uv_build"
@@ -1,3 +1,5 @@
1
+ from boring_ssl2 import QuicVersion
2
+
1
3
  from boring_http.client import Client
2
4
  from boring_http.cookies import Cookie, CookieJar
3
5
  from boring_http.errors import (
@@ -5,14 +7,15 @@ from boring_http.errors import (
5
7
  HTTPError,
6
8
  ProtocolError,
7
9
  RemoteDisconnected,
10
+ RevocationError,
8
11
  StreamRefused,
9
12
  StreamResetError,
10
13
  TimeoutError,
11
14
  TooManyRedirects,
12
15
  WebSocketClosed,
13
16
  )
14
- from boring_http.http2 import CHROME, Http2Profile
15
- from boring_http.http3 import Http3Profile
17
+ from boring_http.http2 import CHROME_HTTP2, Http2Profile
18
+ from boring_http.http3 import CHROME_HTTP3, Http3Profile
16
19
  from boring_http.message import (
17
20
  URL,
18
21
  Body,
@@ -23,14 +26,13 @@ from boring_http.message import (
23
26
  Response,
24
27
  )
25
28
  from boring_http.quic.connection import TransportParameters
26
- from boring_http.quic.packet import QUIC_V1, QUIC_V2
29
+ from boring_http.revocation import Revocation
27
30
  from boring_http.timing import Timeout
28
31
  from boring_http.websocket import WebSocket
29
32
 
30
33
  __all__ = (
31
- "CHROME",
32
- "QUIC_V1",
33
- "QUIC_V2",
34
+ "CHROME_HTTP2",
35
+ "CHROME_HTTP3",
34
36
  "URL",
35
37
  "Body",
36
38
  "Client",
@@ -44,9 +46,12 @@ __all__ = (
44
46
  "Http2Profile",
45
47
  "Http3Profile",
46
48
  "ProtocolError",
49
+ "QuicVersion",
47
50
  "RemoteDisconnected",
48
51
  "Request",
49
52
  "Response",
53
+ "Revocation",
54
+ "RevocationError",
50
55
  "StreamRefused",
51
56
  "StreamResetError",
52
57
  "Timeout",
@@ -1,12 +1,11 @@
1
- import asyncio
2
1
  import base64
3
2
  from collections.abc import AsyncGenerator, AsyncIterable, Mapping, Sequence
4
3
  from contextlib import asynccontextmanager
5
4
  from dataclasses import replace
6
5
  from time import monotonic
7
- from typing import Final, Self, TypedDict, Unpack, final
6
+ from typing import Self, TypedDict, Unpack
8
7
 
9
- from boring_ssl2 import ClientContext as TLSClientContext
8
+ from boring_ssl2 import ClientContext
10
9
  from ssl2 import (
11
10
  AsyncTransport,
12
11
  ClientHello,
@@ -17,7 +16,7 @@ from ssl2 import (
17
16
  ProxyError,
18
17
  ProxyScheme,
19
18
  SessionCache,
20
- StreamTransport,
19
+ SocketTransport,
21
20
  TLSClientConfiguration,
22
21
  TLSError,
23
22
  TLSStream,
@@ -30,14 +29,14 @@ from boring_http.errors import (
30
29
  HTTPError,
31
30
  ProtocolError,
32
31
  RemoteDisconnected,
32
+ RevocationError,
33
33
  StreamRefused,
34
34
  TimeoutError,
35
35
  TooManyRedirects,
36
36
  )
37
37
  from boring_http.http1 import Http1Connection
38
- from boring_http.http2 import CHROME, Http2Connection, Http2Profile
39
- from boring_http.http3 import DEFAULT as HTTP3_DEFAULT
40
- from boring_http.http3 import Http3Connection, Http3Profile
38
+ from boring_http.http2 import CHROME_HTTP2, Http2Connection, Http2Profile
39
+ from boring_http.http3 import CHROME_HTTP3, Http3Connection, Http3Profile
41
40
  from boring_http.message import (
42
41
  URL,
43
42
  Body,
@@ -52,25 +51,25 @@ from boring_http.message import (
52
51
  )
53
52
  from boring_http.pool import Connection, Pool
54
53
  from boring_http.quic.protocol import OriginMemory, QuicClient
54
+ from boring_http.revocation import Revocation, Revocations
55
55
  from boring_http.timing import Deadline, Timeout
56
56
  from boring_http.websocket import WebSocket
57
57
 
58
- IMPLEMENTED: Final = (HTTPVersion.HTTP_3, HTTPVersion.HTTP_2, HTTPVersion.HTTP_1)
59
- ALPN: Final = {
58
+ IMPLEMENTED = (HTTPVersion.HTTP_3, HTTPVersion.HTTP_2, HTTPVersion.HTTP_1)
59
+ ALPN = {
60
60
  HTTPVersion.HTTP_2: NextProtocol.H2,
61
61
  HTTPVersion.HTTP_1: NextProtocol.HTTP1,
62
62
  }
63
- ACCEPTED_ENCODINGS: Final = "gzip, deflate, br, zstd"
64
- USER_AGENT: Final = "boring-http"
65
- DEFAULT_TIMEOUT: Final = Timeout()
63
+ ACCEPTED_ENCODINGS = "gzip, deflate, br, zstd"
64
+ USER_AGENT = "boring-http"
65
+ DEFAULT_TIMEOUT = Timeout()
66
66
 
67
67
 
68
- @final
69
68
  class Unset:
70
69
  pass
71
70
 
72
71
 
73
- UNSET: Final = Unset()
72
+ UNSET = Unset()
74
73
 
75
74
 
76
75
  class RequestOptions(TypedDict, total=False):
@@ -87,7 +86,6 @@ class RequestOptions(TypedDict, total=False):
87
86
  proxy: Proxy | str | None | Unset
88
87
 
89
88
 
90
- @final
91
89
  class Client:
92
90
  def __init__(
93
91
  self,
@@ -96,14 +94,15 @@ class Client:
96
94
  timeout: float | Timeout | None = DEFAULT_TIMEOUT,
97
95
  proxy: Proxy | str | None = None,
98
96
  tls: ClientHello | None = None,
99
- http2: Http2Profile = CHROME,
100
- http3: Http3Profile = HTTP3_DEFAULT,
97
+ http2: Http2Profile = CHROME_HTTP2,
98
+ http3: Http3Profile = CHROME_HTTP3,
101
99
  versions: Sequence[HTTPVersion | str] = (
102
100
  HTTPVersion.HTTP_2,
103
101
  HTTPVersion.HTTP_1,
104
102
  ),
105
103
  trust_store: TrustStore | None = None,
106
104
  verify: bool = True,
105
+ revocation: Revocation = Revocation.SOFT,
107
106
  cookies: CookieJar | None = None,
108
107
  base_url: str | None = None,
109
108
  max_connections: int = 100,
@@ -122,11 +121,12 @@ class Client:
122
121
  self._versions = tuple(HTTPVersion(version) for version in versions)
123
122
  self._trust_store = trust_store
124
123
  self._verify = verify
124
+ self._revocations = Revocations(revocation, self._fetch_crl)
125
125
  self._base_url = base_url
126
126
  self._max_redirects = max_redirects
127
127
  self._max_message_size = max_message_size
128
128
  self._decode = decode
129
- self._contexts: dict[tuple[NextProtocol, ...] | None, TLSClientContext] = {}
129
+ self._contexts: dict[tuple[NextProtocol, ...] | None, ClientContext] = {}
130
130
  self._memory: dict[tuple[str, int], OriginMemory] = {}
131
131
  self._pool = Pool(self._connect, max_connections, max_connections_per_host)
132
132
  self.cookies = CookieJar() if cookies is None else cookies
@@ -230,7 +230,11 @@ class Client:
230
230
  if params is not None:
231
231
  target = target.with_params(params)
232
232
  body = self._body(json, data, content, files)
233
- merged = self._headers.merged(Headers(headers))
233
+ merged = (
234
+ self._headers.copy()
235
+ if headers is None
236
+ else self._headers.merged(Headers(headers))
237
+ )
234
238
  if (
235
239
  body is not None
236
240
  and body.content_type is not None
@@ -280,7 +284,7 @@ class Client:
280
284
  response.history = tuple(history)
281
285
  if not stream:
282
286
  try:
283
- _ = await response.read()
287
+ await response.read()
284
288
  except BaseException:
285
289
  await response.close()
286
290
  raise
@@ -290,7 +294,7 @@ class Client:
290
294
  raise TooManyRedirects(
291
295
  f"more than {self._max_redirects} redirects from {history[0].url if history else request.url}"
292
296
  )
293
- _ = await response.read()
297
+ await response.read()
294
298
  history.append(response)
295
299
  request = self._redirect(request, response)
296
300
 
@@ -350,7 +354,7 @@ class Client:
350
354
  )
351
355
  self.cookies.store(response.cookies)
352
356
  if response.status != 101:
353
- _ = await response.read()
357
+ await response.read()
354
358
  raise ProtocolError(
355
359
  f"websocket handshake answered {response.status} {response.reason}: {response.text[:200]}"
356
360
  )
@@ -410,7 +414,7 @@ class Client:
410
414
 
411
415
  def _context(
412
416
  self, protocols: tuple[NextProtocol, ...] | None = None
413
- ) -> TLSClientContext:
417
+ ) -> ClientContext:
414
418
  if protocols not in self._contexts:
415
419
  hello = (
416
420
  self._http3.tls
@@ -437,7 +441,7 @@ class Client:
437
441
  if hello is None or hello.offers(ExtensionType.pre_shared_key)
438
442
  else None
439
443
  )
440
- self._contexts[protocols] = TLSClientContext(
444
+ self._contexts[protocols] = ClientContext(
441
445
  configuration, session_cache=cache
442
446
  )
443
447
  return self._contexts[protocols]
@@ -457,6 +461,19 @@ class Client:
457
461
  ),
458
462
  )
459
463
 
464
+ async def _checked(self, stream: TLSStream, authority: str) -> None:
465
+ try:
466
+ await self._revocations.check(stream.buffer.getpeerchain(), authority)
467
+ except RevocationError:
468
+ await stream.close()
469
+ raise
470
+
471
+ async def _fetch_crl(self, url: str) -> bytes:
472
+ async with Client(
473
+ versions=(HTTPVersion.HTTP_1,), revocation=Revocation.NONE, timeout=10
474
+ ) as inner:
475
+ return (await inner.get(url)).body
476
+
460
477
  async def _connect(
461
478
  self,
462
479
  url: URL,
@@ -477,6 +494,13 @@ class Client:
477
494
  proxy=proxy,
478
495
  memory=memory,
479
496
  )
497
+ try:
498
+ await self._revocations.check(
499
+ client.connection.peer_chain, url.authority
500
+ )
501
+ except RevocationError:
502
+ client.close()
503
+ raise
480
504
  connection = Http3Connection(client, self._http3, memory)
481
505
  await connection.start()
482
506
  return connection
@@ -486,6 +510,7 @@ class Client:
486
510
  url.address,
487
511
  proxy=proxy,
488
512
  )
513
+ await self._checked(stream, url.authority)
489
514
  if stream.negotiated_protocol() is NextProtocol.H2:
490
515
  connection = Http2Connection(stream, self._http2)
491
516
  await connection.start()
@@ -497,13 +522,13 @@ class Client:
497
522
  ):
498
523
  transport: AsyncTransport
499
524
  if proxy.scheme is ProxyScheme.HTTPS:
500
- transport = await TLSStream.connect(
525
+ secured = await TLSStream.connect(
501
526
  self._context(), (proxy.host, proxy.port)
502
527
  )
528
+ await self._checked(secured, f"{proxy.host}:{proxy.port}")
529
+ transport = secured
503
530
  else:
504
- transport = StreamTransport(
505
- *await asyncio.open_connection(proxy.host, proxy.port)
506
- )
531
+ transport = await SocketTransport.connect((proxy.host, proxy.port))
507
532
  authorization = None
508
533
  if proxy.credentials is not None:
509
534
  authorization = "Basic " + base64.b64encode(
@@ -512,11 +537,9 @@ class Client:
512
537
  return Http1Connection(
513
538
  transport, absolute_form=True, proxy_authorization=authorization
514
539
  )
515
- transport, leftover = await TLSStream.tunnel(
516
- self._context(), url.address, proxy
517
- )
518
- return Http1Connection(transport, leftover=leftover)
519
- except ProxyError, ConfigurationError:
540
+ tunnel = await TLSStream.tunnel(self._context(), url.address, proxy)
541
+ return Http1Connection(tunnel.transport, leftover=tunnel.leftover)
542
+ except ProxyError, ConfigurationError, RevocationError:
520
543
  raise
521
544
  except TLSError as error:
522
545
  raise ConnectError(
@@ -537,7 +560,7 @@ class Client:
537
560
  except ConnectError, TimeoutError:
538
561
  if quic and self._versions != (HTTPVersion.HTTP_3,):
539
562
  # the learned HTTP/3 route is unreachable, so this origin goes back to TCP
540
- _ = self._alt_svc.pop(request.url.address, None)
563
+ self._alt_svc.pop(request.url.address, None)
541
564
  quic = False
542
565
  continue
543
566
  raise
@@ -1,14 +1,12 @@
1
1
  import zlib
2
2
  from collections.abc import Callable, Sequence
3
3
  from compression import zstd
4
- from typing import final
5
4
 
6
5
  from boring_ssl2 import BrotliDecoder
7
6
 
8
7
  from boring_http.errors import ProtocolError
9
8
 
10
9
 
11
- @final
12
10
  class ContentDecoder:
13
11
  def __init__(self, codings: Sequence[str]) -> None:
14
12
  self._codings = tuple(reversed(codings))
@@ -2,10 +2,9 @@ import time
2
2
  from collections.abc import Iterable, Iterator
3
3
  from dataclasses import dataclass
4
4
  from email.utils import parsedate_to_datetime
5
- from typing import Self, final
5
+ from typing import Self
6
6
 
7
7
 
8
- @final
9
8
  @dataclass(frozen=True, slots=True)
10
9
  class Cookie:
11
10
  name: str
@@ -86,7 +85,6 @@ class Cookie:
86
85
  return None
87
86
 
88
87
 
89
- @final
90
88
  class CookieJar:
91
89
  def __init__(self) -> None:
92
90
  self._cookies: dict[tuple[str, str, str], Cookie] = {}
@@ -106,7 +104,7 @@ class CookieJar:
106
104
  def set(self, cookie: Cookie) -> None:
107
105
  key = (cookie.domain, cookie.path, cookie.name)
108
106
  if cookie.expired:
109
- _ = self._cookies.pop(key, None)
107
+ self._cookies.pop(key, None)
110
108
  else:
111
109
  self._cookies[key] = cookie
112
110
 
@@ -9,6 +9,10 @@ class ConnectError(HTTPError):
9
9
  pass
10
10
 
11
11
 
12
+ class RevocationError(ConnectError):
13
+ pass
14
+
15
+
12
16
  class RemoteDisconnected(ConnectError):
13
17
  pass
14
18