Metadata-Version: 2.4
Name: rex-tls
Version: 2.24.1
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Dist: certifi>=2024
Requires-Dist: charset-normalizer>=3,<4
Requires-Dist: brotli>=1.1,<2 ; extra == 'dev'
Requires-Dist: maturin>=1.14.1,<2 ; extra == 'dev'
Requires-Dist: pytest>=8,<10 ; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24,<2 ; extra == 'dev'
Requires-Dist: readme-renderer[md]>=45,<46 ; python_full_version >= '3.10' and extra == 'dev'
Requires-Dist: requests>=2.32,<3 ; extra == 'dev'
Requires-Dist: trustme>=1.2.1,<2 ; extra == 'dev'
Requires-Dist: websocket-client>=1.8,<2 ; extra == 'dev'
Requires-Dist: zstandard>=0.23,<1 ; extra == 'dev'
Requires-Dist: h2>=4.3,<4.4 ; python_full_version < '3.10' and extra == 'dev'
Requires-Dist: h2>=4.4,<5 ; python_full_version >= '3.10' and extra == 'dev'
Provides-Extra: dev
License-File: LICENSE
License-File: LICENSE-QUICHE
License-File: LICENSE-RAPIDHASH
Summary: Cross-platform Python HTTP client with native PC and mobile TLS, HTTP/2, and HTTP/3 transport profiles
Keywords: tls,http2,http3,pc,windows,android,ios,chrome,webview,cronet,okhttp,ktor,flutter,dart,boringssl,cross-platform,custom-profile
Home-Page: https://github.com/rex3129909440/rex-tls
Author: rex-tls contributors
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Documentation, https://github.com/rex3129909440/rex-tls#readme
Project-URL: Homepage, https://github.com/rex3129909440/rex-tls
Project-URL: Repository, https://github.com/rex3129909440/rex-tls

# rex-tls

rex-tls is a cross-platform Python HTTP client with a native Rust TLS and HTTP
core. It offers a familiar Requests-style API and versioned transport profiles
for PC and mobile clients, including PC Chrome and Edge, Android Chrome, WebView,
Cronet, OkHttp, Ktor, HttpsURLConnection/Conscrypt, and Flutter on Android and
iOS.
It includes 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.

`rex_tls` is the Python package and import name. It owns the Python API,
type declarations, and native extension for both PC and mobile transports.

## Highlights

- Versioned Android Chrome 149 and 150 profiles with HTTP/1.1, HTTP/2, and
  HTTP/3 support.
- Windows Chrome 148, 152, and 153 profiles with HTTP/1.1, HTTP/2, and HTTP/3
  support.
- Independent Windows Edge 151, 152, and 153 profiles with HTTP/1.1, HTTP/2, and HTTP/3
  request templates.
- 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.
- Three Android Ktor 3.5.2 profiles covering the CIO, Android URLConnection,
  and OkHttp 5.3.2 engines.
- A platform Android 11 HttpsURLConnection profile and four hash-locked,
  application-bundled Conscrypt profiles with verified HTTP/1.1 behavior.
- 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.
- Five versioned Flutter iOS profiles from 3.35.7 through 3.47.1, each built,
  signed, captured, and validated on a physical iOS device.
- 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.24.1

- Fixed desktop Chrome/Edge 153 HTTP/1.1 preconnect accounting while retired
  connections are still delivering responses, preserving TLS ticket reuse.
- Added bounded HTTP/3 recovery for explicitly rejected requests on Chrome/Edge
  153, including buffered POST bodies.
- Fixed QPACK cancellation feedback after a peer resets a request stream.
- Preserved service-failure and RTT state after successful HTTP/3 recovery so
  subsequent connections wait for handshake confirmation when required.

```python
import rex_tls

with rex_tls.Session("chrome_153") as chrome:
    response = chrome.get("https://example.com/")
    print(response.status_code, response.http_version)

with rex_tls.Session("edge_153") as edge:
    response = edge.get("https://example.com/")
    print(response.status_code, response.http_version)
```

Earlier changes are listed in [CHANGELOG.md](CHANGELOG.md).

## Installation

```bash
python -m pip install rex-tls
```

