Skip to main content

rex-tls

rex-tls is a Python HTTP client with a native Rust TLS and HTTP core. It offers a familiar Requests-style API and transport profiles for Android Chrome, WebView, Cronet, OkHttp, and Flutter dart:io, including persistent connections, cookies, HTTP and SOCKS5 proxies, streaming I/O, asyncio support, and bounded session pools.

The native core owns TLS handshakes, ALPN, HTTP/1.1 serialization, HTTP/2 framing, HTTP/3 over QUIC, request-header ordering, response framing, and connection reuse. Python code provides the public API and Requests-compatible objects.

Highlights

  • Versioned Android Chrome 149 and 150 profiles with HTTP/1.1, HTTP/2, and HTTP/3 support.
  • A versioned Android Cronet 151 profile with HTTP/1.1, HTTP/2, and HTTP/3 support.
  • Versioned Android WebView 140 through 150 profiles with HTTP/1.1 and HTTP/2 support.
  • Versioned OkHttp 4.12 and 5.4 profiles with HTTP/1.1 and HTTP/2 support.
  • A versioned Flutter 3.47.1 / Dart 3.13.1 Android profile for the default HTTP/1.1-only dart:io HttpClient transport and Dio IO adapter.
  • Requests-style Session.request(), method helpers, CookieJar operations, redirects, timeouts, proxies, streaming, and Response properties.
  • Native connection reuse and HTTP/2 or HTTP/3 multiplexing.
  • Synchronous, asynchronous, shared-state pool, and isolated-state pool APIs.
  • Incremental uploads and downloads with bounded memory use.

What's new in 2.11.0

  • Added independently versioned webview_android_140 through webview_android_144, plus webview_android_146 and webview_android_147. Together with the existing profiles, every Android WebView major from 140 through 150 is now available.
  • Collected three cold HTTP/1.1 connections, three cold HTTP/2 connections, H2 reuse, H1/H2 TLS resumption, and authenticated three- and four-navigation Alt-Svc sequences for every new major.
  • Verified that WebView 140 through 149 share the same stable Chromium TLS, H1 navigation, and H2 wire family while retaining separate profile names and versioned User-Agent defaults. WebView 150 keeps its independently verified signature-algorithm family.
  • None of the new WebView versions attempted QUIC after authenticated Alt-Svc, so WebView profiles remain H1/H2-only and reject http3="only" before network I/O.
  • Hardened provider transactions for Android versioned static libraries: the WebView and Trichrome version codes must match exactly, and only the transaction-added internal library package is removed during rollback.
  • Expanded installed-wheel verification, loopback benchmarks, Header-order tests, proxy coverage, and release capability checks for the new profiles.

What's new in 2.10.1

  • Corrected Flutter HTTP/1.1 Header ordering using Dart 3.13's complete AOT HashMap mutation and resize behavior, including hidden request framing fields, replacement, removal, and final body-length insertion.
  • Matched GET, HEAD, POST, PUT, PATCH, DELETE, and OPTIONS framing for empty, known-length, and chunked bodies. Dio-generated JSON, form, and multipart requests now use their observed Header order.
  • Matched Dart's 15-second idle connection lifetime and its uncapped default per-host connection policy. An explicit max_connections_per_route still applies a caller-selected bound.
  • Corrected Flutter response decoding: only one exact lowercase gzip value is decoded, repeated fields are rejected, non-gzip values remain encoded, concatenated members work, available output survives truncation, and a present bad checksum fails. Buffered and streaming responses share the same rules.
  • Added one fresh authenticated CONNECT retry after a proxy 407 challenge, with bounded failure for wrong credentials.
  • Expanded device/native regression coverage for TLS 1.2 fallback, TLS 1.3 HelloRetryRequest, certificate rejection, connection reuse, concurrency, request bodies, Dio, and response decoding.

What's new in 2.10.0

  • Added the independently versioned flutter_android_3.47.1 profile for Flutter 3.47.1 and Dart 3.13.1 Android applications using the default dart:io HttpClient transport.
  • Verified that Dio 5.11.0 with its default IOHttpClientAdapter produces the same stable TLS and HTTP/1.1 behavior.
  • Matched the complete stable ClientHello structure, single-record layout, cold and warm connection behavior, connection reuse, lowercase Header casing, and Android AOT HashMap Header ordering.
  • Matched known-length and chunked uploads, unauthenticated and authenticated HTTP CONNECT ordering, and direct-versus-tunneled TLS behavior.
  • Implemented Dart's gzip-only response decoding while preserving the original representation Headers.
  • Added a reproducible arm64 release probe, normalized device baseline, native wheel comparisons, and dedicated regression tests. The profile deliberately does not claim H2 or H3.

What's new in 2.9.0

  • Added webview_android_145 and webview_android_148 with independently collected TLS, HTTP/1.1, HTTP/2, connection-reuse, session-resumption, and Alt-Svc baselines.
  • Both profiles use the same stable TLS and HTTP/2 fields observed for WebView 149 while retaining their own versioned User-Agent defaults and profile identities.
  • Three- and four-navigation Alt-Svc sequences produced no QUIC connection, handshake, or HTTP/3 request for either version, so http3="only" is rejected.
  • HTTP CONNECT, SOCKS5/SOCKS5H, streaming, asyncio, and bounded session pools use the same public API as the existing versioned profiles.
  • Added fail-closed provider transaction, TCP/Alt-Svc collection, privacy normalization, stable-field analysis, and verified ranged-download tools for versioned WebView evidence work.

What's new in 2.8.0

  • Added webview_android_149, backed by controlled HTTP/1.1 and HTTP/2 navigation samples. It uses the Chromium 149 TLS family and the independent WebView navigation Header table.
  • Verified HTTP/2 connection reuse and TLS session resumption for the new profile. Repeated authenticated Alt-Svc navigation produced no QUIC attempt, so WebView 149 deliberately does not claim HTTP/3 support.
  • Removed the superseded request-thread HTTP/2 reader and upload paths. The bounded connection actor is now the only native HTTP/2 execution path for buffered responses, streaming responses, and streaming uploads.
  • Kept existing Chrome, WebView 150, Cronet, OkHttp, and custom profile wire definitions unchanged.

