boring-http 1.0.1__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.0.1 → boring_http-1.2.0}/PKG-INFO +99 -23
  2. {boring_http-1.0.1 → boring_http-1.2.0}/README.md +96 -20
  3. {boring_http-1.0.1 → boring_http-1.2.0}/pyproject.toml +9 -6
  4. {boring_http-1.0.1 → boring_http-1.2.0}/pyproject.toml.orig +6 -3
  5. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/__init__.py +11 -6
  6. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/client.py +57 -34
  7. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/codings.py +0 -2
  8. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/cookies.py +2 -4
  9. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/errors.py +4 -0
  10. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/hpack.py +7 -12
  11. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/hpack_tables.py +2 -4
  12. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/http1.py +12 -6
  13. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/http2.py +101 -89
  14. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/http3.py +26 -14
  15. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/message.py +24 -25
  16. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/pool.py +4 -5
  17. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/qpack.py +4 -6
  18. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/qpack_tables.py +1 -3
  19. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/quic/connection.py +257 -105
  20. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/quic/packet.py +75 -80
  21. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/quic/protocol.py +67 -47
  22. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/quic/recovery.py +28 -21
  23. {boring_http-1.0.1 → 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.0.1 → boring_http-1.2.0}/src/boring_http/websocket.py +3 -4
  27. boring_http-1.0.1/src/boring_http/timing.py +0 -63
  28. {boring_http-1.0.1 → boring_http-1.2.0}/src/boring_http/py.typed +0 -0
  29. {boring_http-1.0.1 → 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.0.1
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 go out in a fresh random order with a GREASE entry, 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,16 +227,92 @@ 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())
237
237
  ```
238
238
 
239
- `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.
239
+ Over HTTP/3 the same holds, with the ClientHello Chrome 155 sends on QUIC: TLS 1.3 only, the `trust_anchors` extension with the 28 identifiers of Chrome's root store, and `pre_shared_key` once a session exists.
240
+
241
+ ```python
242
+ import asyncio
243
+
244
+ from boring_http import Client, Http3Profile
245
+ from ssl2 import (
246
+ CHROME_TRUST_ANCHOR_IDS,
247
+ JA4,
248
+ CertificateCompressionAlgorithm,
249
+ CipherSuite,
250
+ ClientHelloBuilder,
251
+ ExtensionType,
252
+ JA4Transport,
253
+ NamedGroup,
254
+ NextProtocol,
255
+ ProtocolVersion,
256
+ PskKeyExchangeMode,
257
+ SignatureScheme,
258
+ )
259
+
260
+ quic_profile = (
261
+ ClientHelloBuilder()
262
+ .supported_versions(ProtocolVersion.TLS_1_3)
263
+ .cipher_suites(CipherSuite.TLS_AES_128_GCM_SHA256, CipherSuite.TLS_AES_256_GCM_SHA384, CipherSuite.TLS_CHACHA20_POLY1305_SHA256)
264
+ .extensions(
265
+ ExtensionType.signature_algorithms,
266
+ ExtensionType.application_settings,
267
+ ExtensionType.application_layer_protocol_negotiation,
268
+ ExtensionType.compress_certificate,
269
+ ExtensionType.trust_anchors,
270
+ ExtensionType.server_name,
271
+ ExtensionType.psk_key_exchange_modes,
272
+ ExtensionType.key_share,
273
+ ExtensionType.quic_transport_parameters,
274
+ ExtensionType.supported_versions,
275
+ ExtensionType.supported_groups,
276
+ ExtensionType.encrypted_client_hello,
277
+ ExtensionType.pre_shared_key,
278
+ )
279
+ .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
280
+ .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
281
+ .signature_algorithms(
282
+ SignatureScheme.ecdsa_secp256r1_sha256,
283
+ SignatureScheme.rsa_pss_rsae_sha256,
284
+ SignatureScheme.rsa_pkcs1_sha256,
285
+ SignatureScheme.ecdsa_secp384r1_sha384,
286
+ SignatureScheme.rsa_pss_rsae_sha384,
287
+ SignatureScheme.rsa_pkcs1_sha384,
288
+ SignatureScheme.rsa_pss_rsae_sha512,
289
+ SignatureScheme.rsa_pkcs1_sha512,
290
+ SignatureScheme.rsa_pkcs1_sha1,
291
+ )
292
+ .application_protocols(NextProtocol.H3)
293
+ .application_settings(NextProtocol.H3)
294
+ .certificate_compression(CertificateCompressionAlgorithm.brotli)
295
+ .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
296
+ .trust_anchor_ids(*CHROME_TRUST_ANCHOR_IDS)
297
+ .grease()
298
+ .permute_extensions()
299
+ .build()
300
+ )
301
+
302
+
303
+ async def main() -> None:
304
+ async with Client(versions=("h3",), http3=Http3Profile(tls=quic_profile), headers=[("priority", "u=0, i")]) as client:
305
+ _ = await client.get("https://pingly.us.kg/api/all")
306
+ await client.close() # the next request opens a new connection, which resumes the session
307
+ report = (await client.get("https://pingly.us.kg/api/all")).json()
308
+ print(report["tls"]["ja4"] == JA4.of(quic_profile, JA4Transport.QUIC).text, report["tls"]["ja4"]) # True q13d0313h3_55b375c5d22e_b0954bf1abdf
309
+ print(report["http3"]["h3_text"]) # 1:65536;6:262144;7:100;51:1;GREASE|m,a,s,p
310
+
311
+
312
+ asyncio.run(main())
313
+ ```
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_HTTP2` is the default. `http3` is an `Http3Profile` with the HTTP/3 SETTINGS, QUIC transport parameters and QUIC version; `CHROME_HTTP3` is the default.
240
316
 
241
317
  ## WebSocket
242
318
 
@@ -287,17 +363,17 @@ Against a Go server (net/http and quic-go) on loopback, so the server is never t
287
363
 
288
364
  | scenario | boring-http | boring-http+uvloop | wreq | curl_cffi | pycurl | httpx | aiohttp | requests |
289
365
  | ----------- | ----------: | -----------------: | ----: | --------: | -----: | ----: | ------: | -------: |
290
- | h1-seq | 9079 | 13405 | 17190 | 6116 | 28775 | 2171 | 11796 | 3287 |
291
- | h2-seq | 5744 | 5931 | 12384 | 4767 | 15384 | 1808 | - | - |
292
- | h3-seq | 4021 | 4537 | - | 4595 | 12410 | - | - | - |
293
- | h1-conc | 8956 | 12148 | 53500 | 7324 | 31170 | 934 | 23259 | 2361 |
294
- | h2-conc | 14846 | 15584 | 48788 | 10683 | 28764 | 2066 | - | - |
295
- | h3-conc | 6855 | 6901 | - | 11079 | 29305 | - | - | - |
296
- | h1-download | 658 | 645 | 689 | 793 | 1019 | 264 | 1117 | 658 |
297
- | h2-download | 472 | 497 | 426 | 468 | 479 | 123 | - | - |
298
- | h3-download | 105 | 107 | - | 517 | 492 | - | - | - |
299
- | h1-upload | 633 | 687 | 3687 | 2002 | 2124 | 675 | 749 | 2296 |
300
- | h2-upload | 447 | 480 | 2309 | 1375 | 1440 | 197 | - | - |
301
- | 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 | - | - | - |
302
378
 
303
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 go out in a fresh random order with a GREASE entry, 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,16 +217,92 @@ 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())
227
227
  ```
