[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #77 · 2026-10-30 · Benchmark · Medium · for [web scraping](https://cdpfleet.com/web-scraping)

# Kasada and AWS WAF on real sites: twelve browsers, four sites, one exit each

Two real sites behind Kasada and two behind AWS WAF, loaded once by each of twelve browsers and by a returning Camoufox profile, through one sticky residential exit per site, with the vendor signals and how long an automatic check took to clear. Dated 2026-10-30.

[Google Chrome](https://cdpfleet.com/docs/browsers/chrome) [Microsoft Edge](https://cdpfleet.com/docs/browsers/edge) [Brave](https://cdpfleet.com/docs/browsers/brave) [Opera](https://cdpfleet.com/docs/browsers/opera) [Yandex Browser](https://cdpfleet.com/docs/browsers/yandex) [Naver Whale](https://cdpfleet.com/docs/browsers/whale) [Chromium](https://cdpfleet.com/docs/browsers/chromium) [Patchright](https://cdpfleet.com/docs/browsers/patchright) [Firefox](https://cdpfleet.com/docs/browsers/firefox) [WebKit](https://cdpfleet.com/docs/browsers/webkit) [Camoufox](https://cdpfleet.com/docs/browsers/camoufox)

Run on production on 2026-10-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.

## The problem

Kasada and AWS WAF were the two vendors our [vendor survey](https://cdpfleet.com/docs/cases/which-bot-protection-is-this) found on too few public pages for their own lab case. This one takes the four real sites from our sweep where each vendor showed up on every load — two Kasada sites (one our sweep rated medium, one hard) and two AWS WAF sites (both easy for headless Chrome in the sweep) — and loads each once with twelve browsers and a returning Camoufox profile, through one residential exit per site, recording what each load ended on and how long an automatic check took.

## What we used, and why

| What | Why |
| --- | --- |
| Twelve browsers | Chrome headful and headless, Edge, Brave, Opera, Yandex, Whale, Chromium headless and Patchright (all Chromium), Firefox, WebKit, and Camoufox (based on Firefox source; not affiliated with Mozilla). Rebrowser needs a Playwright 1.52 client, so it's left out of a program written for 1.60. |
| `proxy`: `cdpfleet-resi-country-us-session-<id>` | One sticky residential exit per site, shared by every setup on that site, so a difference between rows is the browser. |
| One session at a time per site | Each site's setups run one after another; the four sites run side by side. |
| `page.on("response")` | The status of the page's first response and of the last main-page document 8 s later. Kasada answers a visitor it wants to check with `429` and a script; AWS WAF's automatic check answers `202` with its `challenge.js`. When either clears by itself the page reloads with `200`: that is `cleared`, with the seconds it took. |
| Vendor signals, by name only | Kasada: its `ips.js` script, `x-kpsdk-*` headers and `KP_UIDz` cookies. AWS WAF: an `awswaf.com` or `challenge.js` request, the `x-amzn-waf-action` header and the `aws-waf-token` cookie. Cookie and header values are never read. |
| `page.evaluate()` after 8 s | AWS WAF's CAPTCHA, or a short "verify you are human" page, is `challenge`; a near-empty page (Kasada's `429` answer that never cleared), an "access denied" page or a final error status is `blocked`; anything else `content`. Nothing is clicked. |
| `profile` | A returning visitor: Camoufox loads the site once with a new profile, the profile is saved, and a second session with it loads the site again. |
| `LAB_SITES_KASADA_AWSWAF` | The four URLs come from the environment, so the published programs don't name the sites; set your own to repeat the measurement. |

## How it works

1.  For each site, take one sticky residential exit and, one session at a time, open each of the twelve setups, load the page once, wait 8 s, and read the page, the vendor signals and the document statuses.
2.  Classify what the page ended on; a network error gets one retry.
3.  Then load the site with Camoufox and a new profile, wait for the profile to be saved, load it again with the same profile and record the second visit. Delete the profile.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/77-kasada-and-aws-waf-by-browser), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, LAB_SITES_KASADA_AWSWAF (comma-separated URLs of the pages to test; one load per page per setup, nothing clicked)
import { randomBytes } from 'node:crypto';
import { chromium, firefox, webkit } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const RUN = randomBytes(3).toString('hex');
const SITES = process.env.LAB_SITES_KASADA_AWSWAF.split(',').map((url, i) => ({ site: String.fromCharCode(65 + i), url }));
// Every browser the fleet offers that a Playwright 1.60 client can drive (Rebrowser needs 1.52, so it's left out).
const SETUPS = [
  ['chrome_headful', 'Chrome, headful', 'chrome', chromium, false],
  ['chrome_headless', 'Chrome, headless', 'chrome', chromium, 'new'],
  ['edge', 'Edge, headful', 'edge', chromium, false],
  ['brave', 'Brave, headful', 'brave', chromium, false],
  ['opera', 'Opera, headful', 'opera', chromium, false],
  ['yandex', 'Yandex, headful', 'yandex', chromium, false],
  ['whale', 'Whale, headful', 'whale', chromium, false],
  ['chromium_headless', 'Chromium, headless', 'chromium', chromium, true],
  ['patchright', 'Patchright, headful', 'patchright', chromium, false],
  ['firefox', 'Firefox, headful', 'firefox', firefox, false],
  ['webkit', 'WebKit, headful', 'webkit', webkit, false],
  ['camoufox', 'Camoufox, headful', 'camoufox', firefox, false],
];

// What the page shows after it loaded and had 8 s to run its checks, in the lab's words. AWS WAF's
// CAPTCHA ("Let's confirm you are human") is interactive: a challenge. A near-empty page 8 s later
// (Kasada's 429 answer is a blank page with a script) or an "access denied" page is blocked.
const STATE = `(() => {
  const text = document.body ? document.body.innerText : '';
  const t = (document.title + ' ' + text.slice(0, 600)).replace(/\\s+/g, ' ');
  if (document.querySelector('#captcha-container, awswaf-captcha') || (/confirm you are human|verify you are (a )?human|are you a robot|solve the puzzle/i.test(t) && text.length < 4000)) return 'challenge';
  if (/access (to this page has been )?denied|has been blocked|you don.t have permission to access|request (unsuccessful|blocked)|too many requests/i.test(t) && text.length < 4000) return 'blocked';
  if (text.trim().length < 200) return 'blank';
  return 'content';
})()`;

const KASADA_URL = /\/ips\.js(\?|$)|\/(tl|fp)(\?|$)/;
const AWS_URL = /awswaf\.com|\/challenge\.js(\?|$)/;

async function launch(engine, headless, proxy, profile) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify({ proxy, headless, record: false, ...(profile ? { profile } : {}) }),
    });
    if (res.ok) return (await res.json()).wsUrl;
    if (![429, 503].includes(res.status) || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

const failed = () => ({ outcome: 'error', status: 0, final_status: 0, kasada: false, aws_waf: false, cookies: [] });

// One session, one load. Records the status of the page's first response and of the last main-frame
// document 8 s later (an interstitial that clears itself reloads the page), which vendor's scripts,
// headers or cookies showed up (cookie and header NAMES only; values are never read), and the state.
async function visit(setup, url, proxy, profile) {
  const [, , engine, type, headless] = setup;
  const browser = await type.connect(await launch(engine, headless, proxy, profile), { headers: { 'x-api-key': KEY } });
  try {
    const context = profile ? browser.contexts()[0] : await browser.newContext();
    const tab = await context.newPage();
    const seen = { kasada: false, aws_waf: false, waf_action: null, docs: [] };
    const t0 = Date.now();
    tab.on('request', (r) => {
      if (KASADA_URL.test(r.url()) || Object.keys(r.headers()).some((h) => h.startsWith('x-kpsdk'))) seen.kasada = true;
      if (AWS_URL.test(r.url())) seen.aws_waf = true;
    });
    tab.on('response', (r) => {
      const h = r.headers();
      if (Object.keys(h).some((n) => n.startsWith('x-kpsdk'))) seen.kasada = true;
      if (h['x-amzn-waf-action']) { seen.aws_waf = true; if (/^(challenge|captcha)$/.test(h['x-amzn-waf-action'])) seen.waf_action ??= h['x-amzn-waf-action']; }
      if (r.request().isNavigationRequest() && r.frame() === tab.mainFrame()) seen.docs.push([r.status(), Date.now() - t0]);
    });
    let status = 0;
    try {
      status = (await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }))?.status() ?? 0;
    } catch (err) {
      // A reset HTTP/2 connection is a refusal, not a network failure.
      return { ...failed(), outcome: /ERR_HTTP2_PROTOCOL_ERROR/.test(err.message) ? 'blocked' : 'error' };
    }
    await tab.waitForTimeout(8000);
    let outcome = await tab.evaluate(STATE).catch(() => 'error');
    const [finalStatus, finalAt] = seen.docs.at(-1) || [status, 0];
    let clearedS = null;
    // A 202/429 check that passed by itself reloads the page with a success status: cleared, even if
    // the new page hasn't rendered yet at the 8 s mark. Without that reload it never cleared.
    if (outcome === 'content' || outcome === 'blank') {
      if (finalStatus >= 400) outcome = 'blocked';
      else if (status >= 400 || status === 202) {
        if (seen.docs.length > 1 && finalStatus !== 202) { outcome = 'cleared'; clearedS = Math.round(finalAt / 100) / 10; } else outcome = 'blocked';
      } else if (outcome === 'blank') outcome = 'blocked';
    }
    const cookies = (await context.cookies()).map((c) => c.name).filter((n) => /^(KP_|aws-waf)/i.test(n)).sort();
    if (cookies.some((n) => /^KP_/i.test(n))) seen.kasada = true;
    if (cookies.some((n) => /^aws-waf/i.test(n))) seen.aws_waf = true;
    return { outcome, status, final_status: finalStatus, ...(clearedS != null ? { cleared_s: clearedS } : {}), kasada: seen.kasada, aws_waf: seen.aws_waf, ...(seen.waf_action ? { waf_action: seen.waf_action } : {}), cookies: [...new Set(cookies)] };
  } finally {
    await browser.close();
  }
}