rex-tls does not invent an application package for X-Requested-With in a WebView profile; when caller Headers are non-empty, it sorts only the fields provided by the caller.

The custom TLS/HTTP profile introduced in 2.4.0 remains isolated from every built-in profile. Custom HTTP/3 is not supported.

Installation

python -m pip install rex-tls

Requirements:

Item Supported value
Python CPython 3.9 or newer
Windows x86-64
Linux x86-64, manylinux 2.28 or newer

The package is distributed as binary wheels. Installation on an unsupported platform fails instead of silently building a different native core from a source distribution.

Transport profiles

Profile HTTP/1.1 HTTP/2 HTTP/3 Automatic response decoding
chrome_android_149 Yes Yes Yes gzip, deflate, Brotli, zstd, stacked codings
chrome_android_150 Yes Yes Yes gzip, deflate, Brotli, zstd, stacked codings
webview_android_140 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_141 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_142 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_143 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_144 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_145 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_146 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_147 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_148 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_149 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
webview_android_150 Yes Yes No gzip, deflate, Brotli, zstd, stacked codings
cronet_android_151 Yes Yes Yes gzip, deflate, Brotli, zstd, stacked codings
okhttp_4.12 Yes Yes No gzip
okhttp_5.4 Yes Yes No gzip
flutter_android_3.47.1 Yes No No one exact lowercase gzip value; representation Headers remain visible

Aliases are explicit mappings:

Alias Selected profile
chrome_android chrome_android_150
chrome_android_latest chrome_android_150
cronet cronet_android_151
cronet_latest cronet_android_151
okhttp okhttp_5.4
okhttp_latest okhttp_5.4
flutter flutter_android_3.47.1
flutter_latest flutter_android_3.47.1

Use a versioned name when an application must remain pinned to one profile. The profiles cover application-layer TLS and HTTP behavior. Operating-system IP and TCP packet fields are produced by the machine running rex-tls.

Choose the profile that matches the networking component used by the original client:

  • Use chrome_android_149 or chrome_android_150 for Android Chrome browser traffic.
  • Use cronet_android_151 for an Android app that sends requests through the packaged Cronet engine.
  • Use webview_android_140 through webview_android_150 for main-frame navigation behavior from the matching system WebView provider.
  • Use okhttp_4.12 or okhttp_5.4 for an Android app that sends requests through that OkHttp version.
  • Use flutter_android_3.47.1 for a Flutter Android app that uses the default dart:io HttpClient, including Dio with its default IO adapter.
  • Use profile="custom" only when the caller owns and validates a separate TLS/H1/H2 definition. It does not identify itself as a built-in profile.

Flutter Android 3.47.1

import rex_tls

with rex_tls.Session("flutter_android_3.47.1", timeout=20) as session:
    response = session.get("https://example.com/api")

print(response)
print(response.http_version)

This profile is pinned to Flutter 3.47.1 and Dart 3.13.1. The Android release probe used dart:io HttpClient; Dio 5.11.0 with its default IOHttpClientAdapter produced the same stable TLS and Header behavior.

The real client sends no ALPN and therefore uses HTTP/1.1. The profile does not claim H2 or H3, so http2=True and http3="only" are rejected. Its process-cold, warm-process, forced-new-connection, and same-connection-reuse paths were collected separately. New TLS connections remain cold and do not add a PSK extension; an open HTTP/1.1 connection is reused by sequential requests.

With no caller Headers, the lowercase wire order is user-agent, accept-encoding, then host. Dart's defaults remain present when application Headers are supplied. Ordering uses the complete Dart 3.13 Android AOT HashMap mutation history rather than Python dictionary order, including hidden framing fields and resize boundaries. HTTP CONNECT uses the same Android AOT HashMap behavior, and the TLS ClientHello inside the tunnel is identical to the direct path.

Only one case-sensitive Content-Encoding value exactly equal to gzip is decoded automatically. Uppercase, comma-list, Brotli, deflate, zstd, and unknown values remain encoded; repeated field lines fail. Flutter keeps the original Content-Encoding and Content-Length fields visible after decoding. Buffered and streaming paths also match Dart's truncated-gzip and checksum behavior. Detailed verified behavior and the evidence definition are in the Flutter Android profile document.

The profile uses Dart's 15-second idle lifetime. When max_connections_per_route is omitted, it also follows Dart's default uncapped per-host policy; pass an integer to apply an explicit bound.

Cronet 151

Use the exact versioned name when HTTP/3 must be required:

import rex_tls

with rex_tls.Session(
    profile="cronet_android_151",
    http3="only",
) as session:
    response = session.get(
        "https://example.com/api",
        headers={
            "X-Application-Id": "example",
            "User-Agent": "ExampleApp/1.0 Cronet/151.0.7922.83",
        },
    )

print(response.http_version)
print(response.connection_reused)

Connection reuse is exposed through Response.connection_reused.

Cronet is an application networking library, not the Android Chrome browser or Android WebView. Its profile therefore has its own default Headers and priority behavior. H1, H2, and H3 share the same Session cookie jar and connection lifecycle, while each protocol retains its audited wire order.

Android WebView 140 through 150

import rex_tls

with rex_tls.Session(profile="webview_android_144") as session:
    response = session.get("https://example.com/")

print(response.http_version)

WebView 140 through 149 have independently collected baselines with the same stable TLS fields. WebView 150 has its own signature-algorithm change. None of the profiles uses the Android Chrome or Cronet Header table. Their default navigation omits Chrome Client Hints and uses the WebView navigation order. The exact User-Agent, Accept-Language, and X-Requested-With values depend on the embedding app and runtime.

When headers is empty, rex-tls supplies the stable WebView navigation defaults but does not invent an application package for X-Requested-With. When headers is non-empty, the existing exact-header contract applies: rex-tls sorts only the fields supplied by the caller and does not add profile defaults. Pass the complete app Header set when an application-specific X-Requested-With value is required.