Version 2.24.1 provides binary wheels for Windows x86-64 and Linux x86-64.

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 |
| `chrome_153` | Yes | Yes | Yes | gzip, deflate, Brotli, zstd, stacked codings |
| `chrome_152` | Yes | Yes | Yes | gzip, deflate, Brotli, zstd, stacked codings |
| `chrome_148` | Yes | Yes | Yes | gzip, deflate, Brotli, zstd, stacked codings |
| `edge_153` | Yes | Yes | Yes | gzip, deflate, Brotli, zstd, stacked codings |
| `edge_152` | Yes | Yes | Yes | gzip, deflate, Brotli, zstd, stacked codings |
| `edge_151` | 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 |
| `flutter_ios_3.35.7` | Yes | No | No | one exact lowercase gzip value; representation Headers remain visible |
| `flutter_ios_3.38.10` | Yes | No | No | one exact lowercase gzip value; representation Headers remain visible |
| `flutter_ios_3.41.9` | Yes | No | No | one exact lowercase gzip value; representation Headers remain visible |
| `flutter_ios_3.44.9` | Yes | No | No | one exact lowercase gzip value; representation Headers remain visible |
| `flutter_ios_3.47.1` | Yes | No | No | one exact lowercase gzip value; representation Headers remain visible |
| `httpsurlconnection_android_api30` | Yes | No | No | gzip |
| `conscrypt_android_2.5.2` | Yes | No | No | gzip |
| `conscrypt_android_2.5.3` | Yes | No | No | gzip |
| `conscrypt_android_2.6.0` | Yes | No | No | gzip |
| `conscrypt_android_2.6.3` | Yes | No | No | gzip |
| `ktor_android_cio_3.5.2` | Yes | No | No | caller-selected decoding; CIO sends no default Accept-Encoding |
| `ktor_android_urlconnection_3.5.2` | Yes | No | No | gzip |
| `ktor_android_okhttp_3.5.2` | Yes | Yes | No | gzip |

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 `chrome_152` for Windows Chrome 152 Stable application-layer TLS,
  H1/H2/H3, header-order, priority, and QPACK behavior.
- Use `chrome_153` or `edge_153` for the corresponding Windows desktop 153
  profile, including navigation, fetch, TLS resumption and H1/H2/H3 support.
- Use `edge_152` for Windows Edge 152. Its TLS and HTTP templates are
  independently selected from Chrome's.
- 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 the matching
  WebView provider. The request policies cover main documents, frames,
  subresources, fetch/XHR, CORS, and request bodies.
- 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 `flutter_ios_3.35.7`, `flutter_ios_3.38.10`, `flutter_ios_3.41.9`,
  `flutter_ios_3.44.9`, or `flutter_ios_3.47.1` for the matching Flutter iOS
  transport. These are not aliases of the Android profile.
- Use `httpsurlconnection_android_api30` for the Android 11 platform-default
  HttpsURLConnection stack. Use a versioned `conscrypt_android_*` profile only
  when the application explicitly installs that Conscrypt AAR as its TLS
  provider.
- Use `ktor_android_cio_3.5.2`, `ktor_android_urlconnection_3.5.2`, or
  `ktor_android_okhttp_3.5.2` for a Ktor 3.5.2 application using that exact
  engine. The engine name matters: Ktor does not have one shared TLS stack.
- 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.

### PC Edge 152

```python
import rex_tls

with rex_tls.Session(profile="edge_152", http2=True, timeout=20) as session:
    response = session.get("https://example.com/")
    print(response.status_code, response.http_version)
    print(session.cookies.get_dict())
```

With no supplied headers, the profile uses a Windows Edge navigation
template. For fetch/XHR, supply the fields of that request, including
`sec-fetch-mode` and `sec-fetch-dest`; the protocol priority follows those
fields. Supplied header sets are reordered without adding a full navigation
header set. Defaults never inject `Pragma` or `Cache-Control`.

`http2=True` requires HTTP/2. To require HTTP/3 instead, use
`rex_tls.Session(profile="edge_152", http3="only")`. HTTP and SOCKS5 proxy
options use the same API as other profiles; those proxy routes use H1/H2,
not HTTP/3. Certificate checks remain enabled by default.

The verified templates cover TLS fixed fields, HTTP header encoding and
ordering, and H3 application streams. They do not claim identical network
latency, packet timing, congestion trajectories, or every QUIC loss-repair
sequence across machines and networks.

### PC Chrome 152

Use the exact versioned name:

```python
import rex_tls

with rex_tls.Session(profile="chrome_152", timeout=20) as session:
    response = session.get("https://example.com/")
    print(response.http_version, response.status_code)
```

The default request shape is a top-level Windows Chrome navigation. For a
fetch-style request, pass the corresponding browser fields; rex-tls derives
the H2 weight and H3 PRIORITY_UPDATE value from them:

```python
fetch_headers = {
    "accept": "*/*",
    "sec-fetch-site": "same-origin",
    "sec-fetch-mode": "cors",
    "sec-fetch-dest": "empty",
}

with rex_tls.Session(profile="chrome_152", http3="only") as session:
    response = session.get(
        "https://example.com/api/items",
        headers=fetch_headers,
        navigation_site="same-origin",
        user_activation=False,
    )
```

H1 preserves Chrome's observed field-name casing, including lowercase
`sec-ch-ua`, `sec-ch-ua-mobile`, and `sec-ch-ua-platform`. H2 and H3 require
all field names to be lowercase. Caller-provided header sets are reordered but
are not expanded into a complete browser request; provide every application
field that the target request class requires.

### Android Ktor 3.5.2

```python
import rex_tls

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

with rex_tls.Session("ktor_android_okhttp_3.5.2", http2=True) as session:
    response = session.get("https://example.com/api")
```

`ktor_android_cio_3.5.2` is an independent TLS 1.2 and HTTP/1.1 transport with
no ALPN. `ktor_android_urlconnection_3.5.2` uses the Android platform TLS
family and Ktor's distinct H1 headers. `ktor_android_okhttp_3.5.2` resolves
OkHttp 5.3.2, uses the audited OkHttp Android 5.x ClientHello/H2 settings, and
adds Ktor's `accept` and `user-agent` fields before OkHttp's
`accept-encoding`.

For four simultaneous cold calls, both the device engine and rex-tls open four
candidate TLS connections but carry all requests on one H2 connection. Losing
candidates flush the same H2 prelude and close without carrying a request. A
stale reused connection may be retried once only for a bodyless GET or HEAD.

All three support the normal HTTP and SOCKS5 proxy arguments. None has an
audited HTTP/3 path. `http2=True` is accepted only for the OkHttp engine.

The device CIO implementation sent an IP literal in SNI and accepted a
certificate-name mismatch. rex-tls reproduces the SNI shape but keeps
`verify=True` as its security default. Detailed evidence and method-specific
header tables are in [the Android Ktor profile document](docs/84-android-ktor-3.5.2-profiles.md).

### Flutter Android 3.47.1

```python
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](docs/71-flutter-android-3.47.1-profile.md).

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.

### Flutter iOS 3.35.7 through 3.47.1

```python
import rex_tls

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

print(response)
print(response.http_version)
```

The versioned iOS profiles cover Flutter 3.35.7 / Dart 3.9.2, Flutter 3.38.10 /
Dart 3.10.9, Flutter 3.41.9 / Dart 3.11.5, Flutter 3.44.9 / Dart 3.12.2, and
Flutter 3.47.1 / Dart 3.13.1. All use the default `dart:io HttpClient`. Dio
5.11.0 with `IOHttpClientAdapter` produces the same ClientHello and HTTP/1.1
transport behavior in every captured version.

The iOS and Android profiles share the Dart extension order, supported groups,
signature algorithms, lack of ALPN, HTTP/1.1 Header rules, 15-second idle
boundary, and response decoding. They are still separate because iOS prefers
AES-GCM cipher suites before ChaCha20 and therefore has a different JA3.

Each version passed three cold-process dart:io samples. It also passed three cold-process Dio samples,
four concurrent connections, TLS 1.2, TLS 1.3 HelloRetryRequest,
certificate rejection, direct reuse, the 14/16-second idle boundary, and a
service-side HTTP/1.1 Header check. The full contracts are in
[the Flutter iOS profile document](docs/79-flutter-ios-3.47.1-profile.md) and
[the five-version matrix](docs/80-flutter-ios-version-matrix.md).

The generic aliases `flutter` and `flutter_latest` continue to select the
Android profile for backward compatibility. Select `flutter_ios_3.47.1`
explicitly for iOS.

### Android HttpsURLConnection and Conscrypt

```python
import rex_tls

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

print(response)
print(response.http_version)  # HTTP/1.1
```

The bundled-provider profiles are selected explicitly:

```python
with rex_tls.Session("conscrypt_android_2.6.3") as session:
    response = session.get("https://example.com/api")
