boring-http 1.0.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.
@@ -0,0 +1,303 @@
1
+ Metadata-Version: 2.3
2
+ Name: boring-http
3
+ Version: 1.0.0
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
7
+ Requires-Python: >=3.14.8
8
+ Project-URL: Repository, https://github.com/baobien-dev/boring-http
9
+ Description-Content-Type: text/markdown
10
+
11
+ # boring-http
12
+
13
+ An asyncio HTTP client on BoringSSL through `ssl2` and `boring-ssl2`. HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers sent in the order you give them, cookies, redirects, phased timeouts, gzip, deflate, brotli and zstd decoding. Runs on asyncio and on uvloop. Requires Python 3.14.
14
+
15
+ ```text
16
+ uv add boring-http
17
+ ```
18
+
19
+ ## Requests
20
+
21
+ ```python
22
+ import asyncio
23
+
24
+ from boring_http import Client
25
+
26
+
27
+ async def main() -> None:
28
+ async with Client(headers=[("User-Agent", "boring-http/0.1"), ("Accept", "application/json")]) as client:
29
+ response = await client.get("https://pingly.us.kg/api/all", params={"page": 2})
30
+ print(response.status, response.version, response.content_type) # 200 h2 application/json
31
+ report = response.json()
32
+ print(report["http_version"], report["user_agent"], report["method"]) # HTTP/2.0 boring-http/0.1 GET
33
+
34
+ response = await client.post("https://httpbingo.org/post", json={"name": "boring"}, headers=[("X-Trace", "abc")])
35
+ echoed = response.json()
36
+ print(echoed["json"], echoed["headers"]["X-Trace"]) # {'name': 'boring'} ['abc']
37
+
38
+
39
+ asyncio.run(main())
40
+ ```
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`.
43
+
44
+ ## Responses
45
+
46
+ ```python
47
+ import asyncio
48
+
49
+ from boring_http import Client
50
+
51
+
52
+ async def main() -> None:
53
+ async with Client() as client:
54
+ response = await client.get("https://httpbingo.org/redirect/2")
55
+ print(response.status, response.url.path, [step.status for step in response.history]) # 200 /get [302, 302]
56
+
57
+ response = await client.get("https://httpbingo.org/redirect/1", allow_redirects=False)
58
+ print(response.status, response.is_redirect, response.location.path) # 302 True /get
59
+
60
+ response = await client.get("https://httpbingo.org/cookies/set?flavor=plain", allow_redirects=False)
61
+ print([cookie.name for cookie in response.cookies], client.cookies.header("httpbingo.org", "/", True)) # ['flavor'] flavor=plain
62
+
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ `response.cookies` holds only the cookies this response set; `client.cookies` is the jar that follows every request. `location`, `content_type`, `charset`, `content_length`, `content_encoding`, `retry_after`, `etag`, `last_modified` and `date` are parsed, `response.headers` keeps the raw fields in order, `raise_for_status()` raises `HTTPStatusError`.
68
+
69
+ ## Streaming and timeouts
70
+
71
+ ```python
72
+ import asyncio
73
+
74
+ from boring_http import Client, Timeout, TimeoutError
75
+
76
+
77
+ async def main() -> None:
78
+ async with Client(timeout=Timeout(connect=5, read=10, write=10, total=30)) as client:
79
+ async with client.stream("GET", "https://httpbingo.org/stream-bytes/100000") as response:
80
+ size = sum([len(chunk) async for chunk in response.aiter_bytes()])
81
+ print(response.status, size) # 200 100000
82
+
83
+ try:
84
+ await client.get("https://httpbingo.org/delay/3", timeout=0.5)
85
+ except TimeoutError as error:
86
+ print(error.phase, error) # total total timed out after 0.5 seconds
87
+
88
+
89
+ asyncio.run(main())
90
+ ```
91
+
92
+ A single number sets every phase and the total, so the request gives up after that many seconds whatever it was doing. With a `Timeout` the phases are separate: `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
93
+
94
+ ## HTTP/3
95
+
96
+ ```python
97
+ import asyncio
98
+
99
+ from boring_http import QUIC_V2, Client, Http3Profile
100
+
101
+
102
+ async def main() -> None:
103
+ async with Client(versions=("h3", "h2", "h1")) as client:
104
+ first = await client.get("https://pingly.us.kg/api/all")
105
+ second = await client.get("https://pingly.us.kg/api/all")
106
+ print(first.version, second.version) # h2 h3, the Alt-Svc header taught the client that this origin speaks HTTP/3
107
+
108
+ async with Client(versions=("h3",), http3=Http3Profile(quic_version=QUIC_V2)) as client:
109
+ response = await client.get("https://pingly.us.kg/api/all")
110
+ print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
111
+
112
+
113
+ asyncio.run(main())
114
+ ```
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.
117
+
118
+ ## Proxies
119
+
120
+ ```python
121
+ import asyncio
122
+
123
+ from boring_http import Client
124
+ from ssl2 import ProxyError
125
+
126
+
127
+ async def main() -> None:
128
+ async with Client(proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
129
+ response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 CONNECT, HTTP/2 inside
130
+ print(response.version, response.json()["http_version"]) # h2 HTTP/2.0
131
+ response = await client.get("https://pingly.us.kg/api/all", proxy="http://alice:secret@127.0.0.1:3128") # per request
132
+ print(response.version) # h2
133
+
134
+ async with Client(versions=("h3",), proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
135
+ response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 UDP ASSOCIATE, QUIC datagrams through the relay
136
+ print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
137
+
138
+ try:
139
+ async with Client(proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
140
+ await client.get("https://pingly.us.kg/api/all")
141
+ except ProxyError as refusal:
142
+ print(refusal) # the SOCKS5 proxy rejected the username and password
143
+
144
+
145
+ asyncio.run(main())
146
+ ```
147
+
148
+ `http://`, `https://`, `socks4://`, `socks4a://`, `socks5://` and `socks5h://` proxies, with credentials in the URL. HTTP and SOCKS proxies carry HTTP/1.1 and HTTP/2; SOCKS5 also carries HTTP/3. `proxy=None` on a request bypasses the client's proxy.
149
+
150
+ ## Fingerprints
151
+
152
+ ```python
153
+ import asyncio
154
+
155
+ from boring_http import CHROME, Client
156
+ from ssl2 import (
157
+ JA4,
158
+ CertificateCompressionAlgorithm,
159
+ CipherSuite,
160
+ ClientHelloBuilder,
161
+ ECPointFormat,
162
+ ExtensionType,
163
+ NamedGroup,
164
+ NextProtocol,
165
+ ProtocolVersion,
166
+ PskKeyExchangeMode,
167
+ SignatureScheme,
168
+ )
169
+
170
+ profile = (
171
+ ClientHelloBuilder()
172
+ .supported_versions(ProtocolVersion.TLS_1_3, ProtocolVersion.TLS_1_2)
173
+ .cipher_suites(
174
+ CipherSuite.TLS_AES_128_GCM_SHA256,
175
+ CipherSuite.TLS_AES_256_GCM_SHA384,
176
+ CipherSuite.TLS_CHACHA20_POLY1305_SHA256,
177
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
178
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
179
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
180
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
181
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
182
+ CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
183
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
184
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
185
+ CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256,
186
+ CipherSuite.TLS_RSA_WITH_AES_256_GCM_SHA384,
187
+ CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA,
188
+ CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
189
+ )
190
+ .extensions(
191
+ ExtensionType.server_name,
192
+ ExtensionType.extended_main_secret,
193
+ ExtensionType.renegotiation_info,
194
+ ExtensionType.supported_groups,
195
+ ExtensionType.ec_point_formats,
196
+ ExtensionType.session_ticket,
197
+ ExtensionType.application_layer_protocol_negotiation,
198
+ ExtensionType.status_request,
199
+ ExtensionType.signature_algorithms,
200
+ ExtensionType.signed_certificate_timestamp,
201
+ ExtensionType.key_share,
202
+ ExtensionType.psk_key_exchange_modes,
203
+ ExtensionType.supported_versions,
204
+ ExtensionType.compress_certificate,
205
+ ExtensionType.application_settings,
206
+ ExtensionType.encrypted_client_hello,
207
+ )
208
+ .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
209
+ .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
210
+ .ec_point_formats(ECPointFormat.uncompressed)
211
+ .signature_algorithms(
212
+ SignatureScheme.ecdsa_secp256r1_sha256,
213
+ SignatureScheme.rsa_pss_rsae_sha256,
214
+ SignatureScheme.rsa_pkcs1_sha256,
215
+ SignatureScheme.ecdsa_secp384r1_sha384,
216
+ SignatureScheme.rsa_pss_rsae_sha384,
217
+ SignatureScheme.rsa_pkcs1_sha384,
218
+ SignatureScheme.rsa_pss_rsae_sha512,
219
+ SignatureScheme.rsa_pkcs1_sha512,
220
+ )
221
+ .application_protocols(NextProtocol.H2, NextProtocol.HTTP1)
222
+ .application_settings(NextProtocol.H2)
223
+ .certificate_compression(CertificateCompressionAlgorithm.brotli)
224
+ .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
225
+ .build()
226
+ )
227
+
228
+
229
+ async def main() -> None:
230
+ async with Client(tls=profile, http2=CHROME) as client:
231
+ report = (await client.get("https://pingly.us.kg/api/all")).json()
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
234
+
235
+
236
+ asyncio.run(main())
237
+ ```
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.
240
+
241
+ ## WebSocket
242
+
243
+ ```python
244
+ import asyncio
245
+
246
+ from boring_http import Client
247
+
248
+
249
+ async def main() -> None:
250
+ async with Client() as client:
251
+ websocket = await client.websocket("wss://echo.websocket.org/")
252
+ print((await websocket.receive())[:17]) # Request served by
253
+ await websocket.send("hello")
254
+ print(await websocket.receive()) # hello
255
+ await websocket.send(b"\x00\x01")
256
+ print(await websocket.receive()) # b'\x00\x01'
257
+ await websocket.close()
258
+ print(websocket.close_code) # 1000
259
+
260
+
261
+ asyncio.run(main())
262
+ ```
263
+
264
+ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen.
265
+
266
+ ## uvloop
267
+
268
+ ```python
269
+ import uvloop
270
+
271
+ from boring_http import Client
272
+
273
+
274
+ async def main() -> None:
275
+ async with Client(versions=("h3",)) as client:
276
+ print((await client.get("https://pingly.us.kg/api/all")).version) # h3
277
+
278
+
279
+ uvloop.run(main())
280
+ ```
281
+
282
+ Every path, QUIC included, runs unchanged on uvloop.
283
+
284
+ ## Performance
285
+
286
+ Against a Go server (net/http and quic-go) on loopback, so the server is never the limit: 1000 requests for an 86-byte JSON body, sequentially on one connection and with 32 in flight, then one 50 MB download and one 20 MB upload per protocol. The first six rows are requests per second, the rest MB/s, `-` means the client does not speak that protocol. Python 3.14.8 on Linux, one process per measurement.
287
+
288
+ | scenario | boring-http | boring-http+uvloop | wreq | curl_cffi | pycurl | httpx | aiohttp | requests |
289
+ | ----------- | ----------: | -----------------: | ----: | --------: | -----: | ----: | ------: | -------: |
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 | - | - | - |
302
+
303
+ 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.
@@ -0,0 +1,293 @@
1
+ # boring-http
2
+
3
+ An asyncio HTTP client on BoringSSL through `ssl2` and `boring-ssl2`. HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers sent in the order you give them, cookies, redirects, phased timeouts, gzip, deflate, brotli and zstd decoding. Runs on asyncio and on uvloop. Requires Python 3.14.
4
+
5
+ ```text
6
+ uv add boring-http
7
+ ```
8
+
9
+ ## Requests
10
+
11
+ ```python
12
+ import asyncio
13
+
14
+ from boring_http import Client
15
+
16
+
17
+ async def main() -> None:
18
+ async with Client(headers=[("User-Agent", "boring-http/0.1"), ("Accept", "application/json")]) as client:
19
+ response = await client.get("https://pingly.us.kg/api/all", params={"page": 2})
20
+ print(response.status, response.version, response.content_type) # 200 h2 application/json
21
+ report = response.json()
22
+ print(report["http_version"], report["user_agent"], report["method"]) # HTTP/2.0 boring-http/0.1 GET
23
+
24
+ response = await client.post("https://httpbingo.org/post", json={"name": "boring"}, headers=[("X-Trace", "abc")])
25
+ echoed = response.json()
26
+ print(echoed["json"], echoed["headers"]["X-Trace"]) # {'name': 'boring'} ['abc']
27
+
28
+
29
+ asyncio.run(main())
30
+ ```
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`.
33
+
34
+ ## Responses
35
+
36
+ ```python
37
+ import asyncio
38
+
39
+ from boring_http import Client
40
+
41
+
42
+ async def main() -> None:
43
+ async with Client() as client:
44
+ response = await client.get("https://httpbingo.org/redirect/2")
45
+ print(response.status, response.url.path, [step.status for step in response.history]) # 200 /get [302, 302]
46
+
47
+ response = await client.get("https://httpbingo.org/redirect/1", allow_redirects=False)
48
+ print(response.status, response.is_redirect, response.location.path) # 302 True /get
49
+
50
+ response = await client.get("https://httpbingo.org/cookies/set?flavor=plain", allow_redirects=False)
51
+ print([cookie.name for cookie in response.cookies], client.cookies.header("httpbingo.org", "/", True)) # ['flavor'] flavor=plain
52
+
53
+
54
+ asyncio.run(main())
55
+ ```
56
+
57
+ `response.cookies` holds only the cookies this response set; `client.cookies` is the jar that follows every request. `location`, `content_type`, `charset`, `content_length`, `content_encoding`, `retry_after`, `etag`, `last_modified` and `date` are parsed, `response.headers` keeps the raw fields in order, `raise_for_status()` raises `HTTPStatusError`.
58
+
59
+ ## Streaming and timeouts
60
+
61
+ ```python
62
+ import asyncio
63
+
64
+ from boring_http import Client, Timeout, TimeoutError
65
+
66
+
67
+ async def main() -> None:
68
+ async with Client(timeout=Timeout(connect=5, read=10, write=10, total=30)) as client:
69
+ async with client.stream("GET", "https://httpbingo.org/stream-bytes/100000") as response:
70
+ size = sum([len(chunk) async for chunk in response.aiter_bytes()])
71
+ print(response.status, size) # 200 100000
72
+
73
+ try:
74
+ await client.get("https://httpbingo.org/delay/3", timeout=0.5)
75
+ except TimeoutError as error:
76
+ print(error.phase, error) # total total timed out after 0.5 seconds
77
+
78
+
79
+ asyncio.run(main())
80
+ ```
81
+
82
+ A single number sets every phase and the total, so the request gives up after that many seconds whatever it was doing. With a `Timeout` the phases are separate: `read` is the longest wait for the next bytes, `total` caps the whole exchange including redirects, and the error names whichever ran out.
83
+
84
+ ## HTTP/3
85
+
86
+ ```python
87
+ import asyncio
88
+
89
+ from boring_http import QUIC_V2, Client, Http3Profile
90
+
91
+
92
+ async def main() -> None:
93
+ async with Client(versions=("h3", "h2", "h1")) as client:
94
+ first = await client.get("https://pingly.us.kg/api/all")
95
+ second = await client.get("https://pingly.us.kg/api/all")
96
+ print(first.version, second.version) # h2 h3, the Alt-Svc header taught the client that this origin speaks HTTP/3
97
+
98
+ async with Client(versions=("h3",), http3=Http3Profile(quic_version=QUIC_V2)) as client:
99
+ response = await client.get("https://pingly.us.kg/api/all")
100
+ print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
101
+
102
+
103
+ asyncio.run(main())
104
+ ```
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.
107
+
108
+ ## Proxies
109
+
110
+ ```python
111
+ import asyncio
112
+
113
+ from boring_http import Client
114
+ from ssl2 import ProxyError
115
+
116
+
117
+ async def main() -> None:
118
+ async with Client(proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
119
+ response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 CONNECT, HTTP/2 inside
120
+ print(response.version, response.json()["http_version"]) # h2 HTTP/2.0
121
+ response = await client.get("https://pingly.us.kg/api/all", proxy="http://alice:secret@127.0.0.1:3128") # per request
122
+ print(response.version) # h2
123
+
124
+ async with Client(versions=("h3",), proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
125
+ response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 UDP ASSOCIATE, QUIC datagrams through the relay
126
+ print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
127
+
128
+ try:
129
+ async with Client(proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
130
+ await client.get("https://pingly.us.kg/api/all")
131
+ except ProxyError as refusal:
132
+ print(refusal) # the SOCKS5 proxy rejected the username and password
133
+
134
+
135
+ asyncio.run(main())
136
+ ```
137
+
138
+ `http://`, `https://`, `socks4://`, `socks4a://`, `socks5://` and `socks5h://` proxies, with credentials in the URL. HTTP and SOCKS proxies carry HTTP/1.1 and HTTP/2; SOCKS5 also carries HTTP/3. `proxy=None` on a request bypasses the client's proxy.
139
+
140
+ ## Fingerprints
141
+
142
+ ```python
143
+ import asyncio
144
+
145
+ from boring_http import CHROME, Client
146
+ from ssl2 import (
147
+ JA4,
148
+ CertificateCompressionAlgorithm,
149
+ CipherSuite,
150
+ ClientHelloBuilder,
151
+ ECPointFormat,
152
+ ExtensionType,
153
+ NamedGroup,
154
+ NextProtocol,
155
+ ProtocolVersion,
156
+ PskKeyExchangeMode,
157
+ SignatureScheme,
158
+ )
159
+
160
+ profile = (
161
+ ClientHelloBuilder()
162
+ .supported_versions(ProtocolVersion.TLS_1_3, ProtocolVersion.TLS_1_2)
163
+ .cipher_suites(
164
+ CipherSuite.TLS_AES_128_GCM_SHA256,
165
+ CipherSuite.TLS_AES_256_GCM_SHA384,
166
+ CipherSuite.TLS_CHACHA20_POLY1305_SHA256,
167
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
168
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
169
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
170
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
171
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
172
+ CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
173
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
174
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
175
+ CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256,
176
+ CipherSuite.TLS_RSA_WITH_AES_256_GCM_SHA384,
177
+ CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA,
178
+ CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
179
+ )
180
+ .extensions(
181
+ ExtensionType.server_name,
182
+ ExtensionType.extended_main_secret,
183
+ ExtensionType.renegotiation_info,
184
+ ExtensionType.supported_groups,
185
+ ExtensionType.ec_point_formats,
186
+ ExtensionType.session_ticket,
187
+ ExtensionType.application_layer_protocol_negotiation,
188
+ ExtensionType.status_request,
189
+ ExtensionType.signature_algorithms,
190
+ ExtensionType.signed_certificate_timestamp,
191
+ ExtensionType.key_share,
192
+ ExtensionType.psk_key_exchange_modes,
193
+ ExtensionType.supported_versions,
194
+ ExtensionType.compress_certificate,
195
+ ExtensionType.application_settings,
196
+ ExtensionType.encrypted_client_hello,
197
+ )
198
+ .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
199
+ .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
200
+ .ec_point_formats(ECPointFormat.uncompressed)
201
+ .signature_algorithms(
202
+ SignatureScheme.ecdsa_secp256r1_sha256,
203
+ SignatureScheme.rsa_pss_rsae_sha256,
204
+ SignatureScheme.rsa_pkcs1_sha256,
205
+ SignatureScheme.ecdsa_secp384r1_sha384,
206
+ SignatureScheme.rsa_pss_rsae_sha384,
207
+ SignatureScheme.rsa_pkcs1_sha384,
208
+ SignatureScheme.rsa_pss_rsae_sha512,
209
+ SignatureScheme.rsa_pkcs1_sha512,
210
+ )
211
+ .application_protocols(NextProtocol.H2, NextProtocol.HTTP1)
212
+ .application_settings(NextProtocol.H2)
213
+ .certificate_compression(CertificateCompressionAlgorithm.brotli)
214
+ .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
215
+ .build()
216
+ )
217
+
218
+
219
+ async def main() -> None:
220
+ async with Client(tls=profile, http2=CHROME) as client:
221
+ report = (await client.get("https://pingly.us.kg/api/all")).json()
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
224
+
225
+
226
+ asyncio.run(main())
227
+ ```
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.
230
+
231
+ ## WebSocket
232
+
233
+ ```python
234
+ import asyncio
235
+
236
+ from boring_http import Client
237
+
238
+
239
+ async def main() -> None:
240
+ async with Client() as client:
241
+ websocket = await client.websocket("wss://echo.websocket.org/")
242
+ print((await websocket.receive())[:17]) # Request served by
243
+ await websocket.send("hello")
244
+ print(await websocket.receive()) # hello
245
+ await websocket.send(b"\x00\x01")
246
+ print(await websocket.receive()) # b'\x00\x01'
247
+ await websocket.close()
248
+ print(websocket.close_code) # 1000
249
+
250
+
251
+ asyncio.run(main())
252
+ ```
253
+
254
+ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, otherwise the client opens an HTTP/1.1 connection for it. `subprotocols=[...]` offers subprotocols; `websocket.subprotocol` is the one chosen.
255
+
256
+ ## uvloop
257
+
258
+ ```python
259
+ import uvloop
260
+
261
+ from boring_http import Client
262
+
263
+
264
+ async def main() -> None:
265
+ async with Client(versions=("h3",)) as client:
266
+ print((await client.get("https://pingly.us.kg/api/all")).version) # h3
267
+
268
+
269
+ uvloop.run(main())
270
+ ```
271
+
272
+ Every path, QUIC included, runs unchanged on uvloop.
273
+
274
+ ## Performance
275
+
276
+ Against a Go server (net/http and quic-go) on loopback, so the server is never the limit: 1000 requests for an 86-byte JSON body, sequentially on one connection and with 32 in flight, then one 50 MB download and one 20 MB upload per protocol. The first six rows are requests per second, the rest MB/s, `-` means the client does not speak that protocol. Python 3.14.8 on Linux, one process per measurement.
277
+
278
+ | scenario | boring-http | boring-http+uvloop | wreq | curl_cffi | pycurl | httpx | aiohttp | requests |
279
+ | ----------- | ----------: | -----------------: | ----: | --------: | -----: | ----: | ------: | -------: |
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 | - | - | - |
292
+
293
+ 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.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "boring-http"
3
+ version = "1.0.0"
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
+ readme = "README.md"
6
+ requires-python = ">=3.14.8"
7
+ dependencies = [
8
+ "boring-ssl2",
9
+ "ssl2",
10
+ ]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/baobien-dev/boring-http"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.13.0,<0.14.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [tool.uv.sources.ssl2]
20
+ path = "../ssl2"
21
+ editable = true
22
+
23
+ [tool.uv.sources.boring-ssl2]
24
+ path = "../boring-ssl2"
25
+ editable = true
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "boring-http"
3
+ version = "1.0.0"
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
+ readme = "README.md"
6
+ requires-python = ">=3.14.8"
7
+ dependencies = [
8
+ "boring-ssl2",
9
+ "ssl2",
10
+ ]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/baobien-dev/boring-http"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.13.0,<0.14.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [tool.uv.sources]
20
+ ssl2 = { path = "../ssl2", editable = true }
21
+ boring-ssl2 = { path = "../boring-ssl2", editable = true }