Controlled navigation for every supported WebView version did not attempt QUIC after authenticated Alt-Svc across three- and four-navigation sequences, so these profiles do not claim HTTP/3 support. http3="only" is rejected. The per-version evidence definition and usage notes are in the Android WebView profile document.

Caller-defined TLS and HTTP/2 profiles

Use profile="custom" when the wire configuration comes from your own tls-client or requests-go definition. A custom configuration is separate from all built-in profiles and does not claim to represent any particular browser, application, or device.

Import a tls-client configuration

import rex_tls

tls_client_config = {
    "ja3String": (
        "771,4865-4866-4867-49195-49199,"
        "0-10-11-13-16-18-27-43-45-51-17613-65037,"
        "4588-29-23-24,0"
    ),
    "supportedSignatureAlgorithms": [
        "ecdsa_secp256r1_sha256",
        "rsa_pss_rsae_sha256",
        "rsa_pkcs1_sha256",
        "ecdsa_secp384r1_sha384",
        "rsa_pss_rsae_sha384",
        "rsa_pkcs1_sha384",
        "rsa_pss_rsae_sha512",
        "rsa_pkcs1_sha512",
    ],
    "supportedVersions": ["GREASE", "1.3", "1.2"],
    "keyShareCurves": ["GREASE", "X25519MLKEM768", "X25519"],
    "alpnProtocols": ["h2", "http/1.1"],
    "alpsProtocols": ["h2"],
    "certCompressionAlgos": ["brotli"],
    "h2Settings": {
        "HEADER_TABLE_SIZE": 65536,
        "ENABLE_PUSH": 0,
        "INITIAL_WINDOW_SIZE": 6291456,
    },
    "h2SettingsOrder": [
        "HEADER_TABLE_SIZE",
        "ENABLE_PUSH",
        "INITIAL_WINDOW_SIZE",
    ],
    "connectionFlow": 15663105,
    "streamId": 1,
    "pseudoHeaderOrder": [":method", ":authority", ":scheme", ":path"],
    "headerPriority": {
        "streamDep": 0,
        "exclusive": True,
        "weight": 255,
    },
}

config = rex_tls.CustomTLSConfig.from_tls_client(
    tls_client_config,
    header_order=["accept", "host", "user-agent", "x-request-id"],
)

with rex_tls.Session(
    profile="custom",
    tls_config=config,
    http2=True,
) as session:
    response = session.get(
        "https://example.com/",
        headers={
            "X-Request-Id": "example",
            "User-Agent": "caller-owned-agent",
            "Accept": "*/*",
        },
    )
    print(response.http_version)

tls-client stores an HTTP/2 priority weight as its encoded byte. The value 255 in headerPriority therefore represents the displayed HTTP/2 weight 256. CustomTLSConfig performs that conversion automatically.

Import a requests-go configuration

import rex_tls

config = rex_tls.CustomTLSConfig.from_requests_go(requests_go_config)

with rex_tls.Session(profile="custom", tls_config=config) as session:
    response = session.request("GET", "https://example.com/")

from_requests_go() accepts a requests-go TLSConfig mapping or a stored capture document containing tls and http2 sections. It converts the requests-go HTTP/2 weight representation and preserves its extension, SETTINGS, pseudo-header, and ordinary Header order.

Custom-profile rules

  • profile="custom" requires a CustomTLSConfig. Passing tls_config to a built-in profile raises an error.
  • Custom profile v1 accepts HTTPS URLs only; allowHttp=True is rejected.
  • Accepted custom fields are emitted by the project-owned TLS, HTTP/1.1, and HTTP/2 paths. Unknown or unsupported fields are rejected.
  • The configuration is immutable. config.identifier is derived from the normalized wire configuration, and Session.profile reports a short custom: identifier.
  • http2=True requires h2 in ALPN and extension 16 in the ClientHello extension list. It means that successful HTTP/2 negotiation is mandatory.
  • A custom profile supplies no browser or OkHttp default Headers. The request contains the Headers provided by the caller plus protocol-required fields such as Host, Content-Length, or HTTP/2 pseudo-headers.
  • header_order sorts only fields that are present; it does not add or remove caller Headers. Unlisted fields keep their relative order after listed fields. Include host when its HTTP/1.1 position matters.
  • Extension 41 may be specified only as the final extension. BoringSSL emits it only when a resumable TLS session supplies a pre-shared key.
  • Custom HTTP/3, delegated credentials, record-size-limit configuration, caller-provided ECH payloads, and explicit padding extension placement are not supported by custom profile v1.
  • profiles() and profile_info() continue to describe only the six built-in profiles. The caller is responsible for validating a custom configuration against its intended source.

Custom profiles work with AsyncSession, HTTP and SOCKS5 proxies, shared or isolated pools, streaming requests, and streaming responses. Each pool member uses the same immutable configuration while retaining its own connection and cookie state according to the selected pool mode.

Quick start

import rex_tls

response = rex_tls.get(
    "https://example.com/",
    profile="chrome_android_150",
    params={"page": 1},
    timeout=20,
)

response.raise_for_status()
print(response.status_code)
print(response.http_version)
print(response.text)

Top-level request(), get(), post(), put(), patch(), delete(), head(), and options() create a temporary session. Reuse a Session when making more than one request to the same service.

import rex_tls

response = rex_tls.request(
    "POST",
    "https://api.example.com/items",
    profile="okhttp_5.4",
    json={"name": "example", "enabled": True},
)

Persistent sessions

A Session retains cookies, connections, protocol state, and TLS session tickets across requests.

Session.request(method, url, ...) is the generic request entry point. get(), post(), put(), patch(), delete(), head(), and options() are convenience wrappers around it.

import rex_tls

with rex_tls.Session(profile="chrome_android_150") as session:
    response = session.request(
        method="POST",
        url="https://example.com/api/items",
        params={"source": "python"},
        headers={"accept": "application/json"},
        cookies={"request-only": "value"},
        json={"name": "example"},
        timeout=20,
    )
    response.raise_for_status()

