# 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 browser-grade fingerprints,
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).

**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 - it reuses the TLS identity and
cookie jar, which is faster and gives better anti-detection behavior.

---

## 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, DART
    DEFAULT_HEADERS,   # dict[str, str] - default Accept/Accept-Language/etc headers
    # 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")
    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.Chrome147 (newest).
    # Non-Chrome families get a matching HTTP header envelope automatically:
    #   Emulation.Edge147    -> Chromium headers, sec-ch-ua brand "Microsoft Edge"
    #   Emulation.Firefox149 -> 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.Chrome147 (newest)

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

    # Custom headers (replaces DEFAULT_HEADERS entirely if provided).
    # Prefer per-request headers= kwarg for one-off overrides.
    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 "Reputation-burn guard" 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 domain 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
    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.

    # 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's host: exact-host
# cookies first, then parent-domain cookies (Domain=.example.com matches
# www.example.com). 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). Returns None when
# the cookie is absent - never raises, works on all profiles.

# 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

# 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.Chrome147" / "Profile.Edge147"
                    # / "Profile.Firefox149"; for non-Emulation profiles it's the
                    # profile name ("safari", "dart", "opera_mini"). Reflects the
                    # CURRENT identity, so after a rotation it shows the one that
                    # actually served (not the session's original).
```

`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.Edge147`): 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.Firefox149`): 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).

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.Chrome147" (Emulation) or "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 a mobile Emulation (iOS/iPad Safari, Android Firefox)
}
```

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/Dart/Opera Mini) only
`user_agent` / `family` / `emulation` are populated (`family` is `"safari"` /
`"dart"` / `"opera_mini"`).

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

---

## Error Hierarchy

```
WaferError (base)
  +- ChallengeDetected    .challenge_type: str, .url: str, .status_code: int, .response: WaferResponse | None
  +- 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.

`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
- 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 "Reputation-burn guard")
- Connection error (refused/reset/TLS failure) -> raises `ConnectionFailed` after exhausting retries
- 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 escalates ACROSS BROWSER FAMILIES before cycling
versions within one family. WAF reputation pools key on browser family, so a
Chrome->Firefox switch is real diversity, while Chrome145->146->147 all share
one Chromium reputation pool (the weakest axis). Every family switch ALSO swaps
the HTTP header envelope to that family's (Accept, Accept-Language, sec-ch-ua)
so the headers stay coherent with the TLS fingerprint - a Firefox TLS shape with
Chrome's Accept/sec-ch-ua would defeat the rotation.

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). Often sufficient when the 403 is from a stale TLS session or tainted cookies, not a fingerprint block.
2. **Firefox** (rotation 2) - `Emulation.Firefox149`: Gecko TLS/H2, Firefox Accept, `Accept-Language: en-US,en;q=0.5`, NO sec-ch-ua.
3. **Safari** (rotation 3) - wafer's wire-verified Safari 26 (custom TlsOptions/Http2Options), a fundamentally different TLS/H2 shape, no client hints.
4. **Edge** (rotation 4) - `Emulation.Edge147`: 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 exactly one cross-family jump (a fresh Chrome session, then Firefox) before wafer raises. The default is deliberately low: a higher rotation budget burns more identities against the same host and worsens its reputation, so raise it only when you actually need deeper escalation. A **pinned** fingerprint (after a browser solve matched the emulation to the solving browser) does NOT rotate - its cookies are bound to that identity. A session started on a non-Chrome `emulation=` walks the same ladder skipping its own starting family; `profile=` identities (Safari/Dart/Opera Mini) keep their existing special-casing and are not forced into the ladder.

### 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 (capped). A hot identity
  "rests" while the others are tried, rather than being hammered.
- **Never retired.** `max_failures` is IGNORED in pool mode: rotation-induced
  403s are expected and must not nuke the session (this is why bell/pcbparts/
  fetchaller disable retirement today). The pool backoff is the entire health
  model. `max_rotations` still bounds the number of rotations per request.
- **Emulation-only.** Pool entries are wreq `Emulation` profiles
  (`[Emulation.Chrome147, Emulation.Firefox149, Emulation.Edge147]`). A pool is
  ignored for `profile=` (Safari/Dart/Opera Mini) sessions.

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

### Reputation-burn guard (stateful flows)

**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 (automatic; browserless at light load)

Some Imperva-protected hosts (e.g. `api2.realtor.ca`) fingerprint the TLS
stack itself and challenge *every* browser-emulating client - rotating Chrome
or Safari fingerprints can't help, because they all share the same underlying
TLS library. When wafer detects an Imperva challenge it automatically retries
the request over a system-OpenSSL transport that presents as a plain API
client (no `Sec-Fetch-*` headers), which these hosts wave through. You don't
configure anything: pass your normal `Origin`/`Referer` headers and call
`get`/`post` as usual; you just get the JSON back.

- **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.
- **Light vs heavy usage (the no-browser/browser split):** at light load these
  hosts give the OpenSSL client a free pass (no `reese84` token needed) - no
  browser. Under heavy load the WAF revokes that and demands the `reese84`
  JS token from *everyone* (even OpenSSL). When that happens and a
  `browser_solver` is set, wafer solves `reese84` **once** in a real browser and
  the token is then reused across the rest of the session (exactly how a real
  browser reuses the token it earned) - so heavy bursts keep returning data.
  This holds whether or not the host was pinned to the native path: a pinned
  host first backs off and retries native (for a transient rate blip), and if
  the challenge persists it un-pins and falls through to the same browser-solve
  escalation. Without a `browser_solver` there's no way to mint the token, so
  the heavy state raises `ChallengeDetected` (or returns the 403 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
bind cookies to TLS fingerprints). Recommended for `BrowserSolver` users.
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)

- TLS + HTTP/2 fingerprint matches real Chrome (currently Chrome 147)
- 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.Chrome147` to pin a specific version

### 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"`
- More effective than Chrome against DataDome (less commonly spoofed TLS)

### 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 domain
- **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)
- **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 17 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 (solved inline, no browser needed)
"amazon"       - Amazon CAPTCHA (solved inline, no browser needed)
"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
```

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 trio
(`acw`, `tmd`, `amazon`) needs no browser at all.

---

## Browser Solver

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

```python
from wafer.browser import BrowserSolver, SolveResult, InterceptResult

solver = BrowserSolver(
    headless=False,       # default: False. Headful recommended for stealth
    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).
)

# 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 real User-Agent
# result.extras: dict | None - WAF-specific data
# result.response: CapturedResponse | None - passthrough content (see below)
# 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()
```

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

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

**Crucial caveat:** minting always *succeeds in producing a token*, but the
**score** Google assigns it depends on request reputation - IP, TLS fingerprint,
and cookies. wafer mints the token; it **cannot guarantee** the site's score
threshold passes. A clean residential IP with the session's real-browser TLS
fingerprint scores best; a flagged datacenter IP may mint a token that the site
still rejects on score.

---

## 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 and 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` for repeated requests.** Defaults to `0.0` (disabled).
    Without it, requests fire back-to-back. A semaphore limits concurrency,
    not rate. Set `rate_limit=0.2`-`1.0` for any domain you hit more than a
    few times.

14. **Never `close()` or recreate sessions between requests to the same domain.**
    Sessions accumulate cookies, TLS identity, and rate limiting state.
    Destroying a session mid-use means the next request looks like a new
    visitor to the WAF. One session per domain, for its entire lifetime.

    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.