async function api(method, path) {
  const res = await fetch(`https://cdpfleet.com${path}`, { method, headers: { 'x-api-key': KEY } });
  return res.json().catch(() => ({}));
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// One sticky residential exit per site; its setups run one after another (one session at a time per
// site), and the sites run side by side. A network error gets one more try.
async function site({ site, url }) {
  const proxy = `cdpfleet-resi-country-us-session-c77${RUN}${site}-lifetime-30`;
  const results = {};
  for (const setup of SETUPS) {
    let r = await visit(setup, url, proxy).catch(failed);
    if (r.outcome === 'error') r = await visit(setup, url, proxy).catch(failed);
    results[setup[0]] = r;
  }
  // A returning visitor: Camoufox with a profile, a first visit and then a second session.
  const name = `case77-${RUN}-${site}`;
  const camoufox = SETUPS.find((s) => s[0] === 'camoufox');
  await visit(camoufox, url, proxy, name).catch(() => null);
  for (let j = 0; j < 60 && (await api('GET', '/v1/me/profiles')).profiles?.find((p) => p.name === name)?.in_use !== false; j++) await sleep(2000);
  results.camoufox_returning = await visit(camoufox, url, proxy, name).catch(failed);
  await api('DELETE', `/v1/me/profiles/${name}`);
  return { site, results };
}

const sites = await Promise.all(SITES.map(site));
console.log(JSON.stringify({ measured_at: new Date().toISOString().slice(0, 10), setups: Object.fromEntries([...SETUPS.map((s) => [s[0], s[1]]), ['camoufox_returning', 'Camoufox, returning profile (2nd visit)']]), sites }, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, LAB_SITES_KASADA_AWSWAF (comma-separated URLs of the pages to test; one load per page per setup, nothing clicked)
import asyncio
import json
import os
import re
import secrets
import time
from datetime import datetime, timezone

import requests
from playwright.async_api import async_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
RUN = secrets.token_hex(3)
SITES = [{"site": chr(65 + i), "url": u} for i, u in enumerate(os.environ["LAB_SITES_KASADA_AWSWAF"].split(","))]
# Every browser the fleet offers that a Playwright 1.60 client can drive (Rebrowser needs 1.52, so it's left out).
SETUPS = [
    ("chrome_headful", "Chrome, headful", "chrome", "chromium", False),
    ("chrome_headless", "Chrome, headless", "chrome", "chromium", "new"),
    ("edge", "Edge, headful", "edge", "chromium", False),
    ("brave", "Brave, headful", "brave", "chromium", False),
    ("opera", "Opera, headful", "opera", "chromium", False),
    ("yandex", "Yandex, headful", "yandex", "chromium", False),
    ("whale", "Whale, headful", "whale", "chromium", False),
    ("chromium_headless", "Chromium, headless", "chromium", "chromium", True),
    ("patchright", "Patchright, headful", "patchright", "chromium", False),
    ("firefox", "Firefox, headful", "firefox", "firefox", False),
    ("webkit", "WebKit, headful", "webkit", "webkit", False),
    ("camoufox", "Camoufox, headful", "camoufox", "firefox", False),
]

# What the page shows after it loaded and had 8 s to run its checks, in the lab's words. AWS WAF's
# CAPTCHA ("Let's confirm you are human") is interactive: a challenge. A near-empty page 8 s later
# (Kasada's 429 answer is a blank page with a script) or an "access denied" page is blocked.
STATE = r"""(() => {
  const text = document.body ? document.body.innerText : '';
  const t = (document.title + ' ' + text.slice(0, 600)).replace(/\s+/g, ' ');
  if (document.querySelector('#captcha-container, awswaf-captcha') || (/confirm you are human|verify you are (a )?human|are you a robot|solve the puzzle/i.test(t) && text.length < 4000)) return 'challenge';
  if (/access (to this page has been )?denied|has been blocked|you don.t have permission to access|request (unsuccessful|blocked)|too many requests/i.test(t) && text.length < 4000) return 'blocked';
  if (text.trim().length < 200) return 'blank';
  return 'content';
})()"""

KASADA_URL = re.compile(r"/ips\.js(\?|$)|/(tl|fp)(\?|$)")
AWS_URL = re.compile(r"awswaf\.com|/challenge\.js(\?|$)")
NONE = {"outcome": "error", "status": 0, "final_status": 0, "kasada": False, "aws_waf": False, "cookies": []}

def api(method, path):
    try:
        return requests.request(method, f"https://cdpfleet.com{path}", headers={"x-api-key": KEY}, timeout=30).json()
    except Exception:
        return {}

async def launch(engine, headless, proxy, profile=None):
    body = {"proxy": proxy, "headless": headless, "record": False, **({"profile": profile} if profile else {})}
    for attempt in range(1, 6):
        res = await asyncio.to_thread(requests.post, f"https://starter.cdpfleet.com/{engine}/session",
                                      headers={"x-api-key": KEY}, json=body, timeout=120)
        if res.ok:
            return res.json()["wsUrl"]
        if res.status_code not in (429, 503) or attempt == 5:
            raise RuntimeError(f"launch {res.status_code} {res.text}")
        await asyncio.sleep(int(res.headers.get("retry-after") or 5))

# One session, one load. Records the status of the page's first response and of the last main-frame
# document 8 s later (an interstitial that clears itself reloads the page), which vendor's scripts,
# headers or cookies showed up (cookie and header NAMES only; values are never read), and the state.
async def visit(pw, setup, url, proxy, profile=None):
    _, _, engine, kind, headless = setup
    browser = await getattr(pw, kind).connect(await launch(engine, headless, proxy, profile), headers={"x-api-key": KEY})
    try:
        context = browser.contexts[0] if profile else await browser.new_context()
        tab = await context.new_page()
        seen = {"kasada": False, "aws_waf": False, "waf_action": None, "docs": []}
        t0 = time.monotonic()

        def on_request(r):
            if KASADA_URL.search(r.url) or any(h.startswith("x-kpsdk") for h in r.headers):
                seen["kasada"] = True
            if AWS_URL.search(r.url):
                seen["aws_waf"] = True

        def on_response(r):
            h = r.headers
            if any(n.startswith("x-kpsdk") for n in h):
                seen["kasada"] = True
            action = h.get("x-amzn-waf-action")
            if action:
                seen["aws_waf"] = True
                if action in ("challenge", "captcha") and not seen["waf_action"]:
                    seen["waf_action"] = action
            if r.request.is_navigation_request() and r.frame == tab.main_frame:
                seen["docs"].append((r.status, time.monotonic() - t0))

        tab.on("request", on_request)
        tab.on("response", on_response)
        try:
            res = await tab.goto(url, wait_until="domcontentloaded", timeout=45000)
            status = res.status if res else 0
        except Exception as err:
            # A reset HTTP/2 connection is a refusal, not a network failure.
            return {**NONE, "outcome": "blocked" if "ERR_HTTP2_PROTOCOL_ERROR" in str(err) else "error"}
        await tab.wait_for_timeout(8000)
        try:
            outcome = await tab.evaluate(STATE)
        except Exception:
            outcome = "error"
        final_status, final_at = seen["docs"][-1] if seen["docs"] else (status, 0)
        cleared_s = None
        # A 202/429 check that passed by itself reloads the page with a success status: cleared, even if
        # the new page hasn't rendered yet at the 8 s mark. Without that reload it never cleared.
        if outcome in ("content", "blank"):
            if final_status >= 400:
                outcome = "blocked"
            elif status >= 400 or status == 202:
                if len(seen["docs"]) > 1 and final_status != 202:
                    outcome, cleared_s = "cleared", round(final_at, 1)
                else:
                    outcome = "blocked"
            elif outcome == "blank":
                outcome = "blocked"
        cookies = sorted({c["name"] for c in await context.cookies() if re.match(r"(?i)^(KP_|aws-waf)", c["name"])})
        if any(n.upper().startswith("KP_") for n in cookies):
            seen["kasada"] = True
        if any(n.lower().startswith("aws-waf") for n in cookies):
            seen["aws_waf"] = True
        out = {"outcome": outcome, "status": status, "final_status": final_status}
        if cleared_s is not None:
            out["cleared_s"] = cleared_s
        out.update({"kasada": seen["kasada"], "aws_waf": seen["aws_waf"]})
        if seen["waf_action"]:
            out["waf_action"] = seen["waf_action"]
        out["cookies"] = cookies
        return out
    finally:
        await browser.close()

async def safe(pw, setup, url, proxy, profile=None):
    try:
        return await visit(pw, setup, url, proxy, profile)
    except Exception:
        return dict(NONE)

# One sticky residential exit per site; its setups run one after another (one session at a time per
# site), and the sites run side by side. A network error gets one more try.
async def site(pw, s):
    proxy = f"cdpfleet-resi-country-us-session-c77{RUN}{s['site']}-lifetime-30"
    results = {}
    for setup in SETUPS:
        r = await safe(pw, setup, s["url"], proxy)
        if r["outcome"] == "error":
            r = await safe(pw, setup, s["url"], proxy)
        results[setup[0]] = r
    # A returning visitor: Camoufox with a profile, a first visit and then a second session.
    name = f"case77-{RUN}-{s['site']}"
    camoufox = next(x for x in SETUPS if x[0] == "camoufox")
    await safe(pw, camoufox, s["url"], proxy, name)
    for _ in range(60):
        p = next((p for p in api("GET", "/v1/me/profiles").get("profiles", []) if p["name"] == name), None)
        if p and p.get("in_use") is False:
            break
        await asyncio.sleep(2)
    results["camoufox_returning"] = await safe(pw, camoufox, s["url"], proxy, name)
    api("DELETE", f"/v1/me/profiles/{name}")
    return {"site": s["site"], "results": results}

async def main():
    async with async_playwright() as pw:
        sites = await asyncio.gather(*(site(pw, s) for s in SITES))
    setups = {x[0]: x[1] for x in SETUPS}
    setups["camoufox_returning"] = "Camoufox, returning profile (2nd visit)"
    print(json.dumps({"measured_at": datetime.now(timezone.utc).date().isoformat(), "setups": setups, "sites": sites}, indent=2))

asyncio.run(main())
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, LAB_SITES_KASADA_AWSWAF (comma-separated URLs of the pages to test; one load per page per setup, nothing clicked)
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
import java.net.URI;
import java.net.http.*;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Pattern;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
  static final HttpClient HTTP = HttpClient.newHttpClient();
  static final String RUN = String.format("%06x", new SecureRandom().nextInt(1 << 24));

  record Setup(String id, String name, String engine, String type, Object headless) {}

  // Every browser the fleet offers that a Playwright 1.60 client can drive (Rebrowser needs 1.52, so it's left out).
  static final List<Setup> SETUPS = List.of(
      new Setup("chrome_headful", "Chrome, headful", "chrome", "chromium", false),
      new Setup("chrome_headless", "Chrome, headless", "chrome", "chromium", "new"),
      new Setup("edge", "Edge, headful", "edge", "chromium", false),
      new Setup("brave", "Brave, headful", "brave", "chromium", false),
      new Setup("opera", "Opera, headful", "opera", "chromium", false),
      new Setup("yandex", "Yandex, headful", "yandex", "chromium", false),
      new Setup("whale", "Whale, headful", "whale", "chromium", false),
      new Setup("chromium_headless", "Chromium, headless", "chromium", "chromium", true),
      new Setup("patchright", "Patchright, headful", "patchright", "chromium", false),
      new Setup("firefox", "Firefox, headful", "firefox", "firefox", false),
      new Setup("webkit", "WebKit, headful", "webkit", "webkit", false),
      new Setup("camoufox", "Camoufox, headful", "camoufox", "firefox", false));

  // What the page shows after it loaded and had 8 s to run its checks, in the lab's words. AWS WAF's
  // CAPTCHA ("Let's confirm you are human") is interactive: a challenge. A near-empty page 8 s later
  // (Kasada's 429 answer is a blank page with a script) or an "access denied" page is blocked.
  static final String STATE = """
      (() => {
        const text = document.body ? document.body.innerText : '';
        const t = (document.title + ' ' + text.slice(0, 600)).replace(/\\s+/g, ' ');
        if (document.querySelector('#captcha-container, awswaf-captcha') || (/confirm you are human|verify you are (a )?human|are you a robot|solve the puzzle/i.test(t) && text.length < 4000)) return 'challenge';
        if (/access (to this page has been )?denied|has been blocked|you don.t have permission to access|request (unsuccessful|blocked)|too many requests/i.test(t) && text.length < 4000) return 'blocked';
        if (text.trim().length < 200) return 'blank';
        return 'content';
      })()""";

  static final Pattern KASADA_URL = Pattern.compile("/ips\\.js(\\?|$)|/(tl|fp)(\\?|$)");
  static final Pattern AWS_URL = Pattern.compile("awswaf\\.com|/challenge\\.js(\\?|$)");
  static final Pattern VENDOR_COOKIE = Pattern.compile("(?i)^(KP_|aws-waf)");

  static JsonObject api(String method, String path) {
    try {
      HttpRequest req = HttpRequest.newBuilder(URI.create("https://cdpfleet.com" + path)).header("x-api-key", KEY)
          .method(method, HttpRequest.BodyPublishers.noBody()).build();
      return JsonParser.parseString(HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()).getAsJsonObject();
    } catch (Exception e) {
      return new JsonObject();
    }
  }

  static String launch(String engine, Object headless, String proxy, String profile) throws Exception {
    Map<String, Object> body = new LinkedHashMap<>(Map.of("proxy", proxy, "headless", headless, "record", false));
    if (profile != null) body.put("profile", profile);
    for (int attempt = 1; ; attempt++) {
      HttpRequest req = HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body))).build();
      HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
      if ((res.statusCode() != 429 && res.statusCode() != 503) || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  static Map<String, Object> failed(String outcome) {
    Map<String, Object> m = new LinkedHashMap<>();
    m.put("outcome", outcome);
    m.put("status", 0);
    m.put("final_status", 0);
    m.put("kasada", false);
    m.put("aws_waf", false);
    m.put("cookies", List.of());
    return m;
  }

  static boolean anyPrefix(Map<String, String> headers, String p) {
    for (String n : headers.keySet()) if (n.toLowerCase().startsWith(p)) return true;
    return false;
  }

  // One session, one load. Records the status of the page's first response and of the last main-frame
  // document 8 s later (an interstitial that clears itself reloads the page), which vendor's scripts,
  // headers or cookies showed up (cookie and header NAMES only; values are never read), and the state.
  static Map<String, Object> visit(Playwright pw, Setup s, String url, String proxy, String profile) {
    try {
      BrowserType type = switch (s.type()) { case "firefox" -> pw.firefox(); case "webkit" -> pw.webkit(); default -> pw.chromium(); };
      Browser browser = type.connect(launch(s.engine(), s.headless(), proxy, profile),
          new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
      try {
        BrowserContext context = profile != null ? browser.contexts().get(0) : browser.newContext();
        Page tab = context.newPage();
        boolean[] vendor = {false, false}; // kasada, aws_waf
        String[] wafAction = {null};
        List<double[]> docs = new ArrayList<>();
        long t0 = System.nanoTime();
        Frame main = tab.mainFrame();
        tab.onRequest(r -> {
          if (KASADA_URL.matcher(r.url()).find() || anyPrefix(r.headers(), "x-kpsdk")) vendor[0] = true;
          if (AWS_URL.matcher(r.url()).find()) vendor[1] = true;
        });
        tab.onResponse(r -> {
          Map<String, String> h = r.headers();
          if (anyPrefix(h, "x-kpsdk")) vendor[0] = true;
          String a = h.get("x-amzn-waf-action");
          if (a != null && !a.isEmpty()) {
            vendor[1] = true;
            if ((a.equals("challenge") || a.equals("captcha")) && wafAction[0] == null) wafAction[0] = a;
          }
          if (r.request().isNavigationRequest() && r.frame() == main) docs.add(new double[] {r.status(), (System.nanoTime() - t0) / 1e9});
        });
        int status;
        try {
          Response res = tab.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(45000));
          status = res != null ? res.status() : 0;
        } catch (PlaywrightException e) {
          // A reset HTTP/2 connection is a refusal, not a network failure.
          return failed(String.valueOf(e.getMessage()).contains("ERR_HTTP2_PROTOCOL_ERROR") ? "blocked" : "error");
        }
        tab.waitForTimeout(8000);
        String outcome;
        try { outcome = (String) tab.evaluate(STATE); } catch (PlaywrightException e) { outcome = "error"; }
        int finalStatus = docs.isEmpty() ? status : (int) docs.get(docs.size() - 1)[0];
        double finalAt = docs.isEmpty() ? 0 : docs.get(docs.size() - 1)[1];
        Double clearedS = null;
        // A 202/429 check that passed by itself reloads the page with a success status: cleared, even if
        // the new page hasn't rendered yet at the 8 s mark. Without that reload it never cleared.
        if ("content".equals(outcome) || "blank".equals(outcome)) {
          if (finalStatus >= 400) outcome = "blocked";
          else if (status >= 400 || status == 202) {
            if (docs.size() > 1 && finalStatus != 202) { outcome = "cleared"; clearedS = Math.round(finalAt * 10) / 10.0; } else outcome = "blocked";
          } else if ("blank".equals(outcome)) outcome = "blocked";
        }
        TreeSet<String> cookies = new TreeSet<>();
        for (com.microsoft.playwright.options.Cookie c : context.cookies()) {
          if (VENDOR_COOKIE.matcher(c.name).find()) {
            cookies.add(c.name);
            if (c.name.toUpperCase().startsWith("KP_")) vendor[0] = true; else vendor[1] = true;
          }
        }
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("outcome", outcome);
        m.put("status", status);
        m.put("final_status", finalStatus);
        if (clearedS != null) m.put("cleared_s", clearedS);
        m.put("kasada", vendor[0]);
        m.put("aws_waf", vendor[1]);
        if (wafAction[0] != null) m.put("waf_action", wafAction[0]);
        m.put("cookies", new ArrayList<>(cookies));
        return m;
      } finally {
        browser.close();
      }
    } catch (Exception e) {
      return failed("error");
    }
  }

  // One sticky residential exit per site; its setups run one after another (one session at a time per
  // site), and the sites run side by side. A network error gets one more try.
  static Map<String, Object> site(String label, String url) throws Exception {
    try (Playwright pw = Playwright.create()) {
      String proxy = "cdpfleet-resi-country-us-session-c77" + RUN + label + "-lifetime-30";
      Map<String, Object> results = new LinkedHashMap<>();
      for (Setup s : SETUPS) {
        Map<String, Object> r = visit(pw, s, url, proxy, null);
        if ("error".equals(r.get("outcome"))) r = visit(pw, s, url, proxy, null);
        results.put(s.id(), r);
      }
      // A returning visitor: Camoufox with a profile, a first visit and then a second session.
      String name = "case77-" + RUN + "-" + label;
      Setup camoufox = SETUPS.get(SETUPS.size() - 1);
      visit(pw, camoufox, url, proxy, name);
      for (int i = 0; i < 60; i++) {
        boolean free = false;
        JsonObject list = api("GET", "/v1/me/profiles");
        if (list.has("profiles")) for (JsonElement p : list.getAsJsonArray("profiles")) {
          JsonObject o = p.getAsJsonObject();
          if (name.equals(o.get("name").getAsString()) && o.has("in_use") && !o.get("in_use").getAsBoolean()) free = true;
        }
        if (free) break;
        Thread.sleep(2000);
      }
      results.put("camoufox_returning", visit(pw, camoufox, url, proxy, name));
      api("DELETE", "/v1/me/profiles/" + name);
      return new LinkedHashMap<>(Map.of("site", label, "results", results));
    }
  }

  public static void main(String[] args) throws Exception {
    String[] urls = System.getenv("LAB_SITES_KASADA_AWSWAF").split(",");
    ExecutorService pool = Executors.newFixedThreadPool(urls.length);
    List<Future<Map<String, Object>>> futures = new ArrayList<>();
    for (int i = 0; i < urls.length; i++) {
      String label = String.valueOf((char) ('A' + i)), url = urls[i];
      futures.add(pool.submit(() -> site(label, url)));
    }
    List<Map<String, Object>> sites = new ArrayList<>();
    for (Future<Map<String, Object>> f : futures) sites.add(f.get());
    pool.shutdown();
    Map<String, String> names = new LinkedHashMap<>();
    for (Setup s : SETUPS) names.put(s.id(), s.name());
    names.put("camoufox_returning", "Camoufox, returning profile (2nd visit)");
    Map<String, Object> out = new LinkedHashMap<>();
    out.put("measured_at", Instant.now().toString().substring(0, 10));
    out.put("setups", names);
    out.put("sites", sites);
    System.out.println(GSON.toJson(out));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, LAB_SITES_KASADA_AWSWAF (comma-separated URLs of the pages to test; one load per page per setup, nothing clicked)
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var run = Convert.ToHexString(RandomNumberGenerator.GetBytes(3)).ToLowerInvariant();
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
var sites = Environment.GetEnvironmentVariable("LAB_SITES_KASADA_AWSWAF")!.Split(',').Select((u, i) => (Site: ((char)('A' + i)).ToString(), Url: u)).ToArray();

// Every browser the fleet offers that a Playwright 1.60 client can drive (Rebrowser needs 1.52, so it's left out).
var setups = new (string Id, string Name, string Engine, IBrowserType Type, JsonNode Headless)[]
{
    ("chrome_headful", "Chrome, headful", "chrome", playwright.Chromium, false),
    ("chrome_headless", "Chrome, headless", "chrome", playwright.Chromium, "new"),
    ("edge", "Edge, headful", "edge", playwright.Chromium, false),
    ("brave", "Brave, headful", "brave", playwright.Chromium, false),
    ("opera", "Opera, headful", "opera", playwright.Chromium, false),
    ("yandex", "Yandex, headful", "yandex", playwright.Chromium, false),
    ("whale", "Whale, headful", "whale", playwright.Chromium, false),
    ("chromium_headless", "Chromium, headless", "chromium", playwright.Chromium, true),
    ("patchright", "Patchright, headful", "patchright", playwright.Chromium, false),
    ("firefox", "Firefox, headful", "firefox", playwright.Firefox, false),
    ("webkit", "WebKit, headful", "webkit", playwright.Webkit, false),
    ("camoufox", "Camoufox, headful", "camoufox", playwright.Firefox, false),
};

// What the page shows after it loaded and had 8 s to run its checks, in the lab's words. AWS WAF's
// CAPTCHA ("Let's confirm you are human") is interactive: a challenge. A near-empty page 8 s later
// (Kasada's 429 answer is a blank page with a script) or an "access denied" page is blocked.
const string State = """
(() => {
  const text = document.body ? document.body.innerText : '';
  const t = (document.title + ' ' + text.slice(0, 600)).replace(/\s+/g, ' ');
  if (document.querySelector('#captcha-container, awswaf-captcha') || (/confirm you are human|verify you are (a )?human|are you a robot|solve the puzzle/i.test(t) && text.length < 4000)) return 'challenge';
  if (/access (to this page has been )?denied|has been blocked|you don.t have permission to access|request (unsuccessful|blocked)|too many requests/i.test(t) && text.length < 4000) return 'blocked';
  if (text.trim().length < 200) return 'blank';
  return 'content';
})()
""";

var kasadaUrl = new Regex(@"/ips\.js(\?|$)|/(tl|fp)(\?|$)");
var awsUrl = new Regex(@"awswaf\.com|/challenge\.js(\?|$)");
var vendorCookie = new Regex(@"^(KP_|aws-waf)", RegexOptions.IgnoreCase);

async Task<string> Launch(string engine, JsonNode headless, string proxy, string? profile)
{
    var body = new JsonObject { ["proxy"] = proxy, ["headless"] = headless.DeepClone(), ["record"] = false };
    if (profile != null) body["profile"] = profile;
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session", JsonContent.Create(body));
        if (res.IsSuccessStatusCode) return (await res.Content.ReadFromJsonAsync<JsonObject>())!["wsUrl"]!.GetValue<string>();
        if (((int)res.StatusCode != 429 && (int)res.StatusCode != 503) || attempt == 5)
            throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5) * 1000);
    }
}

JsonObject Failed(string outcome) =>
    new() { ["outcome"] = outcome, ["status"] = 0, ["final_status"] = 0, ["kasada"] = false, ["aws_waf"] = false, ["cookies"] = new JsonArray() };

// One session, one load. Records the status of the page's first response and of the last main-frame
// document 8 s later (an interstitial that clears itself reloads the page), which vendor's scripts,
// headers or cookies showed up (cookie and header NAMES only; values are never read), and the state.
async Task<JsonObject> Visit((string Id, string Name, string Engine, IBrowserType Type, JsonNode Headless) s, string url, string proxy, string? profile = null)
{
    try
    {
        var browser = await s.Type.ConnectAsync(await Launch(s.Engine, s.Headless, proxy, profile),
            new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
        try
        {
            var context = profile != null ? browser.Contexts[0] : await browser.NewContextAsync();
            var tab = await context.NewPageAsync();
            var gate = new object();
            bool kasada = false, aws = false;
            string? wafAction = null;
            var docs = new List<(int Status, double At)>();
            var t0 = DateTime.UtcNow;
            var main = tab.MainFrame;
            tab.Request += (_, r) =>
            {
                lock (gate)
                {
                    if (kasadaUrl.IsMatch(r.Url) || r.Headers.Keys.Any(h => h.StartsWith("x-kpsdk", StringComparison.OrdinalIgnoreCase))) kasada = true;
                    if (awsUrl.IsMatch(r.Url)) aws = true;
                }
            };
            tab.Response += (_, r) =>
            {
                lock (gate)
                {
                    if (r.Headers.Keys.Any(h => h.StartsWith("x-kpsdk", StringComparison.OrdinalIgnoreCase))) kasada = true;
                    if (r.Headers.TryGetValue("x-amzn-waf-action", out var a) && !string.IsNullOrEmpty(a))
                    {
                        aws = true;
                        if ((a == "challenge" || a == "captcha") && wafAction == null) wafAction = a;
                    }
                    if (r.Request.IsNavigationRequest && r.Frame == main) docs.Add((r.Status, (DateTime.UtcNow - t0).TotalSeconds));
                }
            };
            int status;
            try
            {
                var res = await tab.GotoAsync(url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 45000 });
                status = res?.Status ?? 0;
            }
            catch (PlaywrightException e)
            {
                // A reset HTTP/2 connection is a refusal, not a network failure.
                return Failed(e.Message.Contains("ERR_HTTP2_PROTOCOL_ERROR") ? "blocked" : "error");
            }
            await tab.WaitForTimeoutAsync(8000);
            string outcome;
            try { outcome = await tab.EvaluateAsync<string>(State); } catch (PlaywrightException) { outcome = "error"; }
            var cookies = (await context.CookiesAsync()).Select(c => c.Name).Where(n => vendorCookie.IsMatch(n)).Distinct().OrderBy(n => n, StringComparer.Ordinal).ToList();
            lock (gate)
            {
                var (finalStatus, finalAt) = docs.Count > 0 ? docs[^1] : (status, 0.0);
                double? clearedS = null;
                // A 202/429 check that passed by itself reloads the page with a success status: cleared, even if
                // the new page hasn't rendered yet at the 8 s mark. Without that reload it never cleared.
                if (outcome == "content" || outcome == "blank")
                {
                    if (finalStatus >= 400) outcome = "blocked";
                    else if (status >= 400 || status == 202)
                    {
                        if (docs.Count > 1 && finalStatus != 202) { outcome = "cleared"; clearedS = Math.Round(finalAt, 1); } else outcome = "blocked";
                    }
                    else if (outcome == "blank") outcome = "blocked";
                }
                if (cookies.Any(n => n.StartsWith("KP_", StringComparison.OrdinalIgnoreCase))) kasada = true;
                if (cookies.Any(n => n.StartsWith("aws-waf", StringComparison.OrdinalIgnoreCase))) aws = true;
                var m = new JsonObject { ["outcome"] = outcome, ["status"] = status, ["final_status"] = finalStatus };
                if (clearedS != null) m["cleared_s"] = clearedS;
                m["kasada"] = kasada;
                m["aws_waf"] = aws;
                if (wafAction != null) m["waf_action"] = wafAction;
                m["cookies"] = new JsonArray(cookies.Select(n => (JsonNode)n!).ToArray());
                return m;
            }
        }
        finally { await browser.CloseAsync(); }
    }
    catch (Exception) { return Failed("error"); }
}

// One sticky residential exit per site; its setups run one after another (one session at a time per
// site), and the sites run side by side. A network error gets one more try.
async Task<JsonObject> Site(string label, string url)
{
    var proxy = $"cdpfleet-resi-country-us-session-c77{run}{label}-lifetime-30";
    var results = new JsonObject();
    foreach (var s in setups)
    {
        var r = await Visit(s, url, proxy);
        if (r["outcome"]!.GetValue<string>() == "error") r = await Visit(s, url, proxy);
        results[s.Id] = r;
    }
    // A returning visitor: Camoufox with a profile, a first visit and then a second session.
    var name = $"case77-{run}-{label}";
    var camoufox = setups[^1];
    await Visit(camoufox, url, proxy, name);
    for (var i = 0; i < 60; i++)
    {
        var list = await http.GetFromJsonAsync<JsonObject>("https://cdpfleet.com/v1/me/profiles");
        var p = list?["profiles"]?.AsArray().FirstOrDefault(x => x?["name"]?.GetValue<string>() == name);
        if (p?["in_use"]?.GetValue<bool>() == false) break;
        await Task.Delay(2000);
    }
    results["camoufox_returning"] = await Visit(camoufox, url, proxy, name);
    await http.DeleteAsync($"https://cdpfleet.com/v1/me/profiles/{name}");
    return new JsonObject { ["site"] = label, ["results"] = results };
}

var done = await Task.WhenAll(sites.Select(x => Site(x.Site, x.Url)));
var names = new JsonObject();
foreach (var s in setups) names[s.Id] = s.Name;
names["camoufox_returning"] = "Camoufox, returning profile (2nd visit)";
var output = new JsonObject
{
    ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
    ["setups"] = names,
    ["sites"] = new JsonArray(done.Select(d => (JsonNode)d).ToArray()),
};
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, LAB_SITES_KASADA_AWSWAF (comma-separated URLs of the pages to test; one load per page per setup, nothing clicked)
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"math"
	"os"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")
var runID = func() string { b := make([]byte, 3); rand.Read(b); return hex.EncodeToString(b) }()

type setup struct {
	ID, Name, Engine, Type string
	Headless               any // false, true or "new"
}

// Every browser the fleet offers that a Playwright 1.60 client can drive (Rebrowser needs 1.52, so it's left out).
var setups = []setup{
	{"chrome_headful", "Chrome, headful", "chrome", "chromium", false},
	{"chrome_headless", "Chrome, headless", "chrome", "chromium", "new"},
	{"edge", "Edge, headful", "edge", "chromium", false},
	{"brave", "Brave, headful", "brave", "chromium", false},
	{"opera", "Opera, headful", "opera", "chromium", false},
	{"yandex", "Yandex, headful", "yandex", "chromium", false},
	{"whale", "Whale, headful", "whale", "chromium", false},
	{"chromium_headless", "Chromium, headless", "chromium", "chromium", true},
	{"patchright", "Patchright, headful", "patchright", "chromium", false},
	{"firefox", "Firefox, headful", "firefox", "firefox", false},
	{"webkit", "WebKit, headful", "webkit", "webkit", false},
	{"camoufox", "Camoufox, headful", "camoufox", "firefox", false},
}

// What the page shows after it loaded and had 8 s to run its checks, in the lab's words. AWS WAF's
// CAPTCHA ("Let's confirm you are human") is interactive: a challenge. A near-empty page 8 s later
// (Kasada's 429 answer is a blank page with a script) or an "access denied" page is blocked.
const state = `(() => {
  const text = document.body ? document.body.innerText : '';
  const t = (document.title + ' ' + text.slice(0, 600)).replace(/\s+/g, ' ');
  if (document.querySelector('#captcha-container, awswaf-captcha') || (/confirm you are human|verify you are (a )?human|are you a robot|solve the puzzle/i.test(t) && text.length < 4000)) return 'challenge';
  if (/access (to this page has been )?denied|has been blocked|you don.t have permission to access|request (unsuccessful|blocked)|too many requests/i.test(t) && text.length < 4000) return 'blocked';
  if (text.trim().length < 200) return 'blank';
  return 'content';
})()`

var kasadaURL = regexp.MustCompile(`/ips\.js(\?|$)|/(tl|fp)(\?|$)`)
var awsURL = regexp.MustCompile(`awswaf\.com|/challenge\.js(\?|$)`)
var vendorCookie = regexp.MustCompile(`(?i)^(KP_|aws-waf)`)

type cell struct {
	Outcome     string   `json:"outcome"`
	Status      int      `json:"status"`
	FinalStatus int      `json:"final_status"`
	ClearedS    *float64 `json:"cleared_s,omitempty"`
	Kasada      bool     `json:"kasada"`
	AwsWaf      bool     `json:"aws_waf"`
	WafAction   string   `json:"waf_action,omitempty"`
	Cookies     []string `json:"cookies"`
}

func failed(outcome string) cell { return cell{Outcome: outcome, Cookies: []string{}} }

func hasPrefix(h map[string]string, p string) bool {
	for n := range h {
		if strings.HasPrefix(strings.ToLower(n), p) {
			return true
		}
	}
	return false
}

func api(method, path string) map[string]any {
	req, _ := http.NewRequest(method, "https://cdpfleet.com"+path, nil)
	req.Header.Set("x-api-key", key)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return map[string]any{}
	}
	defer res.Body.Close()
	var out map[string]any
	json.NewDecoder(res.Body).Decode(&out)
	return out
}