The example explicitly uses method="POST", url="https://example.com/api/items", and timeout=20.

The same Session can carry subsequent requests and expose connection reuse:

from rex_tls import Session

with Session("okhttp_5.4", timeout=20) as session:
    session.headers.update({"accept": "application/json"})
    session.cookies.set("locale", "en-US")

    first = session.get("https://example.com/api/profile")
    second = session.get("https://example.com/api/settings")

    print(second.connection_reused)
    print(session.cookies.get_dict())

connection_reused is False when a new connection was required and True when the request used an existing connection. A server close, a different origin or proxy route, incompatible protocol state, an unread streaming body, or a connection limit can prevent reuse.

Session options

session = Session(
    profile="chrome_android_150",
    timeout=30,
    verify=True,
    proxy=None,
    proxies=None,
    follow_redirects=True,
    max_redirects=10,
    trust_env=False,
    http3="off",
    http2=False,
    max_connections_per_route=None,
    tls_config=None,
)
Option Meaning
profile Transport profile or explicit alias.
timeout Default timeout number or (connect, read) pair.
verify True, False, or a CA bundle path.
proxy One proxy URL for all routes. Mutually exclusive with proxies.
proxies Requests-style route mapping. Mutually exclusive with proxy.
follow_redirects Session default for redirect following.
max_redirects Maximum redirects in one request chain.
trust_env Read standard proxy environment variables when True.
http3 "off", "auto", or "only".
http2 Require actual HTTP/2 negotiation when True.
max_connections_per_route Maximum HTTP/1.1 connections per origin and proxy route. None selects the profile default: uncapped for Flutter and 1 for the other built-in profiles.
tls_config Required immutable CustomTLSConfig when profile="custom"; rejected for built-in profiles.

Request arguments

Session.request() and every method helper accept the following request options:

Argument Meaning
params Query mapping or ordered sequence of pairs.
headers Header mapping or ordered sequence of name/value pairs.
cookies Cookies for this request only; the session jar is not mutated.
content Bytes, text, file-like object, or byte iterable.
data Form fields, bytes, text, file-like object, or byte iterable.
json JSON-serializable value.
files Requests-style multipart file mapping.
decode_content Decode supported Content-Encoding values when True.
timeout Override the session timeout for this request.
proxies Override selected entries in Session.proxies for this request.
stream Return before downloading the full body when True.
allow_redirects Override the session redirect policy for this request.
navigation_site Chrome request context for profile header selection.
user_activation Chrome request context for profile header selection.

content, data, and json are mutually exclusive. files may be combined with form fields in data, but not with content or json.

Timeouts and redirects

A single timeout value applies one absolute request deadline and the same value to each TCP socket phase:

response = session.get("https://example.com/", timeout=20)

A pair separates TCP connect and socket read/write limits. Its sum is the absolute deadline for the complete redirect chain:

response = session.get("https://example.com/", timeout=(3, 10))

The split form is timeout=(3, 10).

HTTP/3 uses the combined absolute deadline because QUIC connection progress is managed by one protocol driver.

Disable redirects per request when the caller wants to inspect the next hop:

response = session.get(
    "https://example.com/redirect",
    allow_redirects=False,
)

print(response.is_redirect)
print(response.next)

response.history contains followed redirect responses from oldest to newest. For 307 and 308 redirects, seekable upload sources are rewound to their original position. A one-shot source raises UnrewindableBodyError when replay is required.

Request headers and wire order

Headers can be a mapping or an ordered list of pairs:

headers = [
    ("Accept", "application/json"),
    ("X-Trace", "one"),
    ("X-Trace", "two"),
]

response = session.get("https://example.com/api", headers=headers)

The native core prepares headers using the selected profile and negotiated protocol:

  • When both Session.headers and request headers are empty, the selected profile supplies its default headers.
  • When the caller supplies headers, rex-tls does not add unrelated profile defaults. It can still generate fields required by the URL, cookies, body, or wire protocol.
  • Cronet always retains its transport-managed User-Agent, Accept-Encoding, and H2/H3 priority fields when application Headers are present, matching the pinned Cronet engine. Supplying one of those names replaces its value.
  • Chrome profiles sort known fields with protocol-specific navigation and fetch order tables. Unknown fields keep their caller-relative order after known fields.
  • The WebView profile uses its independent navigation table. It never adds an application-specific X-Requested-With value on the caller's behalf.
  • OkHttp profiles preserve caller field order and apply only the casing required by the selected wire protocol.
  • HTTP/1.1 uses profile casing for recognized fields. Unknown HTTP/1.1 names keep the caller's spelling.
  • HTTP/2 and HTTP/3 send ordinary field names in lowercase. Pseudo headers are generated by the native core and are not supplied in headers.

PC Chrome Header order with Android TLS

The versioned Android Chrome profiles can intentionally combine their Android TLS/HTTP transport profile with PC Chrome ordinary Header order. The switch is automatic when the caller explicitly supplies a desktop Chromium User-Agent:

import rex_tls

pc_headers = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/151.0.0.0 Safari/537.36"
    ),
    "sec-ch-ua-platform": '"Windows"',
    "sec-ch-ua": '"Google Chrome";v="151", "Chromium";v="151"',
    "sec-ch-ua-mobile": "?0",
    "Content-Type": "application/json",
    "Accept": "*/*",
    "Sec-Fetch-Site": "cross-site",
    "Sec-Fetch-Mode": "cors",
    "Sec-Fetch-Dest": "empty",
}

with rex_tls.Session("chrome_android_150", http2=True) as session:
    response = session.post(
        "https://example.com/api",
        headers=pc_headers,
        json={"enabled": True},
    )

print(response.request.headers.raw)

Selection rules:

  • The User-Agent must contain Chrome/, Chromium/, or Edg/ and a desktop platform marker such as Windows NT, Macintosh, X11;, or CrOS .
  • Android, iPhone, iPad, Mobile, or Tablet keeps the Android order.
  • An empty Header set still uses the Android profile defaults.
  • Only fields already present are sorted. The mode does not add PC browser defaults or remove caller fields.
  • TLS ClientHello, ALPN, H2/H3 SETTINGS, priorities, connection pools, and Session.profile remain the selected Android Chrome profile.
  • The branch applies only to chrome_android_149 and chrome_android_150; Cronet, OkHttp, and custom profiles are unchanged.