```

The platform profile and Conscrypt 2.5.2/2.5.3 advertise TLS 1.3 through TLS
1.0. Conscrypt 2.6.0/2.6.3 advertise TLS 1.3 and TLS 1.2. Cipher order,
extension order, signature algorithms, groups, key share, and HTTP/1.1 ALPN
are otherwise equal in the verified cold captures. The profiles do not offer
HTTP/2 or HTTP/3, so `http2=True` and `http3="only"` are rejected.

With empty caller Headers, the H1 order is `User-Agent`, `Host`, `Connection`,
then `Accept-Encoding`. A known-length body places `Content-Type` and
`Content-Length` first. The real Dalvik User-Agent contains device/build data;
rex-tls uses a privacy-neutral Android 11 value by default. Pass the original
application's complete `User-Agent` when exact application Header values are
required.

The default pool permits five same-route connections. Sequential requests
reuse one connection; a forced second connection can resume TLS 1.3. HTTP
CONNECT preserves the direct ClientHello and sends CONNECT Headers in the
verified `Host`, `Proxy-Connection`, `User-Agent` order. Detailed evidence and
the profile boundary are documented in
[the Android Conscrypt profile document](docs/81-android-conscrypt-profiles.md).

### Cronet 151

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

```python
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

```python
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](docs/75-webview-140-147-profiles.md).

## 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

```python
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

```python
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()` describe only the 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

```python
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.

```python
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.

```python
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:

```python
from rex_tls import Session

with Session("okhttp_5.4", timeout=20) as session:
    session.headers.update({"accept": "application/json"})
    session.params.update({"api-version": "2026-08"})
    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.params` supplies default query parameters for every request. A
request mapping overrides matching Session keys and removes a default when its
value is `None`. An ordered request sequence is used as-is, matching requests.
`session.max_redirects` is readable and may be changed after construction.

### Session options

```python
session = Session(
    profile="chrome_android_150",
    timeout=30,
    total_timeout=None,
    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. |
| `total_timeout` | Optional absolute request deadline. `None` disables the total deadline. |
| `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. |
| `total_timeout` | Override the optional absolute deadline for this request. |
| `verify` | Override certificate verification for this request using an isolated native connection pool. |
| `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 the same connect and response-read inactivity
limit. It is not a wall-clock limit for the complete request:

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

A pair separates the connect and response-read inactivity limits:

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

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

Use `total_timeout` only when the complete operation must have one absolute
deadline. It is independent from the phase limits and includes redirects:

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

HTTP/3 derives an internal connection/read budget from the phase values when
`total_timeout` is absent. An explicit total deadline has precedence.

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

```python
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:

```python
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 for the Android Chrome, WebView, OkHttp, and custom profiles. It can
  still generate fields required by the URL, cookies, body, or wire protocol.
- PC profiles `chrome_148`, `chrome_152`, `edge_151`, and `edge_152` merge their
  default headers with every supplied header set over H1/H2/H3. Caller values
  take precedence case-insensitively. This includes
  `Accept-Encoding: gzip, deflate, br, zstd`; use `Accept-Encoding: identity`
  to request no compression. These are profile defaults, not HTTP/2 defaults.
- When a PC request supplies a Chromium User-Agent, missing `sec-ch-ua`,
  `sec-ch-ua-mobile`, and `sec-ch-ua-platform` are generated from that UA using
  the brand ordering/GREASE algorithms in `tools/ua.py` and `tools/ua2.py`.
  Existing Client Hints are preserved. The selected TLS profile is unchanged.
- Context-dependent fields still follow browser behavior: CORS preflights do
  not receive generated Client Hints, and iframe navigation does not imply a
  user activation. Pass `user_activation=True` for an explicitly activated
  iframe navigation. Explicit header values continue to take precedence.
- Full-version and other additional hints are opt-in through
  `rex_tls.generate_client_hints(ua, full=True, options=...)`. Reduced UA
  versions are not expanded using historical lookup tables. Provide observed
  full versions/OS details through `ClientHintOptions` for precise values.
- Chrome and WebView defaults never add `Pragma` or `Cache-Control`. If a
  caller explicitly supplies either field, rex-tls preserves it and only
  places it in the selected request-class order.
- 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:

```python
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 observed fetch order over H1 and H2.
The selected Android TLS, ALPN, H2/H3 settings, and connection behavior remain
unchanged when desktop Header ordering is selected.

### 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`.

```python
# 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:

```python
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:

```python
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](docs/53-h1-h2-h3-header-contract.md).

## Cookies

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

```python
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())

    for cookie in session.cookies:
        print(cookie.name, cookie.value, cookie.domain, cookie.path)

    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. |
