Skip to main content

boring-http

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, 0-RTT, headers on the wire in the order you give them, cookies, redirects, timeouts per phase, gzip, deflate, brotli and zstd. Runs on asyncio and on uvloop. Python 3.14.

uv add boring-http

Requests

import asyncio

from boring_http import Client, Http2Profile
from ssl2 import AkamaiFingerprint

http2 = Http2Profile(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))


async def main() -> None:
    async with Client(http2=http2, headers=[("User-Agent", "boring-http/3"), ("Accept", "application/json")]) as client:
        response = await client.get("https://pingly.us.kg/api/all", params={"page": 2})
        print(response.status, response.version, response.content_type)  # 200 h2 application/json
        report = response.json()
        print(report["http_version"], report["user_agent"], report["method"])  # HTTP/2.0 boring-http/3 GET

        response = await client.post("https://httpbingo.org/post", json={"name": "boring"}, headers=[("X-Trace", "abc")])
        echoed = response.json()
        print(echoed["json"], echoed["headers"]["X-Trace"])  # {'name': 'boring'} ['abc']


asyncio.run(main())

get, post, put, patch, delete, head, options and request take params, headers, json, data, content, files, auth, cookies, timeout, allow_redirects and proxy. Headers go out in the order given, the client's first and then the request's own; Host leads an HTTP/1.1 request, HTTP/2 and HTTP/3 lowercase the names and leave out the connection-specific ones. Nothing else is added or moved.

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. The certificate's CRL is fetched over HTTP, checked against the issuer in the chain and kept until its next update; a listed certificate is refused with RevocationError. The default revocation=Revocation.SOFT lets a connection through when no valid CRL can be had, as browsers do, Revocation.STRICT refuses it and Revocation.NONE skips the check. certificate_chain=SigningChain(...) presents a client certificate, over TCP and QUIC. check_peer= is awaited with the peer's chain in DER, leaf first, and its authority; raising from it refuses the connection, which is where a key pin goes. key_log="keys.txt" appends every TLS and QUIC secret in the NSS key log format, for Wireshark. resolve={("example.com", 443): ("203.0.113.7", 443)} sends a direct connection to that address while SNI and verification keep the name.

Connections are pooled per origin and proxy, under max_connections and max_connections_per_host. One idle for max_idle_time seconds, 90 by default, is closed instead of reused; None keeps it until the server closes it. A request that a reused connection or a refused stream fails before any response is sent once more.

Responses

import asyncio

from boring_http import Client, Http2Profile
from ssl2 import AkamaiFingerprint

http2 = Http2Profile(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))


async def main() -> None:
    async with Client(http2=http2) as client:
        response = await client.get("https://httpbingo.org/redirect/2")
        print(response.status, response.url.path, [step.status for step in response.history])  # 200 /get [302, 302]

        response = await client.get("https://httpbingo.org/redirect/1", allow_redirects=False)
        print(response.status, response.is_redirect, response.location.path)  # 302 True /get

        response = await client.get("https://httpbingo.org/cookies/set?flavor=plain", allow_redirects=False)
        print([cookie.name for cookie in response.cookies], client.cookies.header("httpbingo.org", "/", secure=True))  # ['flavor'] flavor=plain

        info = response.connection
        print(info.tls_version, info.reused, sorted(dict(info)))  # TLSVersion.TLSv1_3 True ['cipher', 'connect_time', 'local_address', 'peer_chain', 'remote_address', 'reused', 'tls_version']


asyncio.run(main())

response.cookies holds what this response set; client.cookies is the jar that follows every request, and refuses a Domain that is a public suffix such as co.uk. 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. response.connection is a ConnectionInfo: the addresses, the TLS version, cipher and peer chain, whether the connection was reused and how long opening it took. response.elapsed is the seconds from sending the request to its last body byte. Every such object prints as Name(field=...) and dict() takes it apart.

Streaming, timeouts and tracing

import asyncio

from boring_http import Client, Http2Profile, RequestTimeoutError, Timeout
from ssl2 import AkamaiFingerprint

http2 = Http2Profile(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))