PC Chrome 150 and 151 produced the same fetch order over H1 and H2. H3 uses the PC H2 ordinary Header order; a separate PC H3 wire capture remains pending because the current local network does not deliver outbound UDP to the controlled endpoint.

Navigation versus fetch requests

Chrome H2 selects its legacy HEADERS priority from the prepared request:

  • Empty Session and request headers use the complete navigation defaults and weight 256.
  • Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document, or Upgrade-Insecure-Requests: 1 marks an explicit navigation request and uses weight 256.
  • Other explicit header sets are treated as fetch/API requests and use weight 220. Typical signals are Sec-Fetch-Mode: cors and Sec-Fetch-Dest: empty.
# Top-level document navigation.
navigation_headers = {
    "Sec-Fetch-Mode": "navigate",
    "Sec-Fetch-Dest": "document",
    "Upgrade-Insecure-Requests": "1",
    "Accept": "text/html,application/xhtml+xml",
}

# JSON API request made with fetch semantics.
fetch_headers = {
    "Sec-Fetch-Mode": "cors",
    "Sec-Fetch-Dest": "empty",
    "Content-Type": "application/json",
    "Accept": "*/*",
}

Supplying only ordinary fields such as User-Agent and Accept, without a navigation signal, selects fetch behavior. navigation_site changes the Sec-Fetch-Site value in generated Chrome navigation defaults; it does not convert an explicit fetch header set into navigation headers.

Do not copy an HTTP/1.1 header block unchanged into HTTP/2 or HTTP/3. Connection, Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade are connection-specific and are rejected. TE is valid only with the value trailers. For HTTP/3, omit Host; authority comes from the request URL.

For example, this is valid for HTTP/1.1:

h1_headers = {
    "Host": "www.example.com",
    "Connection": "keep-alive",
    "Accept": "application/json",
    "User-Agent": "my-client/1.0",
}

For a forced HTTP/2 request, use ordinary end-to-end headers only:

h2_headers = {
    "accept": "application/json",
    "user-agent": "my-client/1.0",
}

with Session("chrome_android_150", http2=True) as session:
    response = session.get("https://www.example.com/", headers=h2_headers)

The complete H1, H2, and H3 header contract is documented in the header guide.

Cookies

Session.cookies is a mutable Requests-style CookieJar view backed by the native session:

from rex_tls import Session

with Session("okhttp_5.4") as session:
    session.cookies.update({"theme": "dark"})
    session.cookies.set(
        "api-token",
        "value",
        domain="api.example.com",
        path="/v1",
        secure=True,
    )

    print(session.cookies.get("theme"))
    print(session.cookies.get_dict())
    print(session.cookies.items())

    session.cookies.clear(
        domain="api.example.com",
        path="/v1",
        name="api-token",
    )

Available CookieJar operations:

Method Behavior
set(name, value, ...) Create, replace, or remove one cookie identity.
set_cookie(cookie) Add an http.cookiejar.Cookie object.
get(name, ...) Read one cookie, optionally scoped by domain and path.
get_dict(...) Return matching cookies as a dictionary.
update(values) Merge a mapping or another cookie container.
clear(...) Remove one cookie, a scoped group, or the complete jar.
keys(), values(), items() Inspect stored cookies.
list_domains(), list_paths() Inspect stored scopes.

A valid response cookie with Domain=example.com is accepted when the response host is example.com or a subdomain such as api.example.com. A cookie is rejected when its Domain does not match the response host or is a public suffix such as com.

Response and Session cookie containers have different scopes:

with Session("okhttp_5.4") as session:
    response = session.get("https://example.com/login")

    # Cookies accepted from this response only.
    print(response.cookies.get_dict())

    # All cookies retained for later requests.
    print(session.cookies.get_dict())

Both containers expose the common methods above. Mutating response.cookies does not mutate session.cookies. Cookie storage evaluates Domain, Path, Secure, expiry, deletion, prefix rules, IP-address rules, HttpOnly, SameSite, and the built-in public-suffix list.

An unscoped get(name) raises CookieConflictError when multiple stored domain/path identities have the same name. Supply domain and path to make the lookup unambiguous.

HTTP and SOCKS5 proxies

Use proxy when one proxy handles every route:

proxy_url = "http://username:password@proxy.example:8080"

with Session("okhttp_5.4", proxy=proxy_url) as session:
    response = session.get("https://example.com/")

Use proxies for per-scheme routing and bypass rules:

proxies = {
    "http": "http://proxy.example:8080",
    "https": "http://proxy.example:8080",
    "all": "http://fallback.example:8080",
    "no_proxy": ".internal.example,localhost,127.0.0.1",
}

with Session("chrome_android_149", proxies=proxies) as session:
    response = session.get("https://example.com/")

Proxy selection uses this order:

  1. A per-request proxies entry updates the Session.proxies mapping for that request.
  2. The target scheme key, http or https, takes priority over all.
  3. no_proxy bypasses the selected proxy.
  4. When trust_env=True, unset routes may use HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY.

Set an explicit route value to None to disable that route. proxy and proxies cannot be passed together to the Session constructor.

HTTP proxy URLs use the http:// scheme. HTTP destinations use absolute-form requests; HTTPS destinations use HTTP CONNECT. Basic proxy credentials are supported. Percent-encode reserved characters in usernames and passwords.

SOCKS5 uses the same arguments:

# Local destination DNS resolution.
with Session("okhttp_5.4", proxy="socks5://127.0.0.1:1080") as session:
    response = session.get("https://example.com/")

# Proxy-side destination DNS resolution and username/password authentication.
with Session(
    "chrome_android_150",
    proxy="socks5h://username:password@proxy.example:1080",
) as session:
    response = session.get("https://example.com/")
