# wafer

> Anti-detection HTTP client for Python wrapping wreq (Rust + BoringSSL).

This file is for LLMs writing code that uses wafer. It contains the exact
API surface, types, defaults, constraints, and common mistakes. Read this
instead of guessing from the README or training data.

Package name on PyPI: `wafer-py`. Import name: `wafer`. Python >=3.12.

## Install Modes

There are exactly two install modes:

```bash
pip install wafer-py            # core
pip install wafer-py[browser]   # core + browser solving
```

**Core** (`wafer-py`) provides the TLS client with wreq browser emulations,
automatic challenge detection, retry with fingerprint rotation, cookie caching,
rate limiting, and inline solving for challenges that don't need JavaScript
(ACW, Amazon CAPTCHA, TMD, Reddit).

**Browser** (`wafer-py[browser]`) adds a real Chrome browser solver for
challenges that require JavaScript execution (Cloudflare Turnstile, DataDome,
PerimeterX, Kasada, AWS WAF, hCaptcha, reCAPTCHA, etc). The
`from wafer.browser import BrowserSolver` import requires this extra.

**Upgrading from rnet:** wafer's underlying HTTP library was renamed from `rnet` to
`wreq`. If upgrading from an older wafer version, uninstall rnet first:
`pip uninstall rnet` (or `uv pip uninstall rnet`). Then reinstall wafer normally.
New installs need no extra steps.

---

## Quick Example

```python
import wafer
from wafer import SyncSession, AsyncSession, ChallengeDetected, RateLimited

# One-shot (creates and tears down a session per call):
resp = wafer.get("https://example.com")

# Session (reuses TLS identity, cookies, fingerprint across requests):
with SyncSession(rate_limit=2.0) as session:
    try:
        resp = session.get("https://protected-site.com")
        resp.raise_for_status()
        data = resp.json()
    except ChallengeDetected as e:
        ...  # e.challenge_type, e.url, e.status_code, e.response (final WaferResponse)
    except RateLimited as e:
        ...  # e.retry_after (seconds or None), e.response (final WaferResponse)

# Async:
async with AsyncSession() as session:
    resp = await session.get("https://example.com")
```

For multiple requests, prefer a session so the TLS identity, connection state,
cookie jar, retry state, and rate limiter are reused.

---

## Public API

```python
from wafer import (
    SyncSession,       # synchronous session
    AsyncSession,      # async session (same API; request methods are coroutines)
    WaferResponse,     # response object
    Profile,           # enum: OPERA_MINI, SAFARI, IOS_SAFARI, DART
    DEFAULT_HEADERS,   # dict[str, str] - default Accept/Accept-Language/etc headers
    __version__,       # str - installed version ("0.0.0" if not installed)
    # Fingerprint helpers (supported public surface - do NOT reach into
    # wafer._fingerprint; these are the stable path):
    sec_ch_ua,                 # build a sec-ch-ua header value (see below)
    full_version,              # real Chrome full version for a major (e.g. "147.0.7727.24")
    chrome_full_version,       # exact Chrome version for an Emulation, or None
    emulation_family,          # classify a wreq.Emulation into a browser family
    emulation_is_mobile,       # True if an Emulation is a mobile profile
    build_fingerprint_envelope,# coherent identity dict for any Emulation
    # Errors (all inherit WaferError):
    WaferError,
    WaferHTTPError,    # raised by raise_for_status() on non-2xx
    WaferTimeout,      # also inherits TimeoutError
    ChallengeDetected, # WAF challenge unsolvable after all retries
    RateLimited,       # HTTP 429 after all retries
    ConnectionFailed,  # network/TLS error after all retries
    EmptyResponse,     # 200 with empty body after all retries
    TooManyRedirects,  # redirect loop exceeded max_redirects
    TokenMintFailed,   # mint_recaptcha_v3() could not extract a token
    ResponseTooLarge,  # body exceeded max_response_size cap
)

# Module-level convenience (each creates a one-shot SyncSession with defaults).
# These are SYNC ONLY - there are no async module-level functions.
# **kwargs are per-request kwargs only (headers, params, timeout, attempt_timeout, max_response_size, json, form, body).
# Session-constructor kwargs (rate_limit, proxy, etc.) are NOT accepted here.
wafer.get(url, **kwargs) -> WaferResponse
wafer.post(url, **kwargs) -> WaferResponse
wafer.put(url, **kwargs) -> WaferResponse
wafer.delete(url, **kwargs) -> WaferResponse
wafer.head(url, **kwargs) -> WaferResponse
wafer.options(url, **kwargs) -> WaferResponse
wafer.patch(url, **kwargs) -> WaferResponse
```

---

## Session Constructor

`SyncSession` and `AsyncSession` accept identical kwargs. All optional.
Both support context managers (`with` / `async with`).

```python
SyncSession(
    # TLS identity (any wreq Emulation profile). Default: Emulation.Chrome149 (newest).
    # Non-Chrome families get a matching HTTP header envelope automatically:
    #   Emulation.Edge148    -> Chromium headers, sec-ch-ua brand "Microsoft Edge"
    #   Emulation.Firefox151 -> Firefox Accept/Accept-Language, NO sec-ch-ua
    # See "Fingerprint Identity". On 403/challenge, rotation escalates ACROSS
    # families (Chrome -> Firefox -> Safari -> Edge) before cycling versions -
    # see "Rotation escalation". A non-Chrome starting emulation just changes
    # the starting family; the same cross-family ladder still applies.
    emulation: wreq.Emulation | None = None,  # default: Emulation.Chrome149 (newest)

    # Non-Chrome profiles (overrides emulation)
    profile: Profile | None = None,           # OPERA_MINI, SAFARI, IOS_SAFARI, or DART
    safari_locale: str = "us",                # "us" or "ca" (SAFARI and IOS_SAFARI)

    # Custom headers (replaces DEFAULT_HEADERS entirely if provided).
    # Prefer per-request headers= kwarg for one-off overrides. Header names are
    # matched case-insensitively, so a per-request "accept" replaces the
    # session's "Accept" rather than being sent alongside it.
    headers: dict[str, str] | None = None,

    # Timeouts (int, float seconds, or datetime.timedelta)
    connect_timeout=10,   # default: 10s
    timeout=30,           # default: 30s. The TOTAL budget for the whole call -
                          # all retries, rotations, and browser solves - whether
                          # set here or per-request. NOT per-attempt like
                          # requests/httpx. One hanging attempt can eat the whole
                          # budget unless you also set attempt_timeout=.
    attempt_timeout=None, # default: None (no per-attempt cap). Caps each individual
                          # attempt so retries/rotations fire while a server hangs.
                          # Overridable per-request.

    # Retry behavior. WARNING: the defaults replay the WHOLE request up to
    # max_retries + max_rotations times (up to 6x by default). For stateful
    # multi-step flows (login, cart, checkout) that is hazardous - lower these
    # or use bulk(). See "Stateful request replay" below.
    max_retries: int = 3,       # for 5xx, connection errors, empty 200
    max_rotations: int = 2,     # for 403/challenge (see rotation escalation below)

    # Session health
    max_failures: int | None = 3,  # consecutive failures per hostname before full identity reset; None to disable.
                                    # IGNORED when fingerprint_pool is set (a pool is never retired).

    # Response-size cap (memory safety). None = no cap (default; behavior
    # unchanged). When set, a response body over this many bytes raises
    # ResponseTooLarge. Enforced two ways: a declared Content-Length over the
    # cap raises BEFORE the body is read; otherwise the body is read and
    # aborted EARLY once the running total passes the cap (the oversize body
    # is never fully buffered). Measures the DECOMPRESSED body, and the
    # decompressor output is bounded too, so a gzip/deflate bomb cannot expand
    # past the cap. Applies to EVERY transport: the normal wreq path, the
    # Opera Mini path, the Imperva native-TLS bypass, and the browser
    # passthrough body. Overridable per-request.
    max_response_size: int | None = None,

    # Fingerprint pool (opt-in, additive). A fixed list of Emulation identities
    # to rotate through on failure INSTEAD of the default cross-family ladder.
    # See "Fingerprint pool" below.
    fingerprint_pool: list[wreq.Emulation] | None = None,

    # Cookie persistence (path is relative to CWD; use absolute path for consistency)
    cache_dir: str | None = None,  # disk path for solver cookie persistence; None = in-memory only

    # Rate limiting (per session, per hostname - "example.com" and "api.example.com" are separate.
    # Two sessions hitting the same host enforce their limits independently.)
    rate_limit: float = 0.0,    # min seconds between requests to same hostname.
                                # 0.0 disables it; None is not accepted and a
                                # non-numeric or negative value raises at
                                # construction.
    rate_jitter: float = 0.0,   # random 0..jitter added to interval

    # TLS session rotation
    rotate_every: int | None = None,  # rebuild TLS session every N requests

    # Redirects (304 Not Modified is NOT treated as a redirect - it passes through)
    follow_redirects: bool = True,
    max_redirects: int = 10,

    # Proxy
    proxy: str | None = None,   # "socks5://user:pass@host:port", "http://...", etc.

    # DNS pinning (SSRF guard): pre-validated host -> list of IP strings.
    # Socket connects to these IPs; TLS SNI + cert still key on the hostname.
    # Construction-only (no per-request form); an empty IP list raises;
    # combining with proxy= raises (a proxy resolves the host, voiding the pin).
    resolve: dict[str, list[str]] | None = None,

    # Embed mode (both modes select a random Referer from embed_referers)
    embed: str | None = None,           # "xhr", "xhr-jquery", or "iframe"
    embed_origin: str | None = None,    # Origin header value
    embed_referers: list[str] | None = None,  # random Referer picked per request

    # Browser solver (requires [browser] extra)
    browser_solver=None,  # BrowserSolver instance or None
    solve_origin: str | None = None,  # origin page the auto-solve navigates to
                                      # mint the WAF token (for JSON/XHR APIs)
)
```