async def main() -> None:
    async with Client(http2=http2, timeout=Timeout(connect=5, read=10, write=10, total=30), trace=True) as client:
        async with client.stream("GET", "https://httpbingo.org/stream-bytes/100000") as response:
            size = sum([len(chunk) async for chunk in response.aiter_bytes()])
        print(response.status, size)  # 200 100000
        print(response.trace)  # connect 29.7 ms, handshake 34.5 ms, pool 65.1 ms, send 0.0 ms, headers 39.0 ms, body 12.3 ms

        try:
            await client.get("https://httpbingo.org/delay/3", timeout=0.5)
        except RequestTimeoutError as error:
            print(error.phase, error)  # total total timed out after 0.5 seconds


asyncio.run(main())

One number limits every phase and the whole request. A Timeout keeps them apart: connect covers opening the connection with its proxy and handshake, read is the longest wait for the next bytes, write the longest wait for the connection to take the next chunk of a body, total caps the exchange with its redirects. The error names the phase that ran out.

With trace=True every response carries a Trace with the timed steps of its request: the connect, proxy and handshake spans from ssl2, then the wait for a pooled connection, the send, the response head and the body. The boring_http logger reports one line per connection and request at INFO, such as response.headers status=200 version=h2 stream=1 seconds=0.039; DEBUG adds the requests sent and the packets QUIC lost.

A body is held to what is read: over HTTP/2 and HTTP/3 the stream window opens again as chunks are taken, so a response nobody reads stops at one window while the connection window keeps the other streams going. An upload keeps at most 1 MiB unsent in a QUIC stream before the next chunk is taken from the body.

HTTP/3

import asyncio
from dataclasses import replace

from boring_http import Client, Http2Profile, Http3Profile, Http3Setting, Parameter, QuicVersion, TransportParameters
from ssl2 import AkamaiFingerprint, PseudoHeader