func launch(engine string, headless any, proxy, profile string) (string, error) {
	body := map[string]any{"proxy": proxy, "headless": headless, "record": false}
	if profile != "" {
		body["profile"] = profile
	}
	b, _ := json.Marshal(body)
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(b))
		req.Header.Set("x-api-key", key)
		req.Header.Set("content-type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return "", err
		}
		data, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == 200 {
			var s struct{ WsUrl string }
			json.Unmarshal(data, &s)
			return s.WsUrl, nil
		}
		if (res.StatusCode != 429 && res.StatusCode != 503) || attempt == 5 {
			return "", fmt.Errorf("launch %d %s", res.StatusCode, data)
		}
		wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
		if wait == 0 {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

// One session, one load. Records the status of the page's first response and of the last main-frame
// document 8 s later (an interstitial that clears itself reloads the page), which vendor's scripts,
// headers or cookies showed up (cookie and header NAMES only; values are never read), and the state.
func visit(pw *playwright.Playwright, s setup, url, proxy, profile string) cell {
	ws, err := launch(s.Engine, s.Headless, proxy, profile)
	if err != nil {
		return failed("error")
	}
	bt := map[string]playwright.BrowserType{"chromium": pw.Chromium, "firefox": pw.Firefox, "webkit": pw.WebKit}[s.Type]
	browser, err := bt.Connect(ws, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return failed("error")
	}
	defer browser.Close()
	var ctx playwright.BrowserContext
	if profile != "" {
		ctx = browser.Contexts()[0]
	} else if ctx, err = browser.NewContext(); err != nil {
		return failed("error")
	}
	tab, err := ctx.NewPage()
	if err != nil {
		return failed("error")
	}
	var mu sync.Mutex
	c := cell{Cookies: []string{}}
	type doc struct {
		status int
		at     float64
	}
	docs := []doc{}
	t0 := time.Now()
	main := tab.MainFrame()
	tab.OnRequest(func(r playwright.Request) {
		mu.Lock()
		defer mu.Unlock()
		if kasadaURL.MatchString(r.URL()) || hasPrefix(r.Headers(), "x-kpsdk") {
			c.Kasada = true
		}
		if awsURL.MatchString(r.URL()) {
			c.AwsWaf = true
		}
	})
	tab.OnResponse(func(r playwright.Response) {
		h := r.Headers()
		mu.Lock()
		defer mu.Unlock()
		if hasPrefix(h, "x-kpsdk") {
			c.Kasada = true
		}
		if a := h["x-amzn-waf-action"]; a != "" {
			c.AwsWaf = true
			if (a == "challenge" || a == "captcha") && c.WafAction == "" {
				c.WafAction = a
			}
		}
		if r.Request().IsNavigationRequest() && r.Frame() == main {
			docs = append(docs, doc{r.Status(), time.Since(t0).Seconds()})
		}
	})
	res, err := tab.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(45000)})
	if err != nil {
		// A reset HTTP/2 connection is a refusal, not a network failure.
		if strings.Contains(err.Error(), "ERR_HTTP2_PROTOCOL_ERROR") {
			return failed("blocked")
		}
		return failed("error")
	}
	status := 0
	if res != nil {
		status = res.Status()
	}
	tab.WaitForTimeout(8000)
	v, err := tab.Evaluate(state)
	outcome, _ := v.(string)
	if err != nil || outcome == "" {
		outcome = "error"
	}
	// Read the cookies before taking the lock: the event handlers need it, and a Playwright call made
	// while holding it would wait on the very goroutine that delivers those events.
	cookies, cookieErr := ctx.Cookies()
	mu.Lock()
	defer mu.Unlock()
	final, finalAt := status, 0.0
	if len(docs) > 0 {
		final, finalAt = docs[len(docs)-1].status, docs[len(docs)-1].at
	}
	// A 202/429 check that passed by itself reloads the page with a success status: cleared, even if
	// the new page hasn't rendered yet at the 8 s mark. Without that reload it never cleared.
	if outcome == "content" || outcome == "blank" {
		if final >= 400 {
			outcome = "blocked"
		} else if status >= 400 || status == 202 {
			if len(docs) > 1 && final != 202 {
				outcome = "cleared"
				s := math.Round(finalAt*10) / 10
				c.ClearedS = &s
			} else {
				outcome = "blocked"
			}
		} else if outcome == "blank" {
			outcome = "blocked"
		}
	}
	c.Outcome, c.Status, c.FinalStatus = outcome, status, final
	if cookieErr == nil {
		set := map[string]bool{}
		for _, k := range cookies {
			if vendorCookie.MatchString(k.Name) && !set[k.Name] {
				set[k.Name] = true
				c.Cookies = append(c.Cookies, k.Name)
				if strings.HasPrefix(strings.ToUpper(k.Name), "KP_") {
					c.Kasada = true
				} else {
					c.AwsWaf = true
				}
			}
		}
		sort.Strings(c.Cookies)
	}
	return c
}