Scheme Destination DNS
socks5:// Resolved by the rex-tls host before SOCKS CONNECT.
socks5h:// Hostname sent to the proxy for resolution.

IPv4 and IPv6 literals are always sent as literal addresses. SOCKS5 itself does not encrypt proxy credentials, so use it over a trusted network path.

HTTP CONNECT and SOCKS5 establish a tunnel first. The selected rex-tls TLS ClientHello, ALPN, and H1/H2 behavior are then generated inside that tunnel; the proxy type does not replace the selected TLS profile.

Supported proxy schemes are http://, socks5://, and socks5h://. HTTPS proxy URLs, SOCKS4, PAC, NTLM/Digest proxy authentication, and MASQUE are not implemented.

HTTP version selection

The negotiated protocol is available as response.http_version.

Require HTTP/2

with Session("chrome_android_150", http2=True) as session:
    response = session.get("https://example.com/")
    assert response.http_version == "HTTP/2"

http2=True requires HTTPS and an actual HTTP/2 ALPN result. The request fails if the server selects HTTP/1.1. It cannot be combined with an HTTP/3 mode.

Select HTTP/3

HTTP/3 is available for the exact versioned Chrome and Cronet profiles:

Value Behavior
http3="off" Disable HTTP/3. This is the default.
http3="auto" Learn authenticated Alt-Svc and use H3 when available, otherwise use H2/H1.
http3="only" Require HTTP/3 and fail when it cannot be established.
with Session("chrome_android_150", http3="auto") as session:
    first = session.get("https://example.com/")
    second = session.get("https://example.com/")
    print(first.http_version, second.http_version)

with Session("chrome_android_149", http3="only") as session:
    response = session.get("https://example.com/")
    assert response.http_version == "HTTP/3"

with Session("cronet_android_151", http3="only") as session:
    response = session.get("https://example.com/")
    assert response.http_version == "HTTP/3"

In auto mode, the first request to an origin normally uses H2 or H1 so rex-tls can authenticate and learn the origin's Alt-Svc advertisement. A later request may use H3. Auto mode does not mean the first request is forced onto QUIC.

HTTP/3 requires HTTPS. OkHttp and WebView profiles do not offer H3. HTTP CONNECT and SOCKS5 are TCP proxy mechanisms, so http3="only" is rejected when a proxy is selected. Auto mode uses the proxied H2/H1 path.

Streaming downloads and content decoding

Set stream=True and close the response after use. A context manager releases the connection when the body reaches EOF or the response is closed:

from rex_tls import Session

with Session("chrome_android_150") as session:
    with session.get("https://example.com/large.bin", stream=True) as response:
        response.raise_for_status()
        with open("large.bin", "wb") as output:
            for chunk in response.iter_content(chunk_size=64 * 1024):
                output.write(chunk)

Streaming readers:

  • iter_content(chunk_size=..., decode_unicode=False) yields body chunks.
  • iter_lines(chunk_size=..., decode_unicode=False, delimiter=None) yields complete lines.
  • raw.read(size) and raw.readinto(buffer) provide file-like byte access.
  • Accessing content consumes and caches an unread streaming body.
  • close() releases the response without reading the remaining body.

Automatic decoding depends on the profile:

  • Chrome, WebView, and Cronet profiles decode identity, gzip, deflate, Brotli, zstd, and stacked codings in reverse application order.
  • OkHttp profiles automatically decode only a single gzip coding.
  • The Flutter profile decodes only one exact lowercase gzip value, preserves representation Headers, and otherwise follows Dart's pass-through and error behavior.
  • decode_content=False preserves the compressed wire body.
  • Truncated, corrupt, or oversized decoded bodies raise ContentDecodingError.

The decoder is incremental and does not buffer the entire decoded body before yielding chunks.

Streaming uploads and multipart files

File objects and byte iterables are uploaded incrementally:

with Session("okhttp_5.4") as session:
    with open("large.bin", "rb") as source:
        response = session.post(
            "https://example.com/upload",
            content=source,
        )

Multipart form uploads use files:

with Session("okhttp_5.4") as session:
    with open("image.png", "rb") as source:
        response = session.post(
            "https://example.com/form",
            data={"title": "example"},
            files={"file": ("image.png", source, "image/png")},
        )

Known-length H1 bodies use Content-Length; unknown-length H1 bodies use chunked transfer. H2 and H3 bodies are streamed through their native flow-control paths. Seekable sources can be replayed across 307 or 308 redirects.

Async API

AsyncSession provides an asyncio interface while the native work runs outside the event-loop thread:

import asyncio
from rex_tls import AsyncSession

async def main() -> None:
    async with AsyncSession(
        "okhttp_5.4",
        max_concurrency=8,
        http2=True,
    ) as session:
        urls = [f"https://example.com/items/{item}" for item in range(10)]
        responses = await asyncio.gather(*(session.get(url) for url in urls))
        print([response.status_code for response in responses])

asyncio.run(main())

AsyncSession accepts the same transport and request options as Session, plus max_concurrency, which bounds active operations. It exposes request(), all method helpers, cancel(), acancel(), close_origin(), aclose_origin(), close(), and async context management. Its close() method is awaited.

Async streaming uses aiter_content(), aiter_lines(), and aclose():

from rex_tls import AsyncSession

async def stream_events(session: AsyncSession) -> None:
    response = await session.get("https://example.com/events", stream=True)
    async with response:
        async for line in response.aiter_lines():
            print(line)

Bounded session pools

SessionPool and AsyncSessionPool provide a fixed number of independent native sessions. Waiting callers are served in FIFO order.

from concurrent.futures import ThreadPoolExecutor
from rex_tls import SessionPool

urls = [f"https://example.com/items/{item}" for item in range(20)]

with SessionPool(
    "okhttp_4.12",
    max_connections=8,
    session_mode="shared",
) as pool:
    pool.headers["accept"] = "application/json"
    with ThreadPoolExecutor(max_workers=16) as executor:
        responses = list(executor.map(pool.get, urls))

Pool state modes:

Mode Headers Proxies Cookies Connections and TLS state
shared Shared Shared One thread-safe jar Independent per member
isolated Independent Independent Independent Independent per member