http2 = Http2Profile(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
http3 = Http3Profile(
    settings=((Http3Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Http3Setting.MAX_FIELD_SECTION_SIZE, 262144), (Http3Setting.QPACK_BLOCKED_STREAMS, 100)),
    pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
    transport=TransportParameters(
        max_idle_timeout=30000,
        max_udp_payload_size=1472,
        initial_max_data=15728640,
        initial_max_stream_data_bidi_local=6291456,
        initial_max_stream_data_bidi_remote=6291456,
        initial_max_stream_data_uni=6291456,
        initial_max_streams_bidi=100,
        initial_max_streams_uni=100,
        order=(
            Parameter.MAX_IDLE_TIMEOUT,
            Parameter.MAX_UDP_PAYLOAD_SIZE,
            Parameter.INITIAL_MAX_DATA,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
            Parameter.INITIAL_MAX_STREAM_DATA_UNI,
            Parameter.INITIAL_MAX_STREAMS_BIDI,
            Parameter.INITIAL_MAX_STREAMS_UNI,
            Parameter.INITIAL_SOURCE_CONNECTION_ID,
            Parameter.VERSION_INFORMATION,
        ),
    ),
)


async def main() -> None:
    async with Client(versions=("h3", "h2", "h1"), http2=http2, http3=http3) as client:
        first = await client.get("https://pingly.us.kg/api/all")
        second = await client.get("https://pingly.us.kg/api/all")
        print(first.version, second.version)  # h2 h3, the Alt-Svc header said this origin speaks HTTP/3

    async with Client(versions=("h3",), http3=replace(http3, quic_version=QuicVersion.V2)) as client:
        response = await client.get("https://pingly.us.kg/api/all")
        print(response.version, response.json()["http_version"])  # h3 HTTP/3.0


asyncio.run(main())

The default is versions=("h2", "h1"). With "h3" in the list an origin moves to QUIC after an Alt-Svc: h3 header and comes back to TCP when the QUIC route stops answering; versions=("h3",) goes straight to QUIC. The first packet is always v1; quic_version=QuicVersion.V2 offers v2 in version_information, so a server that speaks it switches and every other one stays on v1. That needs Parameter.VERSION_INFORMATION in order.

TransportParameters sends the parameters in order, in that order, and refuses a value the peer would never see. max_idle_timeout=0 sets no idle timeout, grease=True adds a GREASE parameter, grease_version=True a GREASE version, shuffle=True a fresh order on every connection, and Parameter.INITIAL_ROUND_TRIP_TIME sends initial_round_trip_time or, without one, the round trip measured on the last connection to that origin. The control stream carries the SETTINGS of the profile, a GREASE setting and a GREASE frame with grease=True, and a PRIORITY_UPDATE for a request with a priority header.

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 is sent again after it, and a server that declines the early data gets it again under the 1-RTT keys. Retry, version negotiation, loss recovery, pacing, flow control, the server's key updates and path MTU probing from 1200 to 1452 bytes are handled; connection migration is not. On Linux the socket moves many datagrams per call through UDP segmentation and receive offload.

Proxies

import asyncio

from boring_http import Client, Http2Profile, Http3Profile, Http3Setting, Parameter, TransportParameters
from ssl2 import AkamaiFingerprint, ProxyError, PseudoHeader

http2 = Http2Profile(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
http3 = Http3Profile(
    settings=((Http3Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Http3Setting.MAX_FIELD_SECTION_SIZE, 262144), (Http3Setting.QPACK_BLOCKED_STREAMS, 100)),
    pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
    transport=TransportParameters(
        max_idle_timeout=30000,
        max_udp_payload_size=1472,
        initial_max_data=15728640,
        initial_max_stream_data_bidi_local=6291456,
        initial_max_stream_data_bidi_remote=6291456,
        initial_max_stream_data_uni=6291456,
        initial_max_streams_bidi=100,
        initial_max_streams_uni=100,
        order=(
            Parameter.MAX_IDLE_TIMEOUT,
            Parameter.MAX_UDP_PAYLOAD_SIZE,
            Parameter.INITIAL_MAX_DATA,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
            Parameter.INITIAL_MAX_STREAM_DATA_UNI,
            Parameter.INITIAL_MAX_STREAMS_BIDI,
            Parameter.INITIAL_MAX_STREAMS_UNI,
            Parameter.INITIAL_SOURCE_CONNECTION_ID,
            Parameter.VERSION_INFORMATION,
        ),
    ),
)


async def main() -> None:
    async with Client(http2=http2, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
        response = await client.get("https://pingly.us.kg/api/all")  # SOCKS5 CONNECT, HTTP/2 inside
        print(response.version, response.json()["http_version"])  # h2 HTTP/2.0
        response = await client.get("https://pingly.us.kg/api/all", proxy="http://alice:secret@127.0.0.1:3128")  # this request only
        print(response.version)  # h2

    async with Client(versions=("h3",), http3=http3, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
        response = await client.get("https://pingly.us.kg/api/all")  # SOCKS5 UDP ASSOCIATE, QUIC datagrams through the relay
        print(response.version, response.json()["http_version"])  # h3 HTTP/3.0

    try:
        async with Client(http2=http2, proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
            await client.get("https://pingly.us.kg/api/all")
    except ProxyError as refusal:
        print(refusal)  # the SOCKS5 proxy rejected the username and password


asyncio.run(main())

http://, https://, socks4://, socks4a://, socks5:// and socks5h://, with credentials in the URL or as a Proxy from ssl2. HTTP and SOCKS proxies carry HTTP/1.1 and HTTP/2; SOCKS5 also carries HTTP/3. proxy=None on a request goes direct.

Resolvers

import asyncio

from boring_http import Client, DnsResolver, DohResolver, Http2Profile
from ssl2 import AkamaiFingerprint

http2 = Http2Profile(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))


async def main() -> None:
    resolver = DnsResolver(("1.1.1.1", "8.8.8.8"))
    addresses = await resolver.resolve("pingly.us.kg", 443)
    print(addresses == await resolver.resolve("pingly.us.kg", 443))  # True, the second answer came from the cache
    async with Client(http2=http2, resolver=resolver, trace=True) as client:
        response = await client.get("https://pingly.us.kg/api/all")
        print(response.status, response.connection.remote_address[0] in {host for host, _ in addresses}, response.trace.spans[0].name)  # 200 True resolve

    async with Client(http2=http2) as carrier:
        doh = DohResolver("https://cloudflare-dns.com/dns-query", client=carrier)
        print(await doh.resolve("pingly.us.kg", 443) == addresses)  # True, the same answer over HTTPS
        async with Client(http2=http2, resolver=doh) as client:
            response = await client.get("https://pingly.us.kg/api/all")
            print(response.status, response.version)  # 200 h2


asyncio.run(main())

The default SystemResolver() asks the operating system. DnsResolver asks the servers given over UDP, and again over TCP when an answer came back truncated. It asks A and AAAA at once and keeps every answer for its TTL in a DnsCache, which cache= shares between resolvers; timeout= bounds each attempt and attempts= their number, each attempt asking the next server. DohResolver sends the same queries as the body of a POST to a DNS over HTTPS server, through the Client given, so they ride its HTTP/2 or HTTP/3 connection. A connection tries the addresses in the order the resolver gives them, IPv6 first, and moves to the next when one fails. resolve= pins win over any resolver, and a name that does not resolve raises ResolveError.

Fingerprints

import asyncio

from boring_http import Client, Http2Profile
from ssl2 import (
    JA4,
    AkamaiFingerprint,
    CertificateCompressionAlgorithm,
    CipherSuite,
    ClientHelloBuilder,
    ECPointFormat,
    ExtensionType,
    Http2Priority,
    NamedGroup,
    NextProtocol,
    ProtocolVersion,
    PskKeyExchangeMode,
    SignatureScheme,
)

hello = (
    ClientHelloBuilder()
    .supported_versions(ProtocolVersion.TLS_1_3, ProtocolVersion.TLS_1_2)
    .cipher_suites(
        CipherSuite.TLS_AES_128_GCM_SHA256,
        CipherSuite.TLS_AES_256_GCM_SHA384,
        CipherSuite.TLS_CHACHA20_POLY1305_SHA256,
        CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
        CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
        CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
        CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
        CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
        CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
        CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
        CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
        CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256,
        CipherSuite.TLS_RSA_WITH_AES_256_GCM_SHA384,
        CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA,
        CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
    )
    .extensions(
        ExtensionType.server_name,
        ExtensionType.extended_main_secret,
        ExtensionType.renegotiation_info,
        ExtensionType.supported_groups,
        ExtensionType.ec_point_formats,
        ExtensionType.session_ticket,
        ExtensionType.application_layer_protocol_negotiation,
        ExtensionType.status_request,
        ExtensionType.signature_algorithms,
        ExtensionType.signed_certificate_timestamp,
        ExtensionType.key_share,
        ExtensionType.psk_key_exchange_modes,
        ExtensionType.supported_versions,
        ExtensionType.compress_certificate,
        ExtensionType.application_settings,
        ExtensionType.encrypted_client_hello,
    )
    .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
    .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
    .ec_point_formats(ECPointFormat.uncompressed)
    .signature_algorithms(
        SignatureScheme.ecdsa_secp256r1_sha256,
        SignatureScheme.rsa_pss_rsae_sha256,
        SignatureScheme.rsa_pkcs1_sha256,
        SignatureScheme.ecdsa_secp384r1_sha384,
        SignatureScheme.rsa_pss_rsae_sha384,
        SignatureScheme.rsa_pkcs1_sha384,
        SignatureScheme.rsa_pss_rsae_sha512,
        SignatureScheme.rsa_pkcs1_sha512,
    )
    .application_protocols(NextProtocol.H2, NextProtocol.HTTP1)
    .application_settings(NextProtocol.H2)
    .certificate_compression(CertificateCompressionAlgorithm.brotli)
    .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
    .build()
)
http2 = Http2Profile(
    AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"),
    headers_priority=Http2Priority(stream_id=0, exclusive=True, dependency=0, weight=256),
)


async def main() -> None:
    async with Client(tls=hello, http2=http2) as client:
        report = (await client.get("https://pingly.us.kg/api/all")).json()
        print(report["tls"]["ja4"] == JA4.of(hello).text, report["tls"]["ja4"])  # True t13d1516h2_8daaf6152771_d8a2da3f94cd
        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


asyncio.run(main())

tls is a ClientHello from ssl2; BoringSSL sends it as described or the client refuses it with the reason. http2 holds the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order of the connection preface; http3 the HTTP/3 SETTINGS, pseudo-header order, transport parameters and QUIC version. Neither has a default: h2 in the offered ALPN, the ClientHello's when tls is given and that of versions otherwise, needs http2, and h3 in versions needs http3. A connection that has to be HTTP/1.1, such as the hop to an https proxy, offers only http/1.1 and leaves the ALPS extension out.

Over QUIC the same holds, here with the ClientHello Chrome 155 sends: TLS 1.3 only, the trust_anchors extension with the identifiers of Chrome's root store, and pre_shared_key once a session exists.

import asyncio

from boring_http import Client, Http3Profile, Http3Setting, Parameter, TransportParameters
from ssl2 import (
    JA4,
    CertificateCompressionAlgorithm,
    CipherSuite,
    ClientHelloBuilder,
    ExtensionType,
    JA4Transport,
    NamedGroup,
    NextProtocol,
    ProtocolVersion,
    PseudoHeader,
    PskKeyExchangeMode,
    SignatureScheme,
)

quic_hello = (
    ClientHelloBuilder()
    .supported_versions(ProtocolVersion.TLS_1_3)
    .cipher_suites(CipherSuite.TLS_AES_128_GCM_SHA256, CipherSuite.TLS_AES_256_GCM_SHA384, CipherSuite.TLS_CHACHA20_POLY1305_SHA256)
    .extensions(
        ExtensionType.signature_algorithms,
        ExtensionType.application_settings,
        ExtensionType.application_layer_protocol_negotiation,
        ExtensionType.compress_certificate,
        ExtensionType.trust_anchors,
        ExtensionType.server_name,
        ExtensionType.psk_key_exchange_modes,
        ExtensionType.key_share,
        ExtensionType.quic_transport_parameters,
        ExtensionType.supported_versions,
        ExtensionType.supported_groups,
        ExtensionType.encrypted_client_hello,
        ExtensionType.pre_shared_key,
    )
    .supported_groups(NamedGroup.X25519MLKEM768, NamedGroup.x25519, NamedGroup.secp256r1, NamedGroup.secp384r1)
    .key_shares(NamedGroup.X25519MLKEM768, NamedGroup.x25519)
    .signature_algorithms(
        SignatureScheme.ecdsa_secp256r1_sha256,
        SignatureScheme.rsa_pss_rsae_sha256,
        SignatureScheme.rsa_pkcs1_sha256,
        SignatureScheme.ecdsa_secp384r1_sha384,
        SignatureScheme.rsa_pss_rsae_sha384,
        SignatureScheme.rsa_pkcs1_sha384,
        SignatureScheme.rsa_pss_rsae_sha512,
        SignatureScheme.rsa_pkcs1_sha512,
        SignatureScheme.rsa_pkcs1_sha1,
    )
    .application_protocols(NextProtocol.H3)
    .application_settings(NextProtocol.H3)
    .certificate_compression(CertificateCompressionAlgorithm.brotli)
    .psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
    .trust_anchor_ids(
        "44947.2.1", "44947.2.6", "44947.2.13", "44947.2.14", "44947.2.15", "44947.2.18", "44947.2.19", "44947.2.20",
        "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",
        "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",
    )
    .grease()
    .permute_extensions()
    .build()
)
http3 = Http3Profile(
    settings=((Http3Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Http3Setting.MAX_FIELD_SECTION_SIZE, 262144), (Http3Setting.QPACK_BLOCKED_STREAMS, 100), (Http3Setting.H3_DATAGRAM, 1)),
    pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
    transport=TransportParameters(
        max_idle_timeout=30000,
        max_udp_payload_size=1472,
        initial_max_data=15728640,
        initial_max_stream_data_bidi_local=6291456,
        initial_max_stream_data_bidi_remote=6291456,
        initial_max_stream_data_uni=6291456,
        initial_max_streams_bidi=100,
        initial_max_streams_uni=103,
        order=(
            Parameter.MAX_IDLE_TIMEOUT,
            Parameter.MAX_UDP_PAYLOAD_SIZE,
            Parameter.INITIAL_MAX_DATA,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
            Parameter.INITIAL_MAX_STREAM_DATA_UNI,
            Parameter.INITIAL_MAX_STREAMS_BIDI,
            Parameter.INITIAL_MAX_STREAMS_UNI,
            Parameter.INITIAL_SOURCE_CONNECTION_ID,
            Parameter.VERSION_INFORMATION,
            Parameter.MAX_DATAGRAM_FRAME_SIZE,
            Parameter.GOOGLE_CONNECTION_OPTIONS,
            Parameter.INITIAL_ROUND_TRIP_TIME,
        ),
        max_datagram_frame_size=65536,
        google_connection_options=(b"ORIG",),
        grease=True,
        grease_version=True,
        shuffle=True,
    ),
    tls=quic_hello,
    grease=True,
)


async def main() -> None:
    async with Client(versions=("h3",), http3=http3, headers=[("priority", "u=0, i")]) as client:
        _ = await client.get("https://pingly.us.kg/api/all")
        await client.close()  # the next request opens a new connection, which resumes the session
        report = (await client.get("https://pingly.us.kg/api/all")).json()
        print(report["tls"]["ja4"] == JA4.of(quic_hello, JA4Transport.QUIC).text, report["tls"]["ja4"])  # True q13d0313h3_55b375c5d22e_b0954bf1abdf
        print(report["http3"]["h3_text"])  # 1:65536;6:262144;7:100;51:1;GREASE|m,a,s,p


asyncio.run(main())

WebSocket

import asyncio

from boring_http import Client, Http2Profile
from ssl2 import AkamaiFingerprint

http2 = Http2Profile(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))


async def main() -> None:
    async with Client(http2=http2) as client:
        websocket = await client.websocket("wss://echo.websocket.org/")
        print((await websocket.receive())[:17])  # Request served by
        await websocket.send("hello")
        print(await websocket.receive())  # hello
        await websocket.send(b"\x00\x01")
        print(await websocket.receive())  # b'\x00\x01'
        await websocket.close()
        print(websocket.close_code)  # 1000


asyncio.run(main())

Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extended CONNECT when the server announces SETTINGS_ENABLE_CONNECT_PROTOCOL, otherwise an HTTP/1.1 connection is opened for it. Each WebSocket has a connection of its own, which close() ends after the closing handshake. subprotocols=[...] offers subprotocols and websocket.subprotocol is the one chosen. The total of the timeout covers the handshake only; read and write still limit each wait afterwards, so Timeout(read=None) lets receive() wait through any silence. async for message in websocket reads until the close.

Your own BoringSSL

from boring_ssl2 import Library

from boring_http import Client

library = Library.default()  # the build that ships with boring-ssl2; Library("/opt/boringssl/libboring_ssl2.so") loads another
print(len(library.source_revision))  # 40
client = Client(versions=("h1",), library=library)

Every connection of the client uses that build: the TLS contexts, the QUIC packet protection, the certificate parsing and the brotli decoder. A build that predates a feature the client needs refuses with a ConfigurationError naming the missing function.

uvloop

import uvloop

from boring_http import Client, Http3Profile, Http3Setting, Parameter, TransportParameters
from ssl2 import PseudoHeader

http3 = Http3Profile(
    settings=((Http3Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Http3Setting.MAX_FIELD_SECTION_SIZE, 262144), (Http3Setting.QPACK_BLOCKED_STREAMS, 100)),
    pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
    transport=TransportParameters(
        max_idle_timeout=30000,
        max_udp_payload_size=1472,
        initial_max_data=15728640,
        initial_max_stream_data_bidi_local=6291456,
        initial_max_stream_data_bidi_remote=6291456,
        initial_max_stream_data_uni=6291456,
        initial_max_streams_bidi=100,
        initial_max_streams_uni=100,
        order=(
            Parameter.MAX_IDLE_TIMEOUT,
            Parameter.MAX_UDP_PAYLOAD_SIZE,
            Parameter.INITIAL_MAX_DATA,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
            Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
            Parameter.INITIAL_MAX_STREAM_DATA_UNI,
            Parameter.INITIAL_MAX_STREAMS_BIDI,
            Parameter.INITIAL_MAX_STREAMS_UNI,
            Parameter.INITIAL_SOURCE_CONNECTION_ID,
            Parameter.VERSION_INFORMATION,
        ),
    ),
)


async def main() -> None:
    async with Client(versions=("h3",), http3=http3) as client:
        print((await client.get("https://pingly.us.kg/api/all")).version)  # h3


uvloop.run(main())

Every path runs unchanged on uvloop, QUIC included. On Windows' default event loop, which signals no socket readiness, QUIC runs on a datagram endpoint instead of the raw socket.

Performance

Against a Go server (net/http and quic-go) on loopback, Python 3.14.8 on Linux, one process per measurement. seq is 1000 requests one after another on one connection, conc 1000 requests with 32 in flight, heavy 2000 requests with 64 in flight mixing a small GET, a 64 KB GET and a 1 KB POST as a page load does, download one 50 MB GET, upload one 20 MB POST, loss a 20 MB GET through a relay dropping 5% of the datagrams each way. Requests per second for the first nine rows, MB/s for the rest; - is a protocol the client does not speak.

scenario boring-http boring-http+uvloop wreq curl_cffi pycurl aioquic httpx aiohttp requests
h1-seq 10356 10613 18206 6920 30037 - 2180 12554 3321
h2-seq 6487 6898 12685 5318 16639 - 1682 - -
h3-seq 3555 3627 - 4840 14154 6777 - - -
h1-conc 10718 9929 71668 11214 32329 - 899 23208 2443
h2-conc 14246 17030 43825 11274 30840 - 2047 - -
h3-conc 8165 8255 - 11719 31401 8128 - - -
h1-heavy 8111 7955 32752 8469 18744 - 750 13431 1952
h2-heavy 9079 9534 22780 8988 17037 - 1432 - -
h3-heavy 4786 4768 - 8102 12177 1802 - - -
h1-download 732 763 871 864 1028 - 286 1031 655
h2-download 335 357 449 timed out 459 - 130 - -
h3-download 182 187 - 516 506 65 - - -
h1-upload 2911 2826 3603 2154 2033 - 773 750 2387
h2-upload 1040 927 2229 1485 1350 - 190 - -
h3-upload 102 106 - 683 447 50 - - -
h3-loss 35 36 - 54 54 4 - - -

The round trip of one small request is 0.10 ms on HTTP/1.1, 0.15 ms on HTTP/2 and 0.28 ms on HTTP/3 at the median, against 0.03 ms for pycurl. The client process peaks at 62 to 67 MB of RSS on the request scenarios and holds the body in memory on a download, as every client here does.

wreq is Rust and pycurl is libcurl in C: one call does the whole request in compiled code and Python only waits. Here BoringSSL does the cryptography and everything else, the HTTP framing, the headers, the pool, the timeouts and the asyncio futures, runs as Python, about 90 µs of CPU per HTTP/1.1 request. HTTP/3 is a QUIC stack in Python on top of that. The same code holds a thousand clients on their own threads and event loops, a thousand clients on one loop, 20000 requests with 256 in flight, and keeps a flat memory footprint over thousands of connections.

Metadata

Release files for boring-http 3.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for boring-http 3.1.2
File Size Uploaded Provenance Actions
boring_http-3.1.2.tar.gz 162.0 kB With provenance Details for boring_http-3.1.2.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for boring-http 3.1.2
File Interpreter ABI Platform Actions
boring_http-3.1.2-py3-none-any.whl Python 3 none any Details for boring_http-3.1.2-py3-none-any.whl

Total release size: 329.3 kB

Release files / boring_http-3.1.2.tar.gz

Download URL boring_http-3.1.2.tar.gz
Size 162.0 kB
Tags Source
SHA-256 checksum
How to use checksums
eec3f1925e914f7c634f8b6348243db5e27d53fa066f4fa640c5aab1c41ff78d
BLAKE2b-256 checksum
How to use checksums
f83635b6f30f96e6fd859ba7ab20a76dc5cd3fa8951394f8d0f057abb9034daf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.

Transparency log

Release files / boring_http-3.1.2-py3-none-any.whl

Download URL boring_http-3.1.2-py3-none-any.whl
Size 167.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
eb0f85cdd75aaf12fb8ac4b04447dd903a6f77f4b4f20b4e29d7ed45251f1ed0
BLAKE2b-256 checksum
How to use checksums
63f3a038f174bc85f2c2bfe848fe721f3c33f1498943e7a3bed70e14c7a78f60
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.

Transparency log

Security for boring-http 3.1.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for boring_http-3.1.2.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page