boring-http 1.1.0__tar.gz → 2.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.
Files changed (31) hide show
  1. boring_http-2.0.0/PKG-INFO +523 -0
  2. boring_http-2.0.0/README.md +513 -0
  3. {boring_http-1.1.0 → boring_http-2.0.0}/pyproject.toml +9 -6
  4. {boring_http-1.1.0 → boring_http-2.0.0}/pyproject.toml.orig +6 -3
  5. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/__init__.py +14 -9
  6. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/client.py +71 -38
  7. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/codings.py +0 -2
  8. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/cookies.py +2 -4
  9. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/errors.py +4 -0
  10. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/hpack.py +7 -12
  11. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/hpack_tables.py +2 -4
  12. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/http1.py +12 -6
  13. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/http2.py +114 -123
  14. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/http3.py +24 -40
  15. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/message.py +24 -25
  16. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/pool.py +4 -5
  17. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/qpack.py +4 -6
  18. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/qpack_tables.py +1 -3
  19. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/quic/connection.py +244 -136
  20. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/quic/packet.py +75 -80
  21. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/quic/protocol.py +64 -47
  22. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/quic/recovery.py +28 -21
  23. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/quic/streams.py +23 -39
  24. boring_http-2.0.0/src/boring_http/revocation.py +56 -0
  25. boring_http-2.0.0/src/boring_http/timing.py +99 -0
  26. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/websocket.py +3 -4
  27. boring_http-1.1.0/PKG-INFO +0 -379
  28. boring_http-1.1.0/README.md +0 -369
  29. boring_http-1.1.0/src/boring_http/timing.py +0 -63
  30. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/py.typed +0 -0
  31. {boring_http-1.1.0 → boring_http-2.0.0}/src/boring_http/quic/__init__.py +0 -0
