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.
- {boring_http-1.1.0 → boring_http-1.2.0}/PKG-INFO +23 -23
- {boring_http-1.1.0 → boring_http-1.2.0}/README.md +20 -20
- {boring_http-1.1.0 → boring_http-1.2.0}/pyproject.toml +9 -6
- {boring_http-1.1.0 → boring_http-1.2.0}/pyproject.toml.orig +6 -3
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/__init__.py +11 -6
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/client.py +57 -34
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/codings.py +0 -2
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/cookies.py +2 -4
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/errors.py +4 -0
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/hpack.py +7 -12
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/hpack_tables.py +2 -4
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/http1.py +12 -6
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/http2.py +101 -89
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/http3.py +8 -12
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/message.py +24 -25
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/pool.py +4 -5
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/qpack.py +4 -6
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/qpack_tables.py +1 -3
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/connection.py +226 -102
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/packet.py +75 -80
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/protocol.py +64 -47
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/recovery.py +28 -21
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/quic/streams.py +23 -39
- boring_http-1.2.0/src/boring_http/revocation.py +56 -0
- boring_http-1.2.0/src/boring_http/timing.py +99 -0
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/websocket.py +3 -4
- boring_http-1.1.0/src/boring_http/timing.py +0 -63
- {boring_http-1.1.0 → boring_http-1.2.0}/src/boring_http/py.typed +0 -0
- {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.
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
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"] ==
|
|
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; `
|
|
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 |
|
|
367
|
-
| h2-seq |
|
|
368
|
-
| h3-seq |
|
|
369
|
-
| h1-conc |
|
|
370
|
-
| h2-conc |
|
|
371
|
-
| h3-conc |
|
|
372
|
-
| h1-download |
|
|
373
|
-
| h2-download |
|
|
374
|
-
| h3-download |
|
|
375
|
-
| h1-upload |
|
|
376
|
-
| h2-upload |
|
|
377
|
-
| h3-upload |
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
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"] ==
|
|
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; `
|
|
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 |
|
|
357
|
-
| h2-seq |
|
|
358
|
-
| h3-seq |
|
|
359
|
-
| h1-conc |
|
|
360
|
-
| h2-conc |
|
|
361
|
-
| h3-conc |
|
|
362
|
-
| h1-download |
|
|
363
|
-
| h2-download |
|
|
364
|
-
| h3-download |
|
|
365
|
-
| h1-upload |
|
|
366
|
-
| h2-upload |
|
|
367
|
-
| h3-upload |
|
|
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.
|
|
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
|
-
[
|
|
16
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
32
|
-
"
|
|
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
|
|
6
|
+
from typing import Self, TypedDict, Unpack
|
|
8
7
|
|
|
9
|
-
from boring_ssl2 import ClientContext
|
|
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
|
-
|
|
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
|
|
39
|
-
from boring_http.http3 import
|
|
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
|
|
59
|
-
ALPN
|
|
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
|
|
64
|
-
USER_AGENT
|
|
65
|
-
DEFAULT_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
|
|
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 =
|
|
100
|
-
http3: Http3Profile =
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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] =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
107
|
+
self._cookies.pop(key, None)
|
|
110
108
|
else:
|
|
111
109
|
self._cookies[key] = cookie
|
|
112
110
|
|