// One sticky residential exit per site; its setups run one after another (one session at a time per
// site), and the sites run side by side. A network error gets one more try.
func site(label, url string) map[string]any {
	pw, err := playwright.Run()
	if err != nil {
		log.Fatal(err)
	}
	defer pw.Stop()
	proxy := "cdpfleet-resi-country-us-session-c77" + runID + label + "-lifetime-30"
	results := map[string]cell{}
	for _, s := range setups {
		r := visit(pw, s, url, proxy, "")
		if r.Outcome == "error" {
			r = visit(pw, s, url, proxy, "")
		}
		results[s.ID] = r
	}
	// A returning visitor: Camoufox with a profile, a first visit and then a second session.
	name := "case77-" + runID + "-" + label
	camoufox := setups[len(setups)-1]
	visit(pw, camoufox, url, proxy, name)
	for i := 0; i < 60; i++ {
		free := false
		if list, ok := api("GET", "/v1/me/profiles")["profiles"].([]any); ok {
			for _, p := range list {
				if m := p.(map[string]any); m["name"] == name && m["in_use"] == false {
					free = true
				}
			}
		}
		if free {
			break
		}
		time.Sleep(2 * time.Second)
	}
	results["camoufox_returning"] = visit(pw, camoufox, url, proxy, name)
	api("DELETE", "/v1/me/profiles/"+name)
	return map[string]any{"site": label, "results": results}
}