### Bulk mode constructor

```python
session = SyncSession.bulk(**kwargs)
# Equivalent to: SyncSession(max_retries=1, max_rotations=0, max_failures=None, **kwargs)
# Returns responses instead of raising on 429/challenge/empty.
```

---

## Request Methods

```python
# Session methods:
session.get(url, **kwargs) -> WaferResponse
session.post(url, **kwargs) -> WaferResponse
session.put(url, **kwargs) -> WaferResponse
session.delete(url, **kwargs) -> WaferResponse
session.head(url, **kwargs) -> WaferResponse
session.options(url, **kwargs) -> WaferResponse
session.patch(url, **kwargs) -> WaferResponse
session.request(method: str, url: str, **kwargs) -> WaferResponse

# Cookie injection (sync on both SyncSession and AsyncSession - not a coroutine):
session.add_cookie(raw_set_cookie: str, url: str) -> None
# raw_set_cookie is a Set-Cookie header string, e.g. "name=value; Path=/; Secure"
# Raises NotImplementedError for Opera Mini profile.

# Cookie read access (sync on both SyncSession and AsyncSession - not a coroutine):
session.get_cookie(name: str, url: str) -> str | None
# Reads the session's accumulated cookie state, scoped to url by RFC 6265
# rules. A Domain cookie (Set-Cookie carried Domain=.example.com) is returned
# for www.example.com; a host-only cookie (Set-Cookie omitted Domain) is
# returned only on the exact host it was set on, so a cookie from
# www.example.com is not visible at api.example.com. The cookie's Path must
# also match url's path, and when several stored cookies share a name the
# longest matching path wins. Covers every transport the session uses (the
# normal jar, the native-TLS Imperva-bypass jar, the Opera Mini jar). Cookies
# with the Secure flag are only returned when url is https:// - pass the https
# URL if you expect a Secure cookie (most WAF cookies are Secure). A cookie
# placed in the jar by something other than this session's own traffic has no
# recorded host-only bit, so it resolves on its exact host but is not offered
# to a subdomain. Returns None when the cookie is absent - never raises, works
# on all profiles.

# Manual browser-state acquisition. Both need a browser_solver= on the session
# and return False without one. Sync returns bool; async returns a coroutine.
session.browser_prime(url: str, *, timeout=None, max_response_size=None) -> bool
# Visits url in the browser solver on purpose, then imports and persists the
# browser state scoped to that origin. Use it to warm an origin before the
# real request instead of waiting for a challenge to be detected.
session.browser_solve_challenge(url: str, challenge_type: str, *,
                                timeout=None, max_response_size=None) -> bool
# Solves a challenge you have already identified, navigating url directly
# rather than the session's solve_origin. challenge_type is one of the
# resp.challenge_type values listed under "Challenge types"; an unrecognized
# value returns False rather than raising.
# True from either means browser state was earned and imported, NOT that your
# application request succeeded: reissue that request yourself and validate
# the result. timeout defaults to the session timeout; <= 0 returns False.

# Browser-rendered fetch (sync on SyncSession; coroutine on AsyncSession):
session.render(url: str, *, timeout=None, max_response_size=None) -> WaferResponse
# Loads url in the browser solver, waits for client-side rendering to settle,
# and returns the resulting document as a normal WaferResponse. Use it when a
# page writes its own content with JavaScript: the server ships a shell, and no
# fingerprint recovers markup that was never in the bytes. resp.needs_render
# tells you when a body looks like one.
# No transport request is made - the render replaces the fetch. For an HTML
# document resp.text is the serialized DOM, so Content-Type is text/html;
# charset=utf-8. A non-HTML resource (JSON, XML, plain text, an image) is
# returned as the bytes the server sent under its real Content-Type, because
# Chrome displays those inside a generated viewer document and serializing
# that would hand back the wrapper instead of the resource - so resp.json()
# and resp.content behave normally on a rendered API URL.
# Status and headers describe the document the body came from, including after
# a client-side redirect: a page that sets location.href reports the
# destination's status, not the first navigation's.
# The browser follows redirects regardless of follow_redirects, so resp.url is
# the final URL and resp.history is empty. Cookies the page set are merged into
# the session jar, so a following get() reuses them.
# A session with no browser_solver= creates one on the first render and closes
# it on exit; from then on that session can browser-solve challenges on
# ordinary requests too. Passing browser_solver= keeps the caller's ownership.
# That solver is a real one, so the usual rule applies from then on: a
# per-request headers={"User-Agent": ...} that does not exactly match the
# launched browser's UA raises ValueError, because browser state is bound to
# the browser's UA/client-hint envelope. Set the UA on the session instead, or
# do not mix a custom UA with render on the same session.
# A render that lands on a WAF interstitial solves it in place with the same
# per-WAF handlers the solve path uses, then re-captures the page, so render
# works on protected sites too. The earned cookies land in the session jar
# AND the session pins its replay identity to the solving browser, so the
# clearance survives the next ordinary get() -- the same pin an automatic
# browser solve performs. A render that met no challenge does not pin.
# Raises ChallengeDetected if the document is STILL a challenge after that,
# ConnectionFailed if the browser produced no document, ResponseTooLarge if the
# document exceeds the cap (whether the transfer or the hydrated DOM is what
# went over), and WaferTimeout for timeout <= 0.

session.cookie_scope_summary(url: str) -> list[dict]
# Value-free inspection of the wreq jar for diagnosing a protected flow: up to
# 32 entries of {"name": str, "domain": str, "path": str, "secure": bool}.
# Cookie values are never included. Returns [] for profiles with no wreq jar
# (Opera Mini) and never raises.

session.reddit_bootstrap_state() -> dict
# Value-free state of the anonymous Reddit bootstrap for this session:
#   "attempts": int          - inline bootstraps that reached the network
#   "successes": int         - bootstraps (inline or browser) that established
#                              the anonymous cookie set
#   "last_outcome": str|None - branch that ended the last inline bootstrap:
#                              "established", "verification_status",
#                              "verification_too_large",
#                              "verification_encoding",
#                              "verification_structure", "submission_status",
#                              "cookie_evidence", "transport",
#                              "client_rotated" (abandoned mid-leg by a
#                              concurrent rotation, then retried)
#   "last_status": int|None  - HTTP status of the leg that outcome came from
#   "last_cookie_names": list[str] - Set-Cookie names seen on that leg
#   "browser_attempts": int  - browser recoveries started
#   "last_browser_outcome": str|None - "established", "failed",
#                              "no_time_budget", "unavailable", "interrupted"
#                              (the solver raised, so the recovery it counts
#                              has no result)
#   "last_browser_budget": float|None - seconds the last browser recovery was
#                              given (None when the request carried no deadline
#                              or no recovery ran). A small number here is why
#                              a browser solve failed.
#   "cookie_names": list[str] - reddit.com cookie names in the jar right now,
#                              including a warm cache_dir hydrated at
#                              construction
#   "has_cookie_evidence": bool - whether those names prove anonymous setup
#                              (loid plus token_v2 or csv). This is the
#                              hydration-aware answer to "is this session set
#                              up for Reddit"; cookie_scope_summary() reflects
#                              only cookies observed so far.
# Names, counts, and labels only - never cookie values, the verification token,
# or the solved submission query. Counters are per session and never reset.
# Each failure branch also logs one WARNING carrying the same context, so a
# consumer that configures logging sees the reason without polling this.

# Browser-free reCAPTCHA v3 token minting (sync on SyncSession; coroutine on AsyncSession):
session.mint_recaptcha_v3(sitekey: str, action: str, *, origin=None, referer=None,
                          v=None, enterprise=False) -> str
# See "reCAPTCHA v3 token minting" for full semantics and the score caveat.
```

### Per-request kwargs

- `headers: dict[str, str]` - merged over session headers AND embed mode headers (per-request wins over both)
- `params: dict[str, str]` - appended to URL as query string
- `timeout: int | float | timedelta` - TOTAL deadline for the whole call, covering ALL retries, rotations, backoff/rate-limit/`Retry-After` waits, and any browser solve (including time spent waiting on a shared solver). No single wait or hostile `Retry-After` can push the call past this deadline. Identical to the session-level `timeout` (both are a total budget). **This differs from requests/httpx, where `timeout=` bounds each attempt.** Without `attempt_timeout`, one hanging attempt may consume the entire budget, so `max_retries`/`max_rotations` never fire. Each attempt is clamped to the remaining budget, and the browser solve is bounded by the remaining budget too (the session-default `timeout` applies when none is passed per-request), on top of the solver's own `solve_timeout`.
- `attempt_timeout: int | float | timedelta` - caps each INDIVIDUAL attempt (overrides the session-level `attempt_timeout` for this call). An attempt that hits this cap is a retryable failure: the loop retries, then consumes rotation budget (fresh TLS identity - hangs are often fingerprint-linked WAF tarpits), until budgets or the total `timeout` deadline are exhausted, then raises `WaferTimeout`. With `attempt_timeout` alone (no `timeout`), the total is unbounded and attempts are limited only by `max_retries`/`max_rotations`. Canonical combo:

```python
session = SyncSession(max_rotations=3)
resp = session.get(url, timeout=60, attempt_timeout=15)
# 60s total budget, each try capped at 15s -> up to 4 bounded tries
# (rotating between them) instead of one 60s hang with zero retries
```
- `max_response_size: int | None` - per-request body-size cap in bytes (overrides the session value). Over-cap raises `ResponseTooLarge`; see the constructor field for how it is enforced (Content-Length short-circuit + streamed early-abort).
- `json: dict` - JSON body (auto-sets Content-Type)
- `form: dict` - form-encoded body
- `body: bytes | str` - raw body
- `multipart` - multipart form data (pass-through to wreq; see wreq docs for format)

---

## WaferResponse

```python
resp.status_code    # int
resp.ok             # bool (200 <= status < 300)
resp.text           # str (cached). Charset resolution: Content-Type charset= param,
                    # else a <meta charset=...> / <meta http-equiv> tag in the first
                    # 1KB of HTML bodies (when Content-Type is missing entirely, the
                    # meta sniff only runs if the body looks like markup - first
                    # non-whitespace byte is "<"), else UTF-8. Unknown/invalid charset
                    # names fall back to UTF-8. Decodes with errors="replace" - never
                    # raises, invalid bytes become replacement characters.
resp.content        # bytes (the true decompressed body bytes in the server's
                    # encoding - NOT a utf-8 re-encode of decoded text; safe for
                    # binary like PDFs/images and for hashing/re-parsing)
resp.headers        # dict[str, str] (lowercase keys, string values)
resp.url            # str (final URL after redirects)
resp.history        # list of (status_code, url) named tuples - one entry per followed
                    # redirect hop, in order. Each entry is the 3xx status and the URL
                    # that returned it (requests-style), so [h.url for h in resp.history]
                    # plus resp.url is the full chain. [] when not redirected.
                    # Entries have .status_code / .url and compare equal to plain tuples.
resp.cookies        # dict[str, str] - cookies set by THIS response (parsed from its
                    # Set-Cookie headers; name -> value, attributes dropped). ALL
                    # cookies are included on every transport (incl. native-TLS and
                    # Opera Mini, where the headers dict joins them). Per-response
                    # only - for the session's accumulated cookie state use
                    # session.get_cookie(name, url).
resp.json(**kwargs) # parsed JSON (passes kwargs to json.loads; raises json.JSONDecodeError on invalid JSON)
resp.raise_for_status()  # raises WaferHTTPError if not ok
resp.get_all(key)   # list[str] - all values for a header. For "set-cookie" this
                    # returns the individual Set-Cookie strings on every transport.
resp.retry_after    # float | None - parsed Retry-After header
resp.needs_render   # bool - the body is HTML that ships script but under 1000
                    # characters of visible text, i.e. a client-rendered shell.
                    # A hint for deciding whether to call session.render(url),
                    # not a verdict. False for every non-HTML body. Computed on
                    # first access and cached.

# Retry metadata:
resp.elapsed        # float (seconds)
resp.was_retried    # bool
resp.retries        # int (normal retries used)
resp.rotations      # int (fingerprint rotations used)
resp.inline_solves  # int (inline challenge solves)
resp.challenge_type # str | None (e.g. "cloudflare", "datadome")
resp.emulation      # str | None - the identity that SERVED this response, for
                    # diagnosing a 403/regression. For Emulation sessions it's the
                    # wreq profile repr, e.g. "Profile.Chrome149" / "Profile.Edge148"
                    # / "Profile.Firefox151"; for non-Emulation profiles it's the
                    # profile name ("safari", "ios_safari", "dart",
                    # "opera_mini"). Reflects the
                    # CURRENT identity, so after a rotation it shows the one that
                    # actually served (not the session's original). This is the TLS
                    # profile only; after a browser solve it may report a lower
                    # Chrome major than the wire User-Agent/sec-ch-ua (see
                    # fingerprint_envelope()).
```

`resp.headers` is a plain `dict[str, str]` with lowercase keys. Use `.items()`,
`.get()`, `[]`, etc. normally. For example, `resp.headers.get("etag")` (lowercase).

---

## Fingerprint Identity

wafer picks a TLS `emulation` (a wreq browser profile) and sends an HTTP header
envelope that matches it. The envelope is **family-aware** - the family is
derived from the chosen `emulation`:

- **Chrome** (default): full sec-ch-ua client hints, brand `"Google Chrome"`,
  Chrome navigation `Accept`.
- **Edge** (`emulation=Emulation.Edge148`): Chromium, so Chrome-like headers and
  the SAME navigation `Accept`, but the sec-ch-ua brand is `"Microsoft Edge"`.
  UA is wreq's Edge UA.
- **Firefox** (`emulation=Emulation.Firefox151`): sends **NO** sec-ch-ua client
  hints at all, a Firefox `Accept`
  (`text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8`) and
  `Accept-Language: en-US,en;q=0.5`. UA is wreq's Firefox UA.
- **Safari** (`emulation=Emulation.Safari26_2`): wreq's native Safari Emulation.
  Sends **NO** sec-ch-ua, the short WebKit `Accept`
  (`text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8`),
  `Accept-Language: en-US,en;q=0.9`, `Accept-Encoding: gzip, deflate, br` (no
  zstd), and no `Cache-Control`/`Upgrade-Insecure-Requests`. (This is wreq's
  Safari Emulation; it is distinct from `profile=Profile.SAFARI`, wafer's custom
  wire-verified Safari identity - see Profiles.)

### Mobile profiles

wreq exposes **mobile** Emulation identities. Select them via `emulation=`; the
mobile TLS shape and mobile UA come from wreq automatically, and wafer applies
the same family header envelope (no sec-ch-ua, family-correct `Accept`):

- **iOS Safari**: `Emulation.SafariIos26_2`, `SafariIos18_1_1`, ... - iPhone
  Safari UA + iOS TLS. Safari family -> no client hints.
- **iPadOS Safari**: `Emulation.SafariIPad26`, `SafariIpad26_2`, ... - iPad
  Safari UA. Safari family.
- **Android Firefox**: `Emulation.FirefoxAndroid135` - `Android ...; Mobile`
  Firefox UA. Firefox family.

`fingerprint_envelope()["is_mobile"]` is `True` for these (and `False` for
desktop profiles); it's the **only** mobility signal because these families send
no client hints. wreq has **no mobile Chromium profile**, so wafer never sends
`sec-ch-ua-mobile: ?1` (that hint is Chromium-only, and there is no mobile
Chromium identity to attach it to).

For the capture-accurate iPhone identity, prefer
`profile=Profile.IOS_SAFARI` over a built-in `SafariIos*` emulation. The
dedicated profile is wire-verified against Safari 26.5.2 and reports
`is_mobile: True`.

Pass any wreq profile as `emulation=` and the matching envelope is applied
automatically - you do NOT set headers yourself. (Passing your own `headers=`
replaces the whole envelope, including the auto sec-ch-ua - see "headers="
under the Session Constructor.) Selecting a non-Chrome `emulation` only sets a
coherent identity; it does NOT change rotation behavior.

### session.fingerprint_envelope() -> dict

Snapshot of the identity the session currently serves with - the same UA +
client hints actually on the wire. Useful to feed the same identity to other
tooling (e.g. signing a JS challenge) or to log what served a 403. Replaces
reaching into `wafer._fingerprint`. Always returns these keys:

```python
{
  "user_agent": str | None,        # the UA wreq sends for this profile
  "family": str | None,            # "chrome"|"edge"|"firefox"|"opera"|"safari"|"dart"|"opera_mini"|None
  "emulation": str | None,         # "Profile.Chrome149" or "safari"/"ios_safari"/"dart"/"opera_mini"
  "sec_ch_ua": str | None,         # low-entropy hint; None for Firefox/Safari/Opera (see below)
  "sec_ch_ua_mobile": str | None,  # "?0" or None
  "sec_ch_ua_platform": str | None,# e.g. '"macOS"' or None
  "full_version_list": str | None, # Sec-CH-UA-Full-Version-List value or None
  "platform_version": str | None,  # Sec-CH-UA-Platform-Version value or None
  "user_agent_data": dict | None,  # navigator.userAgentData shape; None for Firefox/Safari/Opera
  "is_mobile": bool,               # True for mobile Emulation or Profile.IOS_SAFARI
}
```

Only Chrome and Edge populate the client-hint fields. Firefox and Safari send
no client hints at all. Opera IS Chromium, but wreq's own Opera profile already
puts accurate Opera sec-ch-ua on the wire, so wafer's envelope leaves the
client-hint fields `None` for Opera (it doesn't re-derive them) -- `family` is
still `"opera"`. For non-Emulation profiles (Safari/iOS Safari/Dart/Opera Mini) only
`user_agent` / `family` / `emulation` are populated (`family` is `"safari"` /
`"dart"` / `"opera_mini"`); `Profile.IOS_SAFARI` also reports
`is_mobile: True`.

The values always reflect what is actually on the wire; feed them to tooling
as returned. After a browser solve, `emulation` (the TLS profile) may be a lower
Chrome major than `user_agent` / `sec_ch_ua` / `full_version_list` (which follow
the solving browser's version); do not reconcile them to a single version.

### Module-level fingerprint helpers

```python
wafer.sec_ch_ua(major_version: int, brand: str = "Google Chrome") -> str
# Build a sec-ch-ua header value for a Chromium browser. Pass
# brand="Microsoft Edge" for Edge. Chromium-only (Firefox/Safari send none).
#   wafer.sec_ch_ua(147)                         -> '"Google Chrome";v="147", ...'
#   wafer.sec_ch_ua(147, brand="Microsoft Edge") -> '"Microsoft Edge";v="147", ...'

wafer.full_version(major: int) -> str
# Real Chrome full version (MAJOR.0.BUILD.PATCH), e.g. full_version(147) -> "147.0.7727.24".

wafer.chrome_full_version(emulation) -> str | None
# Exact four-part Chrome version wafer emits for that Emulation's
# high-entropy client hints. Returns None for non-Chrome profiles.

wafer.emulation_family(emulation) -> str | None
# Classify a wreq.Emulation: "chrome" | "edge" | "firefox" | "opera" | "safari" | None.

wafer.emulation_is_mobile(emulation) -> bool
# True for a mobile Emulation (iOS/iPad Safari, Android Firefox); False for desktop.

wafer.build_fingerprint_envelope(emulation, user_agent: str | None = None, *,
                                 ch_major_version: int | None = None,
                                 ch_full_version: str | None = None) -> dict
# Same dict shape as session.fingerprint_envelope(), for an arbitrary Emulation
# (without a session). user_agent is the UA you intend to send (wreq sets it from
# the Emulation); pass it so the envelope is complete.
# ch_major_version / ch_full_version override the client-hint version and full
# build independently of `emulation` (Chrome family only; ignored for others).
# Leave None to use the Emulation's own version.
```

---

## Error Hierarchy

```
WaferError (base)
  +- ChallengeDetected    .challenge_type: str, .url: str, .status_code: int, .response: WaferResponse | None
  |    +- RequestBlocked  same attributes; a WAF rule denied the request outright
  +- RateLimited          .url: str, .retry_after: float | None, .response: WaferResponse | None
  +- ConnectionFailed     .url: str, .reason: str
  +- EmptyResponse        .url: str, .status_code: int, .response: WaferResponse | None
  +- TooManyRedirects     .url: str, .max_redirects: int
  +- TokenMintFailed      .stage: str | None ("anchor"|"reload"|"apijs"), .status_code: int | None
  +- ResponseTooLarge     .url: str, .size: int (bytes seen when cap hit), .limit: int (the cap)
  +- WaferTimeout         .url: str, .timeout_secs: float  (also inherits TimeoutError)
  +- WaferHTTPError       .status_code: int, .url: str, .response: WaferResponse | None  (raised by raise_for_status())
```

`except WaferError` catches everything including WaferTimeout.

`RequestBlocked` separates a denial from a challenge. A challenge asks the
client to prove it is a browser, so retrying, rotating identity, or solving in
a browser can change the outcome. A block means the request matched a WAF rule,
and every one of those returns the same denial - so wafer raises immediately,
spending no retry or rotation budget, with `.challenge_type` set to the
terminal type (`cloudflare_block`). What can change the outcome is changing the
request: a different path, origin, or egress address. It subclasses
`ChallengeDetected`, so existing handlers still catch it; catch it separately to
tell "try again later" from "this will never work as-is". Under
`max_rotations=0` (including `.bulk()`) the block is returned as a response with
`challenge_type="cloudflare_block"` instead of raised, like every other
challenge.

`ChallengeDetected`, `RateLimited`, `EmptyResponse`, and `WaferHTTPError`
carry the final `WaferResponse` as `e.response` (body, headers, status of the
blocked reply) - read `e.response.text` / `e.response.headers` instead of
string-matching `str(e)`. It can be None in edge cases where no response was
in hand, so check before dereferencing. Caution: `e.response` may be a full WAF challenge
page with embedded tokens/sensor data - do not log its body or headers
unscrubbed.

When `max_failures` consecutive failures occur on a domain, wafer silently resets
the session identity (new TLS fingerprint, cleared cookies for that domain, new
cookie jar) and continues retrying. It does not raise.

### When wafer raises vs returns

Default mode (`max_rotations > 0`):
- 403 + challenge detected -> raises `ChallengeDetected` after exhausting rotations
- 403 + terminal WAF block detected -> raises `RequestBlocked` immediately, before any retry or rotation
- 429 without challenge -> raises `RateLimited` after exhausting rotations
- 200 with empty body -> retries; if the host already served a real body this session, also rotates to a fresh identity (within `max_rotations`); raises `EmptyResponse` once retries+rotations are exhausted (see "Stateful request replay")
- Connection error (refused/reset/TLS failure) -> raises `ConnectionFailed` after exhausting retries. When the host resolved only to `0.0.0.0` / `::`, `.reason` says so: the resolver refused the name (sinkhole, blocklist, filtered DNS) rather than the host being unreachable. Pin the address with `resolve=` or use another resolver.
- Server hang past the total `timeout` deadline -> raises `WaferTimeout` (a timeout on any transport - the wreq path, the native-TLS Imperva bypass, or the Opera Mini path, whether during connect or read - is always bounded by your budget and surfaced as `WaferTimeout`, never `ConnectionFailed`)
- 5xx -> returns response after exhausting retries
- Other 4xx (400, 401, 404, etc.) -> returns response immediately (no retry)

No-rotation mode (`max_rotations = 0`, including `.bulk()`):
- 403, 429, challenge, empty 200 -> returns response (never raises for these)
- Connection error -> still raises `ConnectionFailed`
- Server hang past the total `timeout` deadline -> still raises `WaferTimeout`
- Other 4xx, 5xx -> same as default (returns response)

### Rotation escalation

On 403 or challenge, rotation changes browser families before cycling versions
within one family. Chrome versions share Chromium-family characteristics,
whereas Firefox uses a Gecko TLS/H2 profile. Every family switch also swaps the
HTTP header envelope to that family's `Accept`, `Accept-Language`, and client
hints so the headers remain coherent with the TLS fingerprint.

The deterministic ladder (starting from the default Chrome family):

1. **Fresh TLS session** (rotation 1) - rebuilds the wreq client (new TLS session, empty cookie jar) with the SAME family. Also clears that domain's disk cookie cache (if `cache_dir` is set).
2. **Firefox** (rotation 2) - `Emulation.Firefox151`: Gecko TLS/H2, Firefox Accept, `Accept-Language: en-US,en;q=0.5`, NO sec-ch-ua.
3. **Safari** (rotation 3) - wafer's Safari 26 custom TlsOptions/Http2Options profile, no client hints.
4. **Edge** (rotation 4) - `Emulation.Edge148`: Chromium TLS but the `"Microsoft Edge"` sec-ch-ua brand + Edge build.
5. **Chrome version cycling** (rotation 5+) - returns to the Chrome family and cycles Chrome versions on subsequent rotations.

The rung you reach is bounded by `max_rotations`: the full
Chrome->Firefox->Safari->Edge ladder needs `max_rotations>=4` (Safari needs
`>=3`, Edge `>=4`, Chrome-version cycling `>=5`). With the default
`max_rotations=2` you get one cross-family jump (a fresh Chrome session, then
Firefox) before wafer raises. A higher budget tries more identities against
the same host. A fingerprint pinned after earning browser-bound challenge
state does not rotate because its cookies are bound to that identity. Reddit
browser recovery and challenge-absent Cloudflare passthrough do not pin. A
session started on a non-Chrome `emulation=` walks the same ladder, skipping
its own starting family; `profile=` identities (Safari/iOS
Safari/Dart/Opera Mini) retain their profile-specific behavior.

### Fingerprint pool

`fingerprint_pool: list[Emulation] | None` is an opt-in, additive way to give a
session a fixed set of identities to rotate through, with per-identity backoff,
WITHOUT retiring the whole session on N strikes.

- **Overrides the default ladder.** When set, rotation steps through the pool in
  order (cycling) instead of the Chrome->Firefox->Safari->Edge ladder. Each step
  swaps to that identity's family header envelope (same coherence as the ladder).
- **Per-identity backoff.** A pool member that fails accrues a strike; the next
  time the cycle reaches it, its rotation delay grows up to the configured cap.
- **Never retired.** `max_failures` is IGNORED in pool mode: rotation-induced
  failures do not retire the session. Pool backoff is the health model;
  `max_rotations` still bounds rotations per request.
- **Emulation-only.** Pool entries are wreq `Emulation` profiles
  (`[Emulation.Chrome149, Emulation.Firefox151, Emulation.Edge148]`). A pool is
  ignored for `profile=` (Safari/iOS Safari/Dart/Opera Mini) sessions.

```python
from wreq import Emulation
session = SyncSession(
    fingerprint_pool=[Emulation.Chrome149, Emulation.Firefox151, Emulation.Edge148],
    max_rotations=6,  # bound how many pool steps a single request may take
)
```

### Stateful request replay

**WARNING:** the default `max_retries=3, max_rotations=2` can replay a single
request up to **6 times** - and the replay re-issues the WHOLE request (same
URL, method, and body). For a STATEFUL multi-step flow (login -> add-to-cart ->
checkout, or any request that mutates server state or consumes a one-time
token), that silent re-execution is hazardous: it can double-submit a form,
burn a nonce, or replay an expensive multi-step operation. For such flows:

- Lower the budget: `SyncSession(max_retries=0, max_rotations=0)` (or `1`/`1`)
  so a failed step surfaces immediately instead of being replayed, and you
  drive the retry yourself at the flow level.
- Or use `bulk()` / a `fingerprint_pool` so the costly identity churn happens
  on cheap, idempotent GETs and your stateful POSTs run on a settled identity.

**Empty-200 as a rotation signal.** A `200 OK` with an empty body from a host
that ALREADY returned real content THIS SESSION (a "200-capable" host) is
treated as a soft block on the current identity, not a real empty resource.
After same-identity retries (`max_retries`) are spent, wafer rotates to a fresh
identity (within `max_rotations`) and retries before giving up - a different
fingerprint often gets the real body back. `EmptyResponse` is still the terminal
outcome once rotations are exhausted (or returned, under `max_rotations=0`/
`bulk()`). A FIRST-request empty 200 (host never proven 200-capable) is NOT
rotated - it could legitimately be an empty endpoint - and just retries/raises.

### Imperva / Incapsula native-TLS fallback

Some Imperva deployments challenge browser-emulating clients based on their
TLS stack. Wafer can retry those requests over a system-OpenSSL transport
without `Sec-Fetch-*` headers. The request method, body, and caller-supplied
`Origin`/`Referer` headers are preserved.

- **No browser required** for this path (works without the `[browser]` extra).
- **Sticky per hostname:** once a host is served this way, the whole session
  keeps using it (the WAF cookies live in that transport's jar, so switching
  back mid-flow would just get re-challenged). Other hosts are unaffected.
- **Any method/body:** GET with `params=`, POST with `form=`/`json=`/`body=`
  all work; the body and `Content-Type` are preserved.
- **Token escalation:** when the native path also requires a `reese84` token
  and `browser_solver` is configured, wafer obtains the token in the browser
  and reuses it for the session. A host already assigned to the native path
  retries there first, then leaves that path if the challenge persists.
  Without a browser solver, the persistent challenge raises
  `ChallengeDetected` (or returns the response under
  `.bulk()`/`max_rotations=0`).
- **Proxies:** with no proxy or an `http://` proxy the native path is used (an
  http proxy is honored via CONNECT tunnelling). With a `socks://`/`https://`
  proxy - which `http.client` can't tunnel without leaking your real IP - wafer
  **skips the native path entirely** for that session and handles the challenge
  on the (proxy-aware) wreq path instead (rotation, then the `browser_solver`).

---

## Concurrency and Thread Safety

**AsyncSession** is safe to use from multiple concurrent coroutines. Internally
uses an asyncio.Lock for TLS rotation. You can share a single AsyncSession
across many `asyncio.Task`s.

**SyncSession** is NOT thread-safe. Create one session per thread. For
concurrent workloads, either use AsyncSession with asyncio, or create separate
SyncSession instances in each thread.

**Module-level functions** (`wafer.get()`, etc.) are thread-safe because each
call creates and tears down its own independent SyncSession.

**Cookie cache** (`cache_dir`) is off by default (`None`). When set, only solver
cookies (browser-solved WAF challenges, inline solvers) are persisted to disk.
Normal `Set-Cookie` headers stay in-memory and are lost on session rebuild (WAFs
can bind cookies to TLS fingerprints). Set `cache_dir` when browser or inline
solver cookies must persist across sessions.
Thread-safe (per-domain locks, atomic writes via temp file + rename). Multiple
threads can share the same `cache_dir` path safely. Multiple processes sharing
the same path may lose updates under concurrent writes to the same domain.
Cookie files are written `0o600` and a wafer-created `cache_dir` is `0o700`
(owner-only), since they hold WAF-clearance and auth tokens.

---

## Session Lifecycle

Sessions have no `close()` or `aclose()` method. Context managers are
supported but optional:

```python
with SyncSession(browser_solver=solver) as session:
    ...  # solver is NOT closed on exit - you own it

async with AsyncSession(browser_solver=solver) as session:
    ...  # same
```

**Sessions hold no resources that need explicit cleanup.** Letting them go
out of scope is fine.

**Solver ownership:** a `BrowserSolver` you pass in via `browser_solver=` is
owned by YOU - the session never closes it on exit (a session would only close
a solver it created internally, which wafer currently never does). Sharing one
solver across multiple sessions and re-entering `with` blocks on the same
session are both safe. Call `solver.close()` yourself when all sessions are
done with it, or let the solver's `idle_timeout` shut the browser down.

---

## Profiles

The `profile=` parameter selects the browser identity. Each has different
capabilities and trade-offs.

### Chrome (default, no profile= needed)

- Default wreq TLS + HTTP/2 profile is `Emulation.Chrome149`
- Auto-generates `sec-ch-ua` Client Hints headers
- On 403/challenge: rotates across families (Chrome -> Firefox -> Safari -> Edge -> Chrome versions), swapping the header envelope each switch (see "Rotation escalation")
- All features enabled: challenge detection, retry, rotation, browser solving
- Pass `emulation=wreq.Emulation.Chrome149` to select that specific profile

### Safari (`profile=Profile.SAFARI`)

- TLS + HTTP/2 fingerprint matches real Safari 26 on macOS M3/M4
- No `sec-ch-ua` headers (Safari doesn't send Client Hints)
- All features except fingerprint rotation (only one Safari profile)
- `safari_locale=` param: `"us"` (default) or `"ca"`

### iOS Safari (`profile=Profile.IOS_SAFARI`)

- TLS + HTTP/2 fingerprint captured from real iPhone Safari 26.5.2
- Exact UA: `Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5.2 Mobile/15E148 Safari/604.1`
- JA3 hash: `ecdf4f49dd59effc439639da29186671`
- JA4: `t13d2013h2_a09f3c656075_7f0f34a4126d`
- HTTP/2 fingerprint: `2:0;3:100;4:2097152;9:1|10420225|0|m,s,a,p`
- The `CPU iPhone OS 18_7` / `Version/26.5.2` combination is intentional and
  wire-verified; do not normalize one token to the other
- No Client Hints; `fingerprint_envelope()` reports family `"safari"`,
  emulation `"ios_safari"`, and `is_mobile: True`
- No fingerprint rotation (the captured mobile identity remains fixed)
- Browser solving is rejected at construction: wafer's solver is desktop
  Chromium and its cookies cannot be replayed coherently under mobile Safari
- Imperva's native-OpenSSL fallback is disabled because it would replace the
  captured mobile ClientHello while retaining the iPhone UA
- `safari_locale=` selects `"us"` (default) or `"ca"`
- Challenge detection, retries, cookies, redirects, proxies, rate limiting,
  and embed headers remain available

### Dart (`profile=Profile.DART`)

- TLS fingerprint matches real Dart 3.11 (dart:io) / Flutter BoringSSL
- HTTP/1.1 only (no h2), no ALPN, no GREASE, no sec-ch-ua headers
- JA3 hash: `203503b7023848ab87b9836c336b8e81` (wire-verified identical)
- Minimal default headers: `User-Agent: Dart/3.11 (dart:io)` + `Accept-Encoding: gzip`
- Pass application-specific headers (e.g. `X-User-Agent`) via per-request `headers=`
- No challenge detection, no fingerprint rotation, no browser solving
- Embed mode (`embed=`) is not supported (raises ValueError)
- All other features work: retry, rate limiting, cookies, redirects, proxy
- Useful for impersonating Flutter/Dart mobile apps behind bot detection

### Opera Mini (`profile=Profile.OPERA_MINI`)

- Impersonates Opera Mini in Extreme data-saving mode
- GET only (raises ValueError on POST, PUT, etc.)
- No challenge detection, no retry, no browser solving
- Rate limiting still works
- Useful for fetching server-side rendered pages that Opera Mini triggers

---

## What Wafer Handles (do not reimplement)

These are all automatic. Do not write code to handle these yourself:

- **Redirects** - 3xx followed automatically (POST -> GET on 301/302/303). 304 passes through. Auth stripped on cross-origin redirects. Body headers stripped on method change. Followed hops are recorded in `resp.history`.
- **Referer headers** - set automatically from the last URL visited per hostname
- **Cookies** - managed in-memory and optionally persisted to disk; no manual cookie jar needed
- **WAF challenges** - detected, browser-solved if configured, retried with cross-family fingerprint rotation (Chrome -> Firefox -> Safari -> Edge). A terminal WAF *block* is reported at once instead (see `RequestBlocked`).
- **Rate limiting** - per-hostname delays enforced automatically when `rate_limit` is set
- **TLS fingerprint** - sec-ch-ua headers auto-generated to match the Chrome version
- **Binary responses** - detected via Content-Type. `resp.content` preserves the decompressed body bytes exactly on every response (safe for PDFs, images, etc.). `resp.text` decodes charset-aware (Content-Type charset, HTML meta tag, UTF-8 fallback) with replacement characters - it never raises.
- **Decompression** - gzip/brotli/zstd response bodies are decompressed automatically; `resp.content` is the decompressed bytes.

---

## Challenge Types

Wafer detects 19 WAF/challenge types automatically. When a challenge cannot be
solved, `ChallengeDetected.challenge_type` and `resp.challenge_type` contain
one of these strings:

```
"cloudflare"   - Cloudflare managed challenge / Turnstile
"akamai"       - Akamai Bot Manager
"datadome"     - DataDome
"perimeterx"   - PerimeterX / HUMAN Security
"imperva"      - Imperva / Incapsula
"kasada"       - Kasada
"shape"        - F5 Shape
"awswaf"       - AWS WAF
"acw"          - Alibaba Cloud WAF (solved inline, no browser needed)
"tmd"          - Alibaba TMD (inline warm-up; browser for Baxia/reCAPTCHA)
"amazon"       - Amazon CAPTCHA (solved inline, no browser needed)
"reddit"       - Reddit cold-session JSON/HTML gate (inline first; optional browser recovery)
"vercel"       - Vercel bot protection
"arkose"       - Arkose Labs / FunCaptcha (no dedicated solver; not solvable)
"geetest"      - GeeTest v4
"hcaptcha"     - hCaptcha
"recaptcha"    - reCAPTCHA v2
"generic_js"   - unclassified JS challenge
"cloudflare_block" - Cloudflare WAF *block* page (Error 1020 and the IP bans):
                 terminal, nothing to solve. Raised as RequestBlocked without
                 spending retry or rotation budget. Do not confuse it with
                 "cloudflare", which is a solvable interstitial.
```

Some challenges (Cloudflare, DataDome, AWS WAF, Kasada, Vercel, hCaptcha,
reCAPTCHA, generic_js) require a browser solver - TLS fingerprint rotation alone
cannot help. Pass a `BrowserSolver` to the session to handle these automatically.

Detection != solving. `"arkose"` has NO dedicated solver: without a
`browser_solver` it raises `ChallengeDetected`; with one it falls through to a
generic browser JS-wait that does NOT solve interactive FunCaptcha - so treat
Arkose as unsolved and handle it yourself. `"recaptcha"` *challenge* solving is
v2 only (checkbox/image grid in the browser). reCAPTCHA **v3** is different: it's
a score token, not a visible challenge, and is minted browser-free via
`session.mint_recaptcha_v3(...)` (see "reCAPTCHA v3 token minting").
`"vercel"`/`"generic_js"` also get the generic browser JS-wait (no
dedicated solver, but it passes their passive JS checks). The inline set
(`acw`, `tmd`, `amazon`, `reddit`) needs no browser for its normal path.
Reddit's solver recognizes both the large JSON block and direct 200 verification
HTML, performs the logged-out New Reddit verification at
`https://www.reddit.com/` in the same TLS session, then retries the original
request. If the strict inline flow fails and `browser_solver` is configured,
wafer navigates that fixed HTML root in Chrome, optionally reloads it once,
requires `loid` plus `token_v2` or `csv`, imports the cookies, and retries the
original method/body through wreq. It never browser-navigates the JSON URL or
returns browser HTML as the API response. Reddit's fixed root overrides
`solve_origin` for this recovery. `cache_dir`
persists the durable cookie-setting response legs so later processes can start
warm. A bootstrap that fails logs one WARNING naming the branch (verification
status, oversize or non-UTF-8 body, unrecognized form, submission status,
missing cookie evidence, transport) with the HTTP status and the Set-Cookie
names observed; `session.reddit_bootstrap_state()` returns the same information
for a caller that needs it programmatically. Explicit `old.reddit.com` requests are fetched normally, but wafer never
selects Old Reddit automatically or uses it as a fallback. A login wall from
an explicit Old Reddit request is returned normally.

---

## Browser Solver

Requires `pip install wafer-py[browser]`. Uses Patchright (patched Playwright).

```python
from wafer.browser import (
    BrowserSolver,
    CapturedResponse,
    HardenedLaunch,
    InterceptResult,
    SolveResult,
    format_cookie_str,
    hardened_launch_config,
    preflight_recaptcha_models,
    preload_recaptcha_models,
    scrub_headless_ua,
)

# Convert one Playwright/Patchright cookie dict to a Set-Cookie string.
format_cookie_str(cookie: dict) -> str

# Optional startup/release preflight for deployments that require reCAPTCHA
# image-grid support. Downloads the immutable, library-pinned classifier and
# detector revisions when absent, loads both ONNX sessions, and returns True
# only when both are ready. timeout= bounds the caller's wait; a timed-out
# first preparation continues in one shared daemon loader for later reuse.
models_ready = preload_recaptcha_models(timeout=60.0)
# Raising deployment gate: returns None only when both pinned models loaded.
preflight_recaptcha_models(timeout=60.0)

# Chromium launch settings for driving your own Playwright, when you need the
# per-exchange request/response log rather than the settled document that
# session.render() returns. Everything else about the browser stays yours.
config: HardenedLaunch = hardened_launch_config(
    headless=True,      # required
    proxied=False,      # True only when the browser runs behind a proxy; it
                        # adds UDP-containment switches that otherwise change
                        # the launch fingerprint for no benefit
    platform=None,      # defaults to sys.platform
)
config.args                 # tuple[str, ...] -> chromium.launch(args=[...])
config.ignore_default_args  # tuple[str, ...] -> ignore_default_args=[...]
config.init_scripts         # tuple[str, ...] -> register each via CDP
                            # Page.addScriptToEvaluateOnNewDocument after
                            # Page.enable; do not detach the CDP session
                            # afterwards, which unregisters them. Empty when
                            # headless=False.

# --headless=new leaves "HeadlessChrome" in the user agent, and that token alone
# earns degraded service (throttling, challenges) from sites that present no
# challenge to a normal browser. Read the launched browser's own UA and scrub it
# rather than composing one, so the version stays truthful.
raw = page.evaluate("navigator.userAgent")
context = browser.new_context(user_agent=scrub_headless_ua(raw))

solver = BrowserSolver(
    headless=False,       # default; headless has lower solve coverage
    idle_timeout=300.0,   # default: 300s. Close browser after N seconds idle
    solve_timeout=30.0,   # default: 30s. Max seconds per solve attempt.
                          # The call's timeout= (session default or per-request)
                          # caps this further (whichever is smaller wins).
    proxy=None,           # optional HTTP(S)/SOCKS5 proxy for every Chromium
                          # connection; use the same proxy= on the session.
    egress_guard_proxy=None,
                          # optional unauthenticated loopback SOCKS5 security
                          # guard for browser-only destination filtering. This
                          # is not an upstream identity proxy and does not need
                          # a matching Session(proxy=).
    executable_path=None, # optional str/os.PathLike Chrome executable override
)
# Proxy configuration is mutable only before Chromium launches.
solver.proxy_server -> str | None
solver.egress_guard_proxy -> str | None
solver.configure_proxy(proxy: str) -> None
solver.configure_egress_guard(proxy: str) -> None
solver.proxy_matches(proxy: str | None) -> bool
# Launch Chrome and validate the runtime without navigating a target site.
solver.preflight() -> None
# Published after a successful preflight; reading it performs no browser I/O.
solver.browser_identity -> tuple[str, str] | None  # (user_agent, full_version)
# Read-only, non-blocking lifecycle signal. True only while the launched Chrome
# process remains connected; it clears on disconnect, idle close, and close().
assert solver.runtime_ready is False
# Startup checks the browser's four-part version against
# chrome_full_version(DEFAULT_EMULATION). A difference is logged and accepted,
# not rejected: Chrome auto-updates ahead of the newest available emulation, so
# the installed browser is authoritative for browser-bound clearance replay.
# Those solve paths align the session's User-Agent and client hints to it.
# Reddit recovery and challenge-absent Cloudflare passthrough do not pin the
# session. An unreadable binary, or one that does not report a Chrome version,
# still raises.
# Automatic session solves reserve 10% of the remaining total request timeout
# (capped at 5s) for cookie injection and the protected-request replay; the
# timeout passed into BrowserSolver is reduced by that reserve.
# TMD/Baxia uses one recorded drag per punishment document. If the remaining
# request budget can fund them, automatic solving uses up to three fresh
# browser contexts, splits browser time fairly among them, and excludes recent
# rejected drag recordings across contexts. It reserves up to 15 seconds for
# authoritative native-HTTP replay when a new target-scoped x5sec is minted.
# If Chrome instead reaches a validated, challenge-free exact GET document
# without transferable clearance, the session returns that browser response
# directly, preserves its existing wreq identity/jar, and does not claim later
# transport requests are unlocked. An unchanged URL or vanished iframe with
# TMD markup still in the main DOM is failure. Short budgets use one viable
# context instead of several underfunded attempts. When the response body
# carries an issued punishment URL selecting Google reCAPTCHA (AliExpress MTop
# answers an API call that way), one long context is used instead: its image
# rounds belong to a single-use document that a fresh context would discard.

# Automatic usage (pass to session):
session = SyncSession(browser_solver=solver)
resp = session.get("https://protected-site.com")  # auto-solves challenges

# Manual solve:
result: SolveResult | None = solver.solve(url, challenge_type)
# result.cookies: list[dict] - browser cookies
# result.user_agent: str - browser's User-Agent (version-reduced, e.g.
#   "...Chrome/150.0.0.0...")
# result.browser_version: str | None - browser's full build (e.g.
#   "150.0.7871.125"); the version-reduced user_agent hides it
# result.extras: dict | None - WAF-specific data
# result.response: CapturedResponse | None - passthrough content (see below)
# result.challenge_absent: bool - no transferable clearance identity was
#   earned. True for a validated Cloudflare main-document response when no
#   challenge iframe appeared, a challenge-free TMD GET returned as
#   browser-only passthrough without x5sec, and a render that met no challenge.
#   Automatic session handling returns those without pinning the fingerprint
#   or rebuilding the wreq client; False means clearance WAS earned and the
#   session pins.

# Manual render (the browser-rendered fetch behind session.render()):
result: SolveResult | None = solver.render(url, timeout=None, max_size=None)
await solver.arender(url, timeout=None, max_size=None)   # async counterpart
# Same SolveResult shape. result.response is always populated on success and
# holds the settled document; prefer session.render(), which wraps this in a
# WaferResponse and merges the cookies for you.
# CapturedResponse fields:
#   .url: str
#   .status: int
#   .headers: dict[str, str] (lowercase names)
#   .body: bytes
#   .set_cookie: list[str] (individual Set-Cookie values)
# Imperva on an API host (e.g. api2.example.com): a top-level browser nav to an
# API host hits Imperva's "Error 15" block. Pass embedder= (the site's origin
# page, e.g. "https://www.example.com/") so the token is earned there, and
# replay={"method","body","content_type"} to get the API response as passthrough:
#   solver.solve(api_url, "imperva", embedder="https://www.example.com/",
#                replay={"method": "POST", "body": "...", "content_type": "..."})
# The automatic session path (browser_solver=) derives both for you - no need to
# call solve() manually unless you're driving the solver yourself.

# Iframe intercept (for embedded widgets):
result: InterceptResult | None = solver.intercept_iframe(
    embedder_url="https://parent-page.com",
    target_domain="widget-domain.com",
    timeout=30.0,
)
# result.cookies: list[dict]
# result.responses: list[CapturedResponse]
# result.user_agent: str

# Async apps driving the solver manually: solve()/intercept_iframe() are
# BLOCKING (Playwright sync API under a lock), so awaiting them directly would
# stall the event loop. Use the async wrappers - identical args and return
# types, just dispatched to a worker thread so the loop keeps running:
#   result = await solver.asolve(url, challenge_type)
#   result = await solver.aintercept_iframe(embedder_url, target_domain)
# (The automatic session path already does this for you; these are only for
# manual solver use from async code.)

# Explicit cleanup. The solver is yours: session __exit__ does NOT close
# a solver you passed in, so close it when all sessions are done with it
# (or let idle_timeout shut the browser down):
solver.close(timeout: float | None = None) -> bool
```

When a session has both `proxy=` and `browser_solver=`, wafer configures an
unstarted `BrowserSolver` with the same HTTP(S) or SOCKS5 proxy. If the browser
is already running with a different proxy, or a custom solver cannot prove it
uses the session proxy, session construction fails closed so browser challenge
solving cannot bypass the configured egress path. SOCKS4 session proxies are
not supported by `BrowserSolver`.

When the URL is supplied by an untrusted caller, `resolve=` protects wafer's
wreq/native transports but cannot pin Chromium's independent DNS/network stack.
Configure `BrowserSolver(egress_guard_proxy=...)` with a local SOCKS5
egress-filtering proxy that validates and numerically pins every destination
(including redirects and subresources). A hostname-only Playwright route check
is not sufficient against DNS rebinding. An ordinary `BrowserSolver(proxy=...)`
is an upstream identity proxy and must match `Session(proxy=...)`; it is a
separate contract from the local browser-only guard. With either browser proxy
configured, wafer disables QUIC and non-proxied WebRTC UDP so
browser-controlled traffic cannot bypass the TCP proxy. Browser navigation
targets themselves are restricted to credential-free HTTP(S) URLs.
The egress guard decides destination policy while Chromium retains its built-in
unsafe-port restrictions. A guard should not assume only ports 80/443:
legitimate public challenge pages may use nonstandard ports such as 8080/8443.

The solver is thread-safe and reuses a single browser instance with idle timeout.
Solves are serialized on an internal lock; a caller waiting for a busy solver
gives up once its own timeout budget is exhausted (it does not block forever),
so one slow solve can't stall other callers past their request deadlines.
Supports: Cloudflare, Akamai, DataDome (WASM PoW auto-resolve + confirm click
only; bails on interactive captchas), PerimeterX
(press-and-hold), Imperva, Kasada, F5 Shape, AWS WAF, GeeTest v4 (slide),
Baxia (slider), hCaptcha, reCAPTCHA v2 (checkbox + image grid via local ONNX
models), Reddit fixed-origin cookie recovery, generic JS.

### Passthrough mode

Some WAFs bind cookies to the TLS session, making cookie replay from wreq
impossible after a browser solve. In these cases the solver captures the page
content directly and returns it as the response. This is transparent -
`session.get()` returns a normal `WaferResponse`. No special handling needed.
Applies to Kasada, AWS WAF, and any challenge where the browser lands on the
real page after solving.

Cloudflare has an additional challenge-absent pass-through: wreq may receive a
Cloudflare 403 while the real browser receives the normal page immediately and
therefore has no challenge iframe to solve. For a same-site HTML `GET`, wafer
validates and returns the actual Playwright main-document response (status,
headers, body, and individual `Set-Cookie` values). It rejects known
challenge/block bodies, empty/non-2xx/non-HTML documents, server redirects,
cross-site or path-changing client navigation, `solve_origin`/embedder loads,
and non-GET originals. Query/fragment-only `history.replaceState()` changes
are allowed. Decoded browser bodies omit stale wire compression/framing
headers. Cookie-less validated responses are allowed, and this path merges any
browser cookies without pinning the fingerprint or rebuilding the transport.
An observed but unresolved Cloudflare iframe remains a failed solve.

### solve_origin (auto-solve on an origin page, not the API URL)

When your session's request URL is a **JSON/XHR API** (e.g.
`https://api.example.com/v1/data`, an MTop/GraphQL/REST endpoint), the automatic
browser solver can't top-navigate to it: a real browser never navigates to a
raw-JSON URL, so the page just renders the JSON, the WAF's challenge JS never
runs, and the solve times out. But the WAF token is usually mintable on the
site's real **origin page** (where the app's own JS runs). Pass `solve_origin`
to point the auto-solve at that page:

```python
session = SyncSession(
    browser_solver=solver,
    solve_origin="https://www.example.com/",  # real page; mints the WAF token
)
resp = session.get("https://api.example.com/v1/data")  # JSON API
# On a challenge, the browser navigates solve_origin, runs the challenge there,
# earns the (registrable-domain-scoped) cookies, and they replay to the API host
# on the retried TLS request. The original API URL is still used for cookie
# scoping/caching.
```

- Applies to **all** challenge types (Cloudflare, DataDome, Imperva, etc.), not
  just Imperva. It generalizes the Imperva "Error 15" origin-page solve.
- For Imperva specifically, an explicit `solve_origin` **overrides** wafer's
  auto-derived origin heuristic (you know your site's real page; use it).
- Where to earn the token is WAF mechanics (wafer's job); the per-site **value**
  of `solve_origin` (which page mints it) is yours to supply.
- Without `solve_origin`, the auto-solve navigates the request URL itself
  (correct for normal HTML pages, wrong for JSON APIs).

---

## reCAPTCHA v3 token minting

reCAPTCHA **v3** returns a *score* token, not a visible challenge. wafer mints
one with two cross-origin HTTP requests to Google's reCAPTCHA endpoints - **no
browser, no `[browser]` extra**. The token is minted under the session's own
TLS-emulated fingerprint, so it rides a real browser identity.

This is distinct from the browser-based reCAPTCHA **v2** grid/checkbox solver
(`challenge_type="recaptcha"`, handled by `BrowserSolver`). v3 minting is pure
HTTP and site-agnostic: it keys only off values readable from the embedding page.

```python
# Sync (SyncSession) - returns the token string directly:
token = session.mint_recaptcha_v3(
    sitekey,                       # the site's reCAPTCHA key (from the page)
    action,                        # action name, e.g. "login" / "submit"
    origin="https://www.site.com", # origin the sitekey is bound to
    referer=None,                  # embedding page URL; defaults to origin
    v=None,                        # api.js release token; None -> auto-scraped + cached
    enterprise=False,              # True -> reCAPTCHA Enterprise paths + enterprise.js
)

# Async (AsyncSession) - same signature, returns a coroutine:
token = await session.mint_recaptcha_v3(sitekey, action, origin="https://www.site.com")
```

Signature (identical on both sessions; async returns a coroutine):

```python
def mint_recaptcha_v3(self, sitekey: str, action: str, *,
                      origin: str | None = None, referer: str | None = None,
                      v: str | None = None, enterprise: bool = False) -> str
```

- **What it returns:** the reCAPTCHA response token (a non-empty string, ~1500-2000
  chars). You then submit it to the site exactly as a browser would (typically a
  `g-recaptcha-response` form field or a JSON body to the site's verify endpoint).
- **`origin` / `referer`:** pass at least one. `origin` is the scheme+host the
  sitekey is registered for. If only `referer` is given, `origin` is derived from
  it. The internal `co` param is `base64url(scheme://host:port)` (Google's
  `.`-padded form), computed for you.
- **Embed-mode safe:** if the session was created with `embed="xhr"` /
  `"xhr-jquery"` / `"iframe"`, minting automatically suspends embed mode for the
  cross-origin Google requests, so the embed `Accept` / `X-Requested-With` /
  `Origin` are never leaked to or duplicated against google.com. You do NOT need
  a separate non-embed session just to mint.
- **`v` is auto-scraped:** when `v=None`, wafer fetches Google's `api.js` (or
  `enterprise.js`) and scrapes the current release hash, then **caches it on the
  session** so repeat mints don't refetch. This keeps minting working when Google
  ships a new api.js. Pass `v=` explicitly only if you already know it.
- **`enterprise=True`** switches to the `recaptcha/enterprise/anchor` +
  `recaptcha/enterprise/reload` endpoints and `enterprise.js`.
- **Errors:** raises `TokenMintFailed` (a `WaferError`) if a token can't be
  extracted (missing anchor token, missing reload token, or a non-200 from
  Google). It **never silently returns None**. `.stage` is `"anchor"`,
  `"reload"`, or `"apijs"`; `.status_code` is the failing HTTP status when known.

**Caveat:** producing a token does not prove the site will accept it. Google's
score depends on reputation signals including IP, TLS identity, and cookies;
the site's score threshold is outside wafer's token-minting result.

---

## Embed Mode

Simulates cross-origin fetch() or iframe navigation. Only use when the request
origin differs from the target (e.g. `widget.com` calling `api.other.com`).
For same-origin requests, skip embed mode and pass Sec-Fetch headers per-request.

```python
session = wafer.AsyncSession(
    embed="xhr",                          # or "iframe"
    embed_origin="https://widget.com",
    embed_referers=["https://widget.com/page1", "https://widget.com/page2"],
)
resp = await session.post("https://api.other.com/data", json=body)
```

`Sec-Fetch-Site` is computed automatically (`same-origin`, `same-site`, or
`cross-site`) from `embed_origin` vs request URL. Random Referer picked per
request from `embed_referers`.

### XHR mode (`embed="xhr"`)
- Emulates a modern `fetch()` call.
- `Sec-Fetch-Mode: cors`, `Sec-Fetch-Dest: empty`, `Accept: */*`
- Sets `Origin` from embed_origin
- Strips navigation headers (`Upgrade-Insecure-Requests`, `Cache-Control`)

### jQuery XHR mode (`embed="xhr-jquery"`)
- Emulates a legacy jQuery `$.ajax` / `XMLHttpRequest` call. Use this (instead
  of `"xhr"`) when the endpoint is a classic jQuery/XHR backend that expects the
  `X-Requested-With` marker - many older `/ajax`, `getData`, tile, and
  autocomplete endpoints reject requests without it.
- Everything `"xhr"` sends (same CORS `Sec-Fetch-*`, `Origin`, Referer, stripped
  navigation headers), PLUS exactly two added headers:
  - `X-Requested-With: XMLHttpRequest`
  - `Accept: application/json, text/javascript, */*; q=0.01` (the jQuery Accept,
    instead of `"xhr"`'s `*/*`)
- Both are set at the client level (no HTTP/2 header duplication).
- Use plain `"xhr"` for modern `fetch()` endpoints (no `X-Requested-With`).

### Iframe mode (`embed="iframe"`)
- `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest: iframe`
- No `Origin` on GET navigations

Per-request `headers=` overrides any embed header.

---

## Logging

Silent by default (`NullHandler`). Enable:

```python
import logging
logging.getLogger("wafer").setLevel(logging.DEBUG)
```

---

## Common Mistakes

1. **Do not pass `emulation=` and `profile=` together.** Profile overrides emulation.
   Chrome is the default when neither is set. Dart, Safari, and iOS Safari use custom TlsOptions,
   not wreq Emulation.

2. **`resp.headers` is a plain `dict[str, str]` with lowercase keys.** Use
   `resp.headers.get("etag")`, not `resp.headers.get("ETag")`.

3. **Body kwarg is `body=`, not `data=`.** Use `body=` (raw bytes/str), `json=`
   (JSON dict), or `form=` (form-encoded dict). There is no `data=` parameter.

4. **No `auth=` parameter.** Set Authorization header manually via `headers=`.

5. **No streaming.** All responses are fully buffered. There is no `stream`,
   `iter_content()`, or `iter_lines()`.

6. **No `Session.cookies` jar attribute.** Use `session.add_cookie(raw_set_cookie, url)`
   to inject a cookie and `session.get_cookie(name, url)` to read one. The
   cookie jar itself is managed internally and not exposed.

7. **No `close()` method on sessions.** Let sessions go out of scope - they hold
   no resources needing cleanup. A `browser_solver=` you pass in is owned by you
   (session exit does not close it); call `solver.close()` yourself when done.
   See Session Lifecycle.

8. **Challenge handling is automatic.** You do not need to detect or solve challenges
   yourself. Wafer tries browser solving (if configured), then rotates across
   browser families (Chrome -> Firefox -> Safari -> Edge, swapping the header
   envelope each switch), inside its retry loop. Just catch `ChallengeDetected`
   if all attempts fail.

9. **Redirects are followed by default.** You do not need to check for 3xx or
   follow Location headers manually. 304 Not Modified is NOT followed - it passes
   through as a normal response. Disable redirect following with `follow_redirects=False`.

10. **`raise_for_status()` raises `WaferHTTPError`**, not a generic exception.
    Catch it specifically if you need the status code: `e.status_code`, `e.url`.

11. **`resp.cookies` is per-response, not the session jar.** It contains only
    the name -> value pairs from that response's own Set-Cookie headers. For
    the session's accumulated cookie state use `session.get_cookie(name, url)`,
    and for full Set-Cookie strings (with attributes) use
    `resp.get_all("set-cookie")`.

12. **Empty 200 responses raise, not return.** Unlike requests/curl_cffi which
    return a response with empty `.text`, wafer raises `EmptyResponse` after
    exhausting retries (the raised exception still carries the reply as
    `e.response`). An empty 200 from a host that already served a real body this
    session is treated as an identity-hot signal and also rotates to a fresh
    fingerprint (within `max_rotations`) before raising. If you want the
    response object returned instead, use `.bulk()` or set `max_retries=0`
    or `max_rotations=0`.

13. **Set `rate_limit` when requests need minimum spacing.** It defaults to
    `0.0` (disabled). A semaphore limits concurrency, not request frequency;
    choose the interval required by the target service.

14. **Reuse a session while cookies and identity state should persist.**
    Recreating it discards in-memory cookies, TLS identity, and rate-limiter
    state.

    Recipe - reuse, don't recreate. In a long-running service (MCP server,
    scraper, worker) build the session ONCE and reuse it. Sync:
    ```python
    _session = None
    def session():
        global _session
        if _session is None:
            _session = SyncSession(rate_limit=1.5, cache_dir="...")
        return _session
    ```
    Async: guard the lazy init with an `asyncio.Lock` (double-checked) so two
    concurrent coroutines don't race to create two sessions:
    ```python
    _session, _lock = None, asyncio.Lock()
    async def session():
        global _session
        if _session is None:
            async with _lock:
                if _session is None:
                    _session = AsyncSession(rate_limit=1.5, cache_dir="...")
        return _session
    ```
    Generic fetcher hitting MANY hosts: keep a `dict[host, session]` and reuse
    per host instead of a throwaway session per URL. One session already
    rate-limits per-hostname and scopes cookies per domain, so a single shared
    session can serve many hosts safely - reach for the per-host dict only when
    you want a distinct identity (fingerprint/cookies) per host. wafer has no
    built-in `SessionPool` type; this recipe is the whole feature.

15. **Don't use embed mode for same-origin requests.** If the page origin and
    API origin match (e.g. `example.com` to `example.com/api`), pass
    Sec-Fetch headers per-request instead. Embed mode is for cross-origin.

16. **Authorization is stripped on cross-origin redirects.** If you pass
    `headers={"Authorization": "Bearer ..."}` and the server 302s to a
    different host, the token is dropped (Fetch spec). Make two explicit
    requests if you need to send auth to both origins.

17. **`timeout=` is the TOTAL budget, not per-attempt.** Unlike requests/httpx,
    where `timeout=` bounds each attempt, wafer's `timeout=` caps the WHOLE call -
    all retries, rotations, backoff/rate-limit/`Retry-After` waits, and browser
    solves - whether set on the session or per-request (both behave identically).
    A server's `Retry-After` can never hold you past the deadline. One hanging
    attempt can eat the entire
    budget so `max_retries`/`max_rotations` never fire. Pass `attempt_timeout=` to
    bound each individual try: `session.get(url, timeout=60, attempt_timeout=15)`
    on a `SyncSession(max_rotations=3)` gives up to 4 bounded tries (rotating
    between them) inside a 60s total budget. The default `timeout=30` is now a
    hard 30s ceiling on the whole call (it used to be a per-attempt default that
    let the total run several times over) - raise it if your flow needs retries
    plus a browser solve.