In isolated mode, pool.headers, pool.proxies, and pool.cookies raise an error because no pool-wide value exists. Lease one member when several requests must retain the same isolated state:

with SessionPool(
    "okhttp_5.4",
    max_connections=4,
    session_mode="isolated",
) as pool:
    with pool.acquire(timeout=1) as account:
        account.headers["authorization"] = "Bearer example-token"
        account.cookies.set("account", "one")
        account.get("https://example.com/step-1")
        account.get("https://example.com/step-2")

Pool requests accept pool_timeout for the time spent waiting for a member. The ordinary timeout continues to control network work. A streaming response holds its pool member until the body is consumed or the response is closed.

AsyncSessionPool provides the same modes and uses async with pool.acquire() for an isolated lease.

Response API

Response implements the commonly used public Requests response surface and adds native transport information.

Requests-style attributes

Attribute Value
status_code Numeric HTTP status.
reason HTTP reason phrase.
url Final response URL.
headers Case-insensitive response header mapping.
content Response body as bytes.
text Decoded response text.
encoding Selected text encoding; it can be reassigned.
apparent_encoding Detected fallback encoding.
cookies Cookies accepted from this response only.
elapsed Request duration as datetime.timedelta.
history Followed redirect responses, oldest first.
request Prepared request metadata for the final hop.
connection Owning Session transport reference.
raw File-like body reader for streaming responses.
ok Whether raise_for_status() would succeed.
is_redirect Whether this response has a redirect target.
is_permanent_redirect Whether this is a permanent redirect response.
next Prepared follow-up request when redirects are disabled.
links Parsed Link response-header relationships.

Requests-style methods

Method Behavior
json() Parse JSON or raise JSONDecodeError.
raise_for_status() Raise HTTPError for 4xx and 5xx responses.
iter_content() Iterate cached or streaming body chunks.
iter_lines() Iterate cached or streaming body lines.
close() Release response resources.

Response supports boolean conversion and synchronous or asynchronous context management. HTTPError and JSONDecodeError retain both response and request context.

Printing or inspecting a response keeps the transport version visible:

print(response)  # <Response [200 HTTP/2]>

Response.headers combines duplicate values for ordinary mapping access. response.headers.get_all(name) returns every value, while response.headers.raw preserves the received name/value order.

Prepared request metadata includes:

Attribute Value
request.method Normalized HTTP method.
request.url Full request URL.
request.path_url Path and query.
request.headers Ordinary headers prepared for that protocol hop.
request.body Buffered request body when available.

For H1, prepared headers include Host. For H2 and H3, names are lowercase and pseudo headers are represented by method, URL, path, and authority metadata rather than inserted into the ordinary header mapping.

rex-tls transport attributes

Attribute Value
http_version HTTP/1.1, HTTP/2, or HTTP/3.
elapsed_seconds Request duration as a floating-point number of seconds.
local_address Local transport endpoint when available.
remote_address Remote transport endpoint when available.
connection_reused Whether an existing connection carried this request.
tls_session_reused Whether the TLS handshake resumed an earlier session.
closed Whether response resources are closed.
consumed Whether the body has been consumed.
content_decoded Whether the native streaming path decoded the body.
response = session.get("https://example.com/account")

print(response.status_code, response.elapsed)
print(response.request.method, response.request.path_url)
print(response.cookies.get_dict())
print(response.http_version, response.connection_reused)

Cancellation and connection control

Session, AsyncSession, and both pool types expose:

  • cancel() to cancel active native work.
  • close_origin(url) to remove and close the cached route for one origin.
  • close() to permanently close the client.

AsyncSession and AsyncSessionPool also provide acancel() and aclose_origin(); their close() methods are awaited. Response provides aclose() for asynchronous stream cleanup. Cancelling one queued pool lease or one HTTP/2 stream does not cancel unrelated callers.

Error handling

import rex_tls

try:
    response = rex_tls.get(
        "https://example.com/",
        profile="chrome_android_150",
        timeout=10,
    )
    response.raise_for_status()
except rex_tls.HTTPError as exc:
    print(f"HTTP error: {exc}")
except rex_tls.RequestError as exc:
    print(f"Request failed: {exc}")
    print(exc.protocol, exc.phase, exc.code, exc.retryable)
except rex_tls.MobileTLSError as exc:
    print(f"Native client error: {exc}")
except (TypeError, ValueError) as exc:
    print(f"Invalid configuration: {exc}")

Public exception types:

Exception Meaning
MobileTLSError Base native-core error.
RequestError Transport or request execution failed.
HTTPError raise_for_status() received a 4xx or 5xx response.
InvalidURL URL is malformed or unsupported.
InvalidHeader Header cannot be represented safely on the selected wire protocol.
CookieConflictError Cookie lookup matched multiple domain/path identities.
ContentDecodingError Compressed response is malformed, truncated, or over its limit.
InvalidJSONError Base response JSON parsing error.
JSONDecodeError Requests-compatible JSON parsing error with response context.
SessionClosedError Operation used a permanently closed client.
StreamClosedError Body stream was read after close.
StreamConsumedError One-shot body stream was consumed more than once.
UnrewindableBodyError Redirect required replaying a one-shot upload.

InvalidJSONError and JSONDecodeError both describe invalid response JSON and retain response/request context.

Every RequestError exposes bounded diagnostic fields:

Field Meaning
protocol HTTP/1.1, HTTP/2, HTTP/3, TLS, SOCKS5, or None.
phase Stable processing stage such as handshake, settings, flow control, response framing, proxy authentication, or request.
code Stable error category intended for application logging and policy.
retryable True only when the transport can prove the request was not processed.

rex-tls does not automatically replay a request body merely because retryable is true. The application remains responsible for deciding whether its operation is safe to retry.

Requests compatibility