@@ -0,0 +1,523 @@
1
+ Metadata-Version: 2.3
2
+ Name: boring-http
3
+ Version: 2.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>=1.2.0
6
+ Requires-Dist: ssl2>=1.2.0
7
+ Requires-Python: >=3.14
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, Http2Settings
25
+ from ssl2 import AkamaiFingerprint
26
+
27
+ http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
28
+
29
+
30
+ async def main() -> None:
31
+ async with Client(http2=http2, headers=[("User-Agent", "boring-http/0.1"), ("Accept", "application/json")]) as client:
32
+ response = await client.get("https://pingly.us.kg/api/all", params={"page": 2})
33
+ print(response.status, response.version, response.content_type) # 200 h2 application/json
34
+ report = response.json()
35
+ print(report["http_version"], report["user_agent"], report["method"]) # HTTP/2.0 boring-http/0.1 GET
36
+
37
+ response = await client.post("https://httpbingo.org/post", json={"name": "boring"}, headers=[("X-Trace", "abc")])
38
+ echoed = response.json()
39
+ print(echoed["json"], echoed["headers"]["X-Trace"]) # {'name': 'boring'} ['abc']
40
+
41
+
42
+ asyncio.run(main())
43
+ ```
44
+
45
+ Certificates are checked against the system trust store, or against `trust_store=TrustStore.from_file(...)` from `ssl2`; `verify=False` turns the check off for a test server. Revocation is checked against the CRL the certificate names: fetched over HTTP, verified with the issuer from the chain, cached until its next update, and a listed certificate is refused with `RevocationError`. `revocation=Revocation.STRICT` also refuses when no valid CRL can be had, `Revocation.NONE` skips the check, and the default `Revocation.SOFT` lets such a connection through, as browsers do. The check covers the origin's certificate and an HTTPS proxy's own certificate on a plain-HTTP request through it; the proxy's certificate on a TLS tunnel is verified but not checked for revocation. Headers go on the wire in the order given, client headers first and then the request's own. `Host` leads an HTTP/1.1 request; HTTP/2 and HTTP/3 lowercase the names and drop the connection-specific ones, nothing else changes. `params`, `json`, `data`, `content`, `files`, `auth` and `cookies` are accepted by `get`, `post`, `put`, `patch`, `delete`, `head`, `options` and `request`.
46
+
47
+ ## Responses
48
+
49
+ ```python
50
+ import asyncio
51
+
52
+ from boring_http import Client, Http2Settings
53
+ from ssl2 import AkamaiFingerprint
54
+
55
+ http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
56
+
57
+
58
+ async def main() -> None:
59
+ async with Client(http2=http2) as client:
60
+ response = await client.get("https://httpbingo.org/redirect/2")
61
+ print(response.status, response.url.path, [step.status for step in response.history]) # 200 /get [302, 302]
62
+
63
+ response = await client.get("https://httpbingo.org/redirect/1", allow_redirects=False)
64
+ print(response.status, response.is_redirect, response.location.path) # 302 True /get
65
+
66
+ response = await client.get("https://httpbingo.org/cookies/set?flavor=plain", allow_redirects=False)
67
+ print([cookie.name for cookie in response.cookies], client.cookies.header("httpbingo.org", "/", True)) # ['flavor'] flavor=plain
68
+
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ `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`.
74
+
75
+ ## Streaming and timeouts
76
+
77
+ ```python
78
+ import asyncio
79
+
80
+ from boring_http import Client, Http2Settings, Timeout, TimeoutError
81
+ from ssl2 import AkamaiFingerprint
82
+
83
+ http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
84
+
85
+
86
+ async def main() -> None:
87
+ async with Client(http2=http2, timeout=Timeout(connect=5, read=10, write=10, total=30)) as client:
88
+ async with client.stream("GET", "https://httpbingo.org/stream-bytes/100000") as response:
89
+ size = sum([len(chunk) async for chunk in response.aiter_bytes()])
90
+ print(response.status, size) # 200 100000
91
+
92
+ try:
93
+ await client.get("https://httpbingo.org/delay/3", timeout=0.5)
94
+ except TimeoutError as error:
95
+ print(error.phase, error) # total total timed out after 0.5 seconds
96
+
97
+
98
+ asyncio.run(main())
99
+ ```
100
+
101
+ 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.
102
+
103
+ ## HTTP/3
104
+
105
+ ```python
106
+ import asyncio
107
+ from dataclasses import replace
108
+
109
+ from boring_http import Client, Http2Settings, Http3Settings, Parameter, QuicVersion, Setting, TransportParameters
110
+ from ssl2 import AkamaiFingerprint, PseudoHeader
111
+
112
+ http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
113
+ http3 = Http3Settings(
114
+ settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
115
+ pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
116
+ transport=TransportParameters(
117
+ max_idle_timeout=30000,
118
+ max_udp_payload_size=1472,
119
+ initial_max_data=15728640,
120
+ initial_max_stream_data_bidi_local=6291456,
121
+ initial_max_stream_data_bidi_remote=6291456,
122
+ initial_max_stream_data_uni=6291456,
123
+ initial_max_streams_bidi=100,
124
+ initial_max_streams_uni=100,
125
+ order=(
126
+ Parameter.MAX_IDLE_TIMEOUT,
127
+ Parameter.MAX_UDP_PAYLOAD_SIZE,
128
+ Parameter.INITIAL_MAX_DATA,
129
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
130
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
131
+ Parameter.INITIAL_MAX_STREAM_DATA_UNI,
132
+ Parameter.INITIAL_MAX_STREAMS_BIDI,
133
+ Parameter.INITIAL_MAX_STREAMS_UNI,
134
+ Parameter.INITIAL_SOURCE_CONNECTION_ID,
135
+ Parameter.VERSION_INFORMATION,
136
+ ),
137
+ ),
138
+ )
139
+
140
+
141
+ async def main() -> None:
142
+ async with Client(versions=("h3", "h2", "h1"), http2=http2, http3=http3) as client:
143
+ first = await client.get("https://pingly.us.kg/api/all")
144
+ second = await client.get("https://pingly.us.kg/api/all")
145
+ print(first.version, second.version) # h2 h3, the Alt-Svc header taught the client that this origin speaks HTTP/3
146
+
147
+ async with Client(versions=("h3",), http3=replace(http3, quic_version=QuicVersion.V2)) as client:
148
+ response = await client.get("https://pingly.us.kg/api/all")
149
+ print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
150
+
151
+
152
+ asyncio.run(main())
153
+ ```
154
+
155
+ 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 the dynamic table the SETTINGS allow 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. `TransportParameters` sends the parameters in `order`, in that order; `grease=True` adds a GREASE entry, `grease_version=True` a GREASE version, `shuffle=True` a fresh random order on every connection, and `INITIAL_ROUND_TRIP_TIME` in `order` sends the round trip time measured last time on a resumed connection. The control stream carries the SETTINGS of the `Http3Settings`, a GREASE entry and a GREASE frame with `grease=True`, and a PRIORITY_UPDATE for every request that has a `priority` header.
156
+
157
+ ## Proxies
158
+
159
+ ```python
160
+ import asyncio
161
+
162
+ from boring_http import Client, Http2Settings, Http3Settings, Parameter, Setting, TransportParameters
163
+ from ssl2 import AkamaiFingerprint, ProxyError, PseudoHeader
164
+
165
+ http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
166
+ http3 = Http3Settings(
167
+ settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
168
+ pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
169
+ transport=TransportParameters(
170
+ max_idle_timeout=30000,
171
+ max_udp_payload_size=1472,
172
+ initial_max_data=15728640,
173
+ initial_max_stream_data_bidi_local=6291456,
174
+ initial_max_stream_data_bidi_remote=6291456,
175
+ initial_max_stream_data_uni=6291456,
176
+ initial_max_streams_bidi=100,
177
+ initial_max_streams_uni=100,
178
+ order=(
179
+ Parameter.MAX_IDLE_TIMEOUT,
180
+ Parameter.MAX_UDP_PAYLOAD_SIZE,
181
+ Parameter.INITIAL_MAX_DATA,
182
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
183
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
184
+ Parameter.INITIAL_MAX_STREAM_DATA_UNI,
185
+ Parameter.INITIAL_MAX_STREAMS_BIDI,
186
+ Parameter.INITIAL_MAX_STREAMS_UNI,
187
+ Parameter.INITIAL_SOURCE_CONNECTION_ID,
188
+ Parameter.VERSION_INFORMATION,
189
+ ),
190
+ ),
191
+ )
192
+
193
+
194
+ async def main() -> None:
195
+ async with Client(http2=http2, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
196
+ response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 CONNECT, HTTP/2 inside
197
+ print(response.version, response.json()["http_version"]) # h2 HTTP/2.0
198
+ response = await client.get("https://pingly.us.kg/api/all", proxy="http://alice:secret@127.0.0.1:3128") # per request
199
+ print(response.version) # h2
200
+
201
+ async with Client(versions=("h3",), http3=http3, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
202
+ response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 UDP ASSOCIATE, QUIC datagrams through the relay
203
+ print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
204
+
205
+ try:
206
+ async with Client(http2=http2, proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
207
+ await client.get("https://pingly.us.kg/api/all")
208
+ except ProxyError as refusal:
209
+ print(refusal) # the SOCKS5 proxy rejected the username and password
210
+
211
+
212
+ asyncio.run(main())
213
+ ```
214
+
215
+ `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.
216
+
217
+ ## Fingerprints
218
+
219
+ ```python
220
+ import asyncio
221
+
222
+ from boring_http import Client, Http2Settings
223
+ from ssl2 import (
224
+ JA4,
225
+ AkamaiFingerprint,
226
+ CertificateCompressionAlgorithm,
227
+ CipherSuite,
228
+ ClientHelloBuilder,
229
+ ECPointFormat,
230
+ ExtensionType,
231
+ Http2Priority,
232
+ NamedGroup,
233
+ NextProtocol,
234
+ ProtocolVersion,
235
+ PskKeyExchangeMode,
236
+ SignatureScheme,
237
+ )
238
+
239
+ hello = (
240
+ ClientHelloBuilder()
241
+ .supported_versions(ProtocolVersion.TLS_1_3, ProtocolVersion.TLS_1_2)
242
+ .cipher_suites(
243
+ CipherSuite.TLS_AES_128_GCM_SHA256,
244
+ CipherSuite.TLS_AES_256_GCM_SHA384,
245
+ CipherSuite.TLS_CHACHA20_POLY1305_SHA256,
246
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
247
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
248
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
249
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
250
+ CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
251
+ CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
252
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
253
+ CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
254
+ CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256,
255
+ CipherSuite.TLS_RSA_WITH_AES_256_GCM_SHA384,
256
+ CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA,
257
+ CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
258
+ )
259
+ .extensions(
260
+ ExtensionType.server_name,
261
+ ExtensionType.extended_main_secret,
262
+ ExtensionType.renegotiation_info,
263
+ ExtensionType.supported_groups,
264
+ ExtensionType.ec_point_formats,
265
+ ExtensionType.session_ticket,
266
+ ExtensionType.application_layer_protocol_negotiation,
267
+ ExtensionType.status_request,
268
+ ExtensionType.signature_algorithms,
269
+ ExtensionType.signed_certificate_timestamp,
270
+ ExtensionType.key_share,
271
+ ExtensionType.psk_key_exchange_modes,
272
+ ExtensionType.supported_versions,
273
+ ExtensionType.compress_certificate,
274
+ ExtensionType.application_settings,
275
+ ExtensionType.encrypted_client_hello,
276
+ )
277
+ .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
278
+ .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
279
+ .ec_point_formats(ECPointFormat.uncompressed)
280
+ .signature_algorithms(
281
+ SignatureScheme.ecdsa_secp256r1_sha256,
282
+ SignatureScheme.rsa_pss_rsae_sha256,
283
+ SignatureScheme.rsa_pkcs1_sha256,
284
+ SignatureScheme.ecdsa_secp384r1_sha384,
285
+ SignatureScheme.rsa_pss_rsae_sha384,
286
+ SignatureScheme.rsa_pkcs1_sha384,
287
+ SignatureScheme.rsa_pss_rsae_sha512,
288
+ SignatureScheme.rsa_pkcs1_sha512,
289
+ )
290
+ .application_protocols(NextProtocol.H2, NextProtocol.HTTP1)
291
+ .application_settings(NextProtocol.H2)
292
+ .certificate_compression(CertificateCompressionAlgorithm.brotli)
293
+ .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
294
+ .build()
295
+ )
296
+ http2 = Http2Settings(
297
+ AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"),
298
+ headers_priority=Http2Priority(stream_id=0, exclusive=True, dependency=0, weight=256),
299
+ )
300
+
301
+
302
+ async def main() -> None:
303
+ async with Client(tls=hello, http2=http2) as client:
304
+ report = (await client.get("https://pingly.us.kg/api/all")).json()
305
+ print(report["tls"]["ja4"] == JA4.of(hello).text, report["tls"]["ja4"]) # True t13d1516h2_8daaf6152771_d8a2da3f94cd
306
+ print(report["http2"]["akamai_fingerprint"] == http2.fingerprint.text, report["http2"]["akamai_fingerprint"]) # True 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
307
+
308
+
309
+ asyncio.run(main())
310
+ ```
311
+
312
+ 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.
313
+
314
+ ```python
315
+ import asyncio
316
+
317
+ from boring_http import Client, Http3Settings, Parameter, Setting, TransportParameters
318
+ from ssl2 import (
319
+ JA4,
320
+ CertificateCompressionAlgorithm,
321
+ CipherSuite,
322
+ ClientHelloBuilder,
323
+ ExtensionType,
324
+ JA4Transport,
325
+ NamedGroup,
326
+ NextProtocol,
327
+ ProtocolVersion,
328
+ PseudoHeader,
329
+ PskKeyExchangeMode,
330
+ SignatureScheme,
331
+ )
332
+
333
+ quic_hello = (
334
+ ClientHelloBuilder()
335
+ .supported_versions(ProtocolVersion.TLS_1_3)
336
+ .cipher_suites(CipherSuite.TLS_AES_128_GCM_SHA256, CipherSuite.TLS_AES_256_GCM_SHA384, CipherSuite.TLS_CHACHA20_POLY1305_SHA256)
337
+ .extensions(
338
+ ExtensionType.signature_algorithms,
339
+ ExtensionType.application_settings,
340
+ ExtensionType.application_layer_protocol_negotiation,
341
+ ExtensionType.compress_certificate,
342
+ ExtensionType.trust_anchors,
343
+ ExtensionType.server_name,
344
+ ExtensionType.psk_key_exchange_modes,
345
+ ExtensionType.key_share,
346
+ ExtensionType.quic_transport_parameters,
347
+ ExtensionType.supported_versions,
348
+ ExtensionType.supported_groups,
349
+ ExtensionType.encrypted_client_hello,
350
+ ExtensionType.pre_shared_key,
351
+ )
352
+ .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
353
+ .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
354
+ .signature_algorithms(
355
+ SignatureScheme.ecdsa_secp256r1_sha256,
356
+ SignatureScheme.rsa_pss_rsae_sha256,
357
+ SignatureScheme.rsa_pkcs1_sha256,
358
+ SignatureScheme.ecdsa_secp384r1_sha384,
359
+ SignatureScheme.rsa_pss_rsae_sha384,
360
+ SignatureScheme.rsa_pkcs1_sha384,
361
+ SignatureScheme.rsa_pss_rsae_sha512,
362
+ SignatureScheme.rsa_pkcs1_sha512,
363
+ SignatureScheme.rsa_pkcs1_sha1,
364
+ )
365
+ .application_protocols(NextProtocol.H3)
366
+ .application_settings(NextProtocol.H3)
367
+ .certificate_compression(CertificateCompressionAlgorithm.brotli)
368
+ .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
369
+ .trust_anchor_ids(
370
+ "44947.2.1", "44947.2.6", "44947.2.13", "44947.2.14", "44947.2.15", "44947.2.18", "44947.2.19", "44947.2.20",
371
+ "52580.200109.1.7", "52580.200109.1.8", "52580.200109.1.9", "52580.200109.1.10", "52580.200109.1.11", "52580.200109.1.12", "52580.200109.1.13", "52580.200109.1.18", "52580.200109.1.19",
372
+ "11129.9.1", "11129.9.4", "11129.9.5", "11129.9.6", "11129.9.7", "11129.9.8", "11129.9.10", "11129.9.11", "11129.9.12", "11129.9.13", "11129.9.15",
373
+ )
374
+ .grease()
375
+ .permute_extensions()
376
+ .build()
377
+ )
378
+ http3 = Http3Settings(
379
+ settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100), (Setting.H3_DATAGRAM, 1)),
380
+ pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
381
+ transport=TransportParameters(
382
+ max_idle_timeout=30000,
383
+ max_udp_payload_size=1472,
384
+ initial_max_data=15728640,
385
+ initial_max_stream_data_bidi_local=6291456,
386
+ initial_max_stream_data_bidi_remote=6291456,
387
+ initial_max_stream_data_uni=6291456,
388
+ initial_max_streams_bidi=100,
389
+ initial_max_streams_uni=103,
390
+ order=(
391
+ Parameter.MAX_IDLE_TIMEOUT,
392
+ Parameter.MAX_UDP_PAYLOAD_SIZE,
393
+ Parameter.INITIAL_MAX_DATA,
394
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
395
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
396
+ Parameter.INITIAL_MAX_STREAM_DATA_UNI,
397
+ Parameter.INITIAL_MAX_STREAMS_BIDI,
398
+ Parameter.INITIAL_MAX_STREAMS_UNI,
399
+ Parameter.INITIAL_SOURCE_CONNECTION_ID,
400
+ Parameter.VERSION_INFORMATION,
401
+ Parameter.MAX_DATAGRAM_FRAME_SIZE,
402
+ Parameter.GOOGLE_CONNECTION_OPTIONS,
403
+ Parameter.INITIAL_ROUND_TRIP_TIME,
404
+ ),
405
+ max_datagram_frame_size=65536,
406
+ google_connection_options=(b"ORIG",),
407
+ grease=True,
408
+ grease_version=True,
409
+ shuffle=True,
410
+ ),
411
+ tls=quic_hello,
412
+ grease=True,
413
+ )
414
+
415
+
416
+ async def main() -> None:
417
+ async with Client(versions=("h3",), http3=http3, headers=[("priority", "u=0, i")]) as client:
418
+ _ = await client.get("https://pingly.us.kg/api/all")
419
+ await client.close() # the next request opens a new connection, which resumes the session
420
+ report = (await client.get("https://pingly.us.kg/api/all")).json()
421
+ print(report["tls"]["ja4"] == JA4.of(quic_hello, JA4Transport.QUIC).text, report["tls"]["ja4"]) # True q13d0313h3_55b375c5d22e_b0954bf1abdf
422
+ print(report["http3"]["h3_text"]) # 1:65536;6:262144;7:100;51:1;GREASE|m,a,s,p
423
+
424
+
425
+ asyncio.run(main())
426
+ ```
427
+
428
+ `tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Settings` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order. `http3` is an `Http3Settings` with the HTTP/3 SETTINGS, pseudo-header order, QUIC transport parameters and QUIC version. Neither has a default: `h2` in `versions` or in the ClientHello needs `http2`, and `h3` in `versions` needs `http3`.
429
+
430
+ ## WebSocket
431
+
432
+ ```python
433
+ import asyncio
434
+
435
+ from boring_http import Client, Http2Settings
436
+ from ssl2 import AkamaiFingerprint
437
+
438
+ http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
439
+
440
+
441
+ async def main() -> None:
442
+ async with Client(http2=http2) as client:
443
+ websocket = await client.websocket("wss://echo.websocket.org/")
444
+ print((await websocket.receive())[:17]) # Request served by
445
+ await websocket.send("hello")
446
+ print(await websocket.receive()) # hello
447
+ await websocket.send(b"\x00\x01")
448
+ print(await websocket.receive()) # b'\x00\x01'
449
+ await websocket.close()
450
+ print(websocket.close_code) # 1000
451
+
452
+
453
+ asyncio.run(main())
454
+ ```
455
+
456
+ 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.
457
+
458
+ ## uvloop
459
+
460
+ ```python
461
+ import uvloop
462
+
463
+ from boring_http import Client, Http3Settings, Parameter, Setting, TransportParameters
464
+ from ssl2 import PseudoHeader
465
+
466
+ http3 = Http3Settings(
467
+ settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
468
+ pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
469
+ transport=TransportParameters(
470
+ max_idle_timeout=30000,
471
+ max_udp_payload_size=1472,
472
+ initial_max_data=15728640,
473
+ initial_max_stream_data_bidi_local=6291456,
474
+ initial_max_stream_data_bidi_remote=6291456,
475
+ initial_max_stream_data_uni=6291456,
476
+ initial_max_streams_bidi=100,
477
+ initial_max_streams_uni=100,
478
+ order=(
479
+ Parameter.MAX_IDLE_TIMEOUT,
480
+ Parameter.MAX_UDP_PAYLOAD_SIZE,
481
+ Parameter.INITIAL_MAX_DATA,
482
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
483
+ Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
484
+ Parameter.INITIAL_MAX_STREAM_DATA_UNI,
485
+ Parameter.INITIAL_MAX_STREAMS_BIDI,
486
+ Parameter.INITIAL_MAX_STREAMS_UNI,
487
+ Parameter.INITIAL_SOURCE_CONNECTION_ID,
488
+ Parameter.VERSION_INFORMATION,
489
+ ),
490
+ ),
491
+ )
492
+
493
+
494
+ async def main() -> None:
495
+ async with Client(versions=("h3",), http3=http3) as client:
496
+ print((await client.get("https://pingly.us.kg/api/all")).version) # h3
497
+
498
+
499
+ uvloop.run(main())
500
+ ```
501
+
502
+ Every path, QUIC included, runs unchanged on uvloop.
503
+
504
+ ## Performance
505
+
506
+ 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.
507
+
508
+ | scenario | boring-http | boring-http+uvloop | wreq | curl_cffi | pycurl | httpx | aiohttp | requests |
509
+ | ----------- | ----------: | -----------------: | ----: | --------: | -----: | ----: | ------: | -------: |
510
+ | h1-seq | 14713 | 14354 | 20116 | 6042 | 29137 | 2248 | 11831 | 3539 |
511
+ | h2-seq | 7307 | 7359 | 13404 | 5096 | 16023 | 1880 | - | - |
512
+ | h3-seq | 4679 | 4308 | - | 4725 | 14041 | - | - | - |
513
+ | h1-conc | 11594 | 12391 | 61212 | 7443 | 33861 | 939 | 24836 | 2438 |
514
+ | h2-conc | 19234 | 20085 | 41075 | 11354 | 30053 | 2164 | - | - |
515
+ | h3-conc | 10121 | 9788 | - | 11447 | 29598 | - | - | - |
516
+ | h1-download | 734 | 772 | 823 | 767 | 1229 | 257 | 1081 | 594 |
517
+ | h2-download | 319 | 303 | 443 | 466 | 484 | 120 | - | - |
518
+ | h3-download | 199 | 207 | - | 542 | 491 | - | - | - |
519
+ | h1-upload | 2513 | 3093 | 3276 | 2106 | 1998 | 694 | 713 | 2376 |
520
+ | h2-upload | 786 | 839 | 2206 | 1085 | 1323 | 186 | - | - |
521
+ | h3-upload | 132 | 132 | - | 819 | 434 | - | - | - |
522
+
523
+ 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.