228
228
 
229
- `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.
229
+ Over HTTP/3 the same holds, with the ClientHello Chrome 155 sends on QUIC: TLS 1.3 only, the `trust_anchors` extension with the 28 identifiers of Chrome's root store, and `pre_shared_key` once a session exists.
230
+
231
+ ```python
232
+ import asyncio
233
+
234
+ from boring_http import Client, Http3Profile
235
+ from ssl2 import (
236
+ CHROME_TRUST_ANCHOR_IDS,
237
+ JA4,
238
+ CertificateCompressionAlgorithm,
239
+ CipherSuite,
240
+ ClientHelloBuilder,
241
+ ExtensionType,
242
+ JA4Transport,
243
+ NamedGroup,
244
+ NextProtocol,
245
+ ProtocolVersion,
246
+ PskKeyExchangeMode,
247
+ SignatureScheme,
248
+ )
249
+
250
+ quic_profile = (
251
+ ClientHelloBuilder()
252
+ .supported_versions(ProtocolVersion.TLS_1_3)
253
+ .cipher_suites(CipherSuite.TLS_AES_128_GCM_SHA256, CipherSuite.TLS_AES_256_GCM_SHA384, CipherSuite.TLS_CHACHA20_POLY1305_SHA256)
254
+ .extensions(
255
+ ExtensionType.signature_algorithms,
256
+ ExtensionType.application_settings,
257
+ ExtensionType.application_layer_protocol_negotiation,
258
+ ExtensionType.compress_certificate,
259
+ ExtensionType.trust_anchors,
260
+ ExtensionType.server_name,
261
+ ExtensionType.psk_key_exchange_modes,
262
+ ExtensionType.key_share,
263
+ ExtensionType.quic_transport_parameters,
264
+ ExtensionType.supported_versions,
265
+ ExtensionType.supported_groups,
266
+ ExtensionType.encrypted_client_hello,
267
+ ExtensionType.pre_shared_key,
268
+ )
269
+ .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
270
+ .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
271
+ .signature_algorithms(
272
+ SignatureScheme.ecdsa_secp256r1_sha256,
273
+ SignatureScheme.rsa_pss_rsae_sha256,
274
+ SignatureScheme.rsa_pkcs1_sha256,
275
+ SignatureScheme.ecdsa_secp384r1_sha384,
276
+ SignatureScheme.rsa_pss_rsae_sha384,
277
+ SignatureScheme.rsa_pkcs1_sha384,
278
+ SignatureScheme.rsa_pss_rsae_sha512,
279
+ SignatureScheme.rsa_pkcs1_sha512,
280
+ SignatureScheme.rsa_pkcs1_sha1,
281
+ )
282
+ .application_protocols(NextProtocol.H3)
283
+ .application_settings(NextProtocol.H3)
284
+ .certificate_compression(CertificateCompressionAlgorithm.brotli)
285
+ .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
286
+ .trust_anchor_ids(*CHROME_TRUST_ANCHOR_IDS)
287
+ .grease()
288
+ .permute_extensions()
289
+ .build()
290
+ )
291
+
292
+
293
+ async def main() -> None:
294
+ async with Client(versions=("h3",), http3=Http3Profile(tls=quic_profile), headers=[("priority", "u=0, i")]) as client:
295
+ _ = await client.get("https://pingly.us.kg/api/all")
296
+ await client.close() # the next request opens a new connection, which resumes the session
297
+ report = (await client.get("https://pingly.us.kg/api/all")).json()
298
+ print(report["tls"]["ja4"] == JA4.of(quic_profile, JA4Transport.QUIC).text, report["tls"]["ja4"]) # True q13d0313h3_55b375c5d22e_b0954bf1abdf
299
+ print(report["http3"]["h3_text"]) # 1:65536;6:262144;7:100;51:1;GREASE|m,a,s,p
300
+
301
+
302
+ asyncio.run(main())
303
+ ```
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_HTTP2` is the default. `http3` is an `Http3Profile` with the HTTP/3 SETTINGS, QUIC transport parameters and QUIC version; `CHROME_HTTP3` is the default.
230
306
 