| `copy()` | Return a detached scoped CookieJar copy. |
| `clear(...)` | Remove one cookie, a scoped group, or the complete jar. |
| `clear_session_cookies()` | Remove cookies without a persistent expiry. |
| `clear_expired_cookies()` | Remove cookies whose expiry has elapsed. |
| `keys()`, `values()`, `items()` | Inspect stored cookies. |
| `iterkeys()`, `itervalues()`, `iteritems()` | Return requests-compatible iterators. |
| `for cookie in jar` | Iterate `http.cookiejar.Cookie` objects with full scope and metadata. |
| `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:

```python
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:

```python
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:

```python
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:

```python
# 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

```python
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:
`edge_152` supports the same mode options.

| 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. |

```python
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("chrome_152", 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:

```python
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:

```python
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`:

```python
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:

```python
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.

For eligible H2 and H3 sessions, buffered requests, redirects, request bodies,
query parameters, request Cookies, proxies, split timeouts, streaming
responses, and streaming uploads use one bounded completion dispatcher.
Completion IDs keep response finalization, cancellation, timeout, and transport
errors scoped to their request. Unsupported profiles and request shapes still
use the compatibility executor; no option is silently removed.

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

```python
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.

```python
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:

```python
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:

```python
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. |

```python
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.
- `retire_origin(url)` or `retire_connections()` to retire cached connections
  while allowing the session to continue serving new requests.
- `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.

Desktop Chrome 153 and Edge 153 sessions also support explicit TCP preconnect:

```python
with rex_tls.Session("chrome_153", http3="off") as session:
    session.preconnect_tcp("https://example.com/", count=1)
    response = session.get("https://example.com/")
```

## Error handling

```python
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. |
| `ConnectionError` | DNS, TCP, proxy, or TLS connection setup failed. |
| `ProxyError` | HTTP CONNECT or SOCKS5 negotiation was rejected or lost. |
| `SSLError` | TLS negotiation, verification, or encrypted transport failed. |
| `Timeout` | Base class for every timeout category. |
| `ConnectTimeout` | DNS, TCP, proxy, or TLS setup exceeded its phase timeout. |
| `ReadTimeout` | Response progress stopped for longer than the read timeout. |
| `WriteTimeout` | Request or protocol bytes could not be written within the timeout. |
| `TotalTimeout` | The explicitly configured `total_timeout` elapsed. |
| `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` | `DNS`, `TCP`, `HTTP CONNECT`, `SOCKS5`, `TLS`, `HTTP/1.1`, `HTTP/2`, `HTTP/3`, or `None`. |
| `phase` | Stable stage such as `dns_resolution`, `tcp_connect`, `proxy_connect`, `tls_handshake`, `response_read`, `response_framing`, or `total`. |
| `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.params, Session.proxies, Session.cookies,
  and Session.max_redirects.
- 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:

```python
# 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)
```

Certificate verification may also be overridden for one request. A different
verification policy always uses an isolated native connection pool, so a TLS
connection created with `verify=False` can never be reused by verified work:

```python
with Session("chrome_android_150", verify=True) as session:
    diagnostic = session.get("https://localhost/", 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.

For eligible H2 and H3 calls, AsyncSession uses one completion dispatcher
instead of one Python executor worker per in-flight request. In the controlled
2.18.0 measurements, H2 stayed at four measured Python threads through
concurrency 128, while H3 stayed at two instead of growing to 129. Unsupported
profiles and extension points retain the compatibility executor.

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](docs/34-performance.md).

## Runtime information

The `profile` argument exposes built-in names and aliases as typed completion
candidates in supported editors, including session constructors and top-level
`get`/`post` helpers. For example, typing `profile="c"` offers Chrome, Cronet,
and Conscrypt names. The `rex_tls` package ships a `py.typed` marker;
computed string values remain supported. Full public API declarations also
cover forwarded request parameters, response members, pools, and async streams;
see [editor completion](docs/100-editor-completion.md) for setup and verification.

```python
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