func main() {
	urls := strings.Split(os.Getenv("LAB_SITES_KASADA_AWSWAF"), ",")
	sites := make([]map[string]any, len(urls))
	var wg sync.WaitGroup
	for i, u := range urls {
		wg.Add(1)
		go func(i int, u string) { defer wg.Done(); sites[i] = site(string(rune('A'+i)), u) }(i, u)
	}
	wg.Wait()
	names := map[string]string{"camoufox_returning": "Camoufox, returning profile (2nd visit)"}
	for _, s := range setups {
		names[s.ID] = s.Name
	}
	out, _ := json.MarshalIndent(map[string]any{"measured_at": time.Now().UTC().Format("2006-01-02"), "setups": names, "sites": sites}, "", "  ")
	fmt.Println(string(out))
}
```

## What we got

| Setup | Site A, a footwear retailer (Kasada) | Site B, a property portal (Kasada) | Site C, a travel-booking site (AWS WAF) | Site D, a transit-app site (AWS WAF) | Measured |
| --- | --- | --- | --- | --- | --- |
| Chrome, headful | blocked (429) | cleared (429 → 200) · 7.4 s | cleared (202 → 200) · 3.4 s | error | 2026-10-30 |
| Chrome, headless | blocked (429) | blocked (429) | cleared (202 → 200) · 4.8 s | blocked (202 → 403) | 2026-10-30 |
| Edge, headful | blocked (429) | blocked (429) | cleared (202 → 200) · 3.9 s | cleared (202 → 200) · 4.6 s | 2026-10-30 |
| Brave, headful | blocked (429) | cleared (429 → 200) · 7.8 s | cleared (202 → 200) · 3.1 s | cleared (202 → 200) · 4.9 s | 2026-10-30 |
| Opera, headful | blocked (429) | blocked (429) | cleared (202 → 200) · 3.4 s | cleared (202 → 200) · 4.6 s | 2026-10-30 |
| Yandex, headful | blocked (429) | blocked (429) | cleared (202 → 200) · 5.2 s | cleared (202 → 200) · 4.6 s | 2026-10-30 |
| Whale, headful | blocked (429) | blocked (429) | cleared (202 → 200) · 5.1 s | cleared (202 → 200) · 4.7 s | 2026-10-30 |
| Chromium, headless | blocked (429) | blocked (429) | cleared (202 → 200) · 4.3 s | blocked (202 → 403) | 2026-10-30 |
| Patchright, headful | blocked (429) | blocked (429) | cleared (202 → 200) · 5.1 s | cleared (202 → 200) · 4.6 s | 2026-10-30 |
| Firefox, headful | blocked (429) | blocked (429) | cleared (202 → 200) · 4.4 s | cleared (202 → 200) · 4.8 s | 2026-10-30 |
| WebKit, headful | blocked (429) | blocked (429) | cleared (202 → 200) · 5.8 s | cleared (202 → 200) · 4.6 s | 2026-10-30 |
| Camoufox, headful | cleared (429 → 200) · 3.9 s | blocked (429) | cleared (202 → 200) · 3.9 s | cleared (202 → 200) · 4.5 s | 2026-10-30 |
| Camoufox, returning profile (2nd visit) | content (200) | content (200) | error | content (200) | 2026-10-30 |

From the Node.js run on 2026-10-30. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)

```
{
  "measured_at": "2026-10-30",
  "setups": {
    "chrome_headful": "Chrome, headful",
    "chrome_headless": "Chrome, headless",
    "edge": "Edge, headful",
    "brave": "Brave, headful",
    "opera": "Opera, headful",
    "yandex": "Yandex, headful",
    "whale": "Whale, headful",
    "chromium_headless": "Chromium, headless",
    "patchright": "Patchright, headful",
    "firefox": "Firefox, headful",
    "webkit": "WebKit, headful",
    "camoufox": "Camoufox, headful",
    "camoufox_returning": "Camoufox, returning profile (2nd visit)"
  },
  "sites": [
    {
      "site": "A",
      "results": {
        "chrome_headful": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "chrome_headless": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "edge": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "brave": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "opera": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "yandex": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "whale": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "chromium_headless": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "patchright": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "firefox": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "webkit": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "camoufox": {
          "outcome": "cleared",
          "status": 429,
          "final_status": 200,
          "cleared_s": 3.9,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        },
        "camoufox_returning": {
          "outcome": "content",
          "status": 200,
          "final_status": 200,
          "kasada": true,
          "aws_waf": false,
          "cookies": []
        }
      }
    },
    {
      "site": "B",
      "results": {
        "chrome_headful": {
          "outcome": "cleared",
          "status": 429,
          "final_status": 200,
          "cleared_s": 7.4,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "chrome_headless": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "edge": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "brave": {
          "outcome": "cleared",
          "status": 429,
          "final_status": 200,
          "cleared_s": 7.8,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "opera": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "yandex": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "whale": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "chromium_headless": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "patchright": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "firefox": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "webkit": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "camoufox": {
          "outcome": "blocked",
          "status": 429,
          "final_status": 429,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        },
        "camoufox_returning": {
          "outcome": "content",
          "status": 200,
          "final_status": 200,
          "kasada": true,
          "aws_waf": false,
          "cookies": [
            "KP_UIDz",
            "KP_UIDz-ssn"
          ]
        }
      }
    },
    {
      "site": "C",
      "results": {
        "chrome_headful": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 3.4,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "chrome_headless": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.8,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "edge": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 3.9,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "brave": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 3.1,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "opera": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 3.4,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "yandex": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 5.2,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "whale": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 5.1,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "chromium_headless": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.3,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "patchright": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 5.1,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "firefox": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.4,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "webkit": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 5.8,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "camoufox": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 3.9,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        },
        "camoufox_returning": {
          "outcome": "error",
          "status": 0,
          "final_status": 0,
          "kasada": false,
          "aws_waf": false,
          "cookies": []
        }
      }
    },
    {
      "site": "D",
      "results": {
        "chrome_headful": {
          "outcome": "error",
          "status": 0,
          "final_status": 0,
          "kasada": false,
          "aws_waf": false,
          "cookies": []
        },
        "chrome_headless": {
          "outcome": "blocked",
          "status": 202,
          "final_status": 403,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "edge": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.6,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "brave": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.9,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "opera": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.6,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "yandex": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.6,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "whale": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.7,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "chromium_headless": {
          "outcome": "blocked",
          "status": 202,
          "final_status": 403,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "patchright": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.6,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "firefox": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.8,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "webkit": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.6,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "camoufox": {
          "outcome": "cleared",
          "status": 202,
          "final_status": 200,
          "cleared_s": 4.5,
          "kasada": false,
          "aws_waf": true,
          "waf_action": "challenge",
          "cookies": [
            "aws-waf-token"
          ]
        },
        "camoufox_returning": {
          "outcome": "content",
          "status": 200,
          "final_status": 200,
          "kasada": false,
          "aws_waf": true,
          "cookies": [
            "aws-waf-token"
          ]
        }
      }
    }
  ]
}
```

## Takeaways

-   **Kasada turned away every browser but Camoufox.** On 2026-10-30, across five runs (one per language), the eleven setups other than Camoufox got Kasada's `429` and a page that stayed blank in 108 of their 110 loads on the two Kasada sites; headful Chrome and Brave each got through once on site B, after 7.4 and 7.8 s. Kasada shows no CAPTCHA: its `429` page runs a script, and the visit either reloads with `200` by itself or stays blank. That is stricter than Akamai's hardest site, where Brave and Opera got in every run ([case 73](https://cdpfleet.com/docs/cases/akamai-real-sites-by-browser)).
-   **Camoufox got through on the easier Kasada site, every time.** On site A fresh Camoufox got the same `429` and cleared it by itself in all five runs, the page reloading with `200` 3.9–5.2 s after the load started; on site B (the one our sweep rated hard) it cleared once in five. A returning Camoufox profile went straight to the page (`200`, no check) on site A in all five runs, and on site B in the two runs where its second session launched (three network errors).
-   **AWS WAF checked everyone and let nearly everyone through.** Both AWS WAF sites answered every first load with `202` and its `challenge.js`; the check then passed by itself and the page reloaded with `200`, in a median 4.4 s on site C and 5.2 s on site D (3.0–20.3 s, counted from the start of the load). Site C let every setup through in 59 of 60 loads, headless included; on site D the ten headful setups cleared in 47 of 49 measured loads. No load on either site ended on a CAPTCHA.
-   **Site D refused headless, site C didn't.** Headless Chrome and headless Chromium got `202` and then `403` on site D in all five runs, while on site C they cleared like everyone else. Site D's first answer carried `x-amzn-waf-action: challenge` on 59 loads; site C sent no such header and relied on `challenge.js` alone — two sites on the same vendor, set up differently.
-   **A saved profile skips the check on both vendors.** With a profile from its first visit, Camoufox's second session got the page with `200` and no interstitial on both AWS WAF sites in every run where it launched (9 of 9) and on Kasada site A in five of five. Kasada set `KP_UIDz` cookies on site B on every load, let in or not; site A set no Kasada cookie at all — its signal was the `ips.js` script.
-   These are four sites on one day through one country's exits; vendors and site owners change their settings often. How the same browsers fared against Cloudflare, DataDome, Akamai and HUMAN: [cases 70](https://cdpfleet.com/docs/cases/cloudflare-real-sites-by-browser), [71](https://cdpfleet.com/docs/cases/datadome-real-sites-by-browser), [73](https://cdpfleet.com/docs/cases/akamai-real-sites-by-browser) and [75](https://cdpfleet.com/docs/cases/human-real-sites-by-browser), and the overview on [/bot-protection](https://cdpfleet.com/bot-protection).

---
Source: https://cdpfleet.com/docs/cases/kasada-and-aws-waf-by-browser · Markdown: https://cdpfleet.com/docs/cases/kasada-and-aws-waf-by-browser.md · All docs for AI tools: https://cdpfleet.com/llms.txt