231
307
  ## WebSocket
232
308
 
@@ -277,17 +353,17 @@ Against a Go server (net/http and quic-go) on loopback, so the server is never t
277
353
 
278
354
  | scenario | boring-http | boring-http+uvloop | wreq | curl_cffi | pycurl | httpx | aiohttp | requests |
279
355
  | ----------- | ----------: | -----------------: | ----: | --------: | -----: | ----: | ------: | -------: |
280
- | h1-seq | 9079 | 13405 | 17190 | 6116 | 28775 | 2171 | 11796 | 3287 |
281
- | h2-seq | 5744 | 5931 | 12384 | 4767 | 15384 | 1808 | - | - |
282
- | h3-seq | 4021 | 4537 | - | 4595 | 12410 | - | - | - |
283
- | h1-conc | 8956 | 12148 | 53500 | 7324 | 31170 | 934 | 23259 | 2361 |
284
- | h2-conc | 14846 | 15584 | 48788 | 10683 | 28764 | 2066 | - | - |
285
- | h3-conc | 6855 | 6901 | - | 11079 | 29305 | - | - | - |
286
- | h1-download | 658 | 645 | 689 | 793 | 1019 | 264 | 1117 | 658 |
287
- | h2-download | 472 | 497 | 426 | 468 | 479 | 123 | - | - |
288
- | h3-download | 105 | 107 | - | 517 | 492 | - | - | - |
289
- | h1-upload | 633 | 687 | 3687 | 2002 | 2124 | 675 | 749 | 2296 |
290
- | h2-upload | 447 | 480 | 2309 | 1375 | 1440 | 197 | - | - |
291
- | 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 | - | - | - |
292
368
 
293
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.0.1"
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.0.1"
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",