Supported Requests-style behavior includes:

  • Top-level request and method helpers.
  • Persistent Session state and generic Session.request().
  • Query parameters, mappings, ordered duplicate headers, form data, JSON, multipart files, request cookies, and streamed bodies.
  • Mutable Session.headers, Session.proxies, and Session.cookies.
  • Numeric and split timeouts, redirects, certificate verification, HTTP and SOCKS5 proxies, and environment proxy discovery.
  • Buffered and streaming Response content, text, JSON, headers, cookies, elapsed time, history, prepared request metadata, links, and status helpers.

The following Requests extension points are not implemented:

  • Authentication handler objects passed through auth=.
  • Response hooks.
  • Transport adapters, adapter mounting, and custom adapter routing.
  • Custom RequestsCookieJar policy objects.
  • PreparedRequest mutation followed by Session.send().

Applications that depend on one of these extension points must keep Requests or adapt that integration before switching clients.

TLS verification

Certificate verification is enabled by default and uses the installed certifi CA bundle:

# Default CA bundle.
Session("chrome_android_150", verify=True)

# Custom CA file.
Session("chrome_android_150", verify="/path/to/private-ca.pem")

# Disable verification explicitly.
Session("chrome_android_150", verify=False)

Disabling verification removes server identity protection and should be limited to controlled test endpoints.

Performance

Practical summary

Reuse one Session for repeated calls. It avoids repeated Python setup, reuses TCP/TLS connections, retains TLS session tickets, and allows H2 or H3 multiplexing. AsyncSession is the normal choice for asyncio. Use a pool when the application needs independent state or several H1 connections; adding a pool does not automatically improve an already multiplexed H2 connection.

Controlled localhost measurements show that no client is fastest in every scenario:

  • rex-tls has low warm-request overhead and scales strongly in the measured H1 concurrency cases.
  • never_primp was slightly faster than rex-tls for the measured single H1 request and remained competitive under concurrency.
  • curl_cffi was faster in the measured medium-concurrency H2 case, while the rex-tls pool was faster at the measured high-concurrency H2 point.
  • httpcloak was slower than rex-tls in the measured H1 points.
  • H2 and H3 stability, connection count, memory use, and protocol behavior should be considered together with raw throughput.

Same-host comparison

The H1 table used a warm client, a 4 KiB response, 100 samples, and the same Windows 11 / CPython 3.14 host. RPS means completed requests per second; higher is better.

Client Concurrency 1 Concurrency 16
rex-tls Session 9,833 RPS 6,385 RPS
rex-tls SessionPool 9,434 RPS 8,037 RPS
curl_cffi 4,829 RPS 2,706 RPS
requests 1,957 RPS 1,676 RPS
never_primp 10,166 RPS 7,567 RPS
httpcloak 3,335 RPS 3,086 RPS

The controlled H2 table used a 1 ms server delay and 100 samples:

Client Concurrency 1 Concurrency 16 Concurrency 64
rex-tls SessionPool 64 RPS 930 RPS 3,012 RPS
curl_cffi 64 RPS 3,444 RPS 1,241 RPS

These are implementation-overhead tests, not a promise for public websites. DNS, TLS handshakes, proxies, server latency, security software, response size, and network quality can change the ranking. Compare clients on the actual workload before choosing one solely for speed.

A 256 MiB streaming upload and download remained incremental in the local regression suite rather than buffering the whole body in Python.

Benchmark definitions, limitations, and reproducible commands are in the performance guide.

Runtime information

import rex_tls

print(rex_tls.__version__)
print(rex_tls.profiles())
print(dict(rex_tls.native_versions()))
print(rex_tls.profile_info("chrome_android_150"))

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

rex_tls-2.11.0-cp39-abi3-win_amd64.whl (2.8 MB view details)

Uploaded CPython 3.9+Windows x86-64

rex_tls-2.11.0-cp39-abi3-manylinux_2_28_x86_64.whl (2.8 MB view details)

Uploaded CPython 3.9+manylinux: glibc 2.28+ x86-64

File details

Details for the file rex_tls-2.11.0-cp39-abi3-win_amd64.whl.

File metadata

  • Download URL: rex_tls-2.11.0-cp39-abi3-win_amd64.whl
  • Upload date:
  • Size: 2.8 MB
  • Tags: CPython 3.9+, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.0

File hashes

Hashes for rex_tls-2.11.0-cp39-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 ff2f95731c32cfb6d4376eeac4190e095930aca166a957bbbae27e67d4951c54
MD5 8584b79ba88c7098c3d248b1b6545021
BLAKE2b-256 427155b3b03437bfef04b665deeaa13c05e538e3b1bddc0694b38ccd84dc0cbc

See more details on using hashes here.

File details

Details for the file rex_tls-2.11.0-cp39-abi3-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for rex_tls-2.11.0-cp39-abi3-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 21efa21c5407dea7a6843ec7fb5fb3018e0d7b1ad07bb1c1faa112a431575a6d
MD5 dd51b6ef066b5453468b7ce88b70d7ad
BLAKE2b-256 f6dbd626fa4aaf45a9fb3e81c90e979a44918fd5a248377dbefeb3bee13c2018

See more details on using hashes here.

Release history Release notifications | RSS feed

2.24.1

2 files

2.24.0

2 files

2.23.0

2 files

2.22.0

2 files

2.21.0

2 files

2.20.2

2 files

2.20.1

2 files

2.20.0

2 files

2.19.2

2 files

2.19.1

2 files

2.19.0

4 files

2.18.1

4 files

2.18.0

2 files

2.17.0

2 files

2.16.3

2 files

2.16.2

2 files

2.16.1

2 files

2.16.0

2 files

2.15.2

2 files

2.15.1

2 files

2.15.0

2 files

2.14.0

2 files

2.13.0

2 files

2.12.0

2 files

This release

2.11.0 This release

2 files

2.10.1

2 files

2.10.0

2 files

2.9.0

2 files

2.8.0

2 files

2.7.0

2 files

2.6.0

2 files

2.5.0

2 files

2.4.0

2 files

2.3.2

2 files

2.3.1

2 files

2.3.0

2 files

2.2.1

2 files

2.2.0

2 files

2.1.0

2 files

2.0.0

2 files

0.1.1

5 files

0.1.0

5 files

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