> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to use curl_cffi: A beginner's guide for 2026
- URL: https://roundproxies.com/blog/curl_cffi/
- Published: 2026-09-24T16:51:43.000Z
- Updated: 2026-09-24T17:25:34.000Z
- Description: Learn how to use curl_cffi: install, impersonate Chrome, add proxies, go async. Working code included.
- Author: Marius Bernard
- Tags: Web Scraping, Bypass, #dated-68e3a07b4fa498a2c6ee1e38

The first time a site 403s you for no reason is confusing. Your headers are perfect, your User-Agent says Chrome, the page loads fine in a browser, and `requests.get()` still comes back with a Cloudflare interstitial.

The server never read your User-Agent. It fingerprinted your TLS handshake, saw Python's OpenSSL cipher order, and blocked you before the HTTP request existed. That's the problem curl\_cffi solves, and it solves it with one keyword argument.

This guide covers how to use curl\_cffi from `pip install` through sessions, proxies, async concurrency, and the errors you'll hit along the way. You need Python 3.10+ and nothing else. Every solution here runs on your own machine, with no scraping API involved.

If you want the theory first, read [what a TLS fingerprint is](https://roundproxies.com/blog/what-is-tls-fingerprint/) and come back. If you want working code, keep going.

## What is curl\_cffi?

curl\_cffi is a Python HTTP client that impersonates real browser TLS and HTTP/2 fingerprints. It binds to curl-impersonate, a patched libcurl, through cffi, and exposes a requests-style API. Pass `impersonate="chrome"` and your request negotiates its handshake the way Chrome does, so fingerprint-based bot detection sees a browser. Use it when `requests` gets 403s that a browser doesn't.

In practice, that means curl\_cffi can:

- Match the JA3/JA4 hash and HTTP/2 SETTINGS frame of Chrome, Safari, Firefox, Edge, and Tor presets
- Speak HTTP/2 and HTTP/3, which `requests` cannot
- Run sync, async (asyncio), and WebSocket workloads from one package
- Retry natively with backoff and jitter, no `urllib3.Retry` dance
- Rotate proxies per request inside an async session

The project is MIT licensed, maintained by lexiforest, and ships prebuilt wheels for Linux, macOS, and Windows. There's no compiler step. The [GitHub repo](https://github.com/lexiforest/curl%5Fcffi) is the source of truth; the [docs](https://curl-cffi.readthedocs.io/) are current and worth bookmarking.

One naming quirk trips up everyone once: the PyPI package is `curl-cffi` (hyphen), the import is `curl_cffi` (underscore). Pip normalizes both spellings, so either works at install time. The import does not forgive you.

## curl\_cffi vs requests vs httpx vs aiohttp

curl\_cffi isn't the only HTTP client in Python, and for plain API work it's overkill. It earns its spot when the target checks fingerprints.

| Feature                        | curl\_cffi      | requests | httpx       | aiohttp |
| ------------------------------ | --------------- | -------- | ----------- | ------- |
| Browser TLS/HTTP2 fingerprints | Yes             | No       | No          | No      |
| HTTP/2                         | Yes             | No       | Yes (extra) | No      |
| HTTP/3                         | Yes             | No       | No          | No      |
| Sync                           | Yes             | Yes      | Yes         | No      |
| Async                          | Yes             | No       | Yes         | Yes     |
| WebSocket                      | Yes             | No       | No          | Yes     |
| Native retry                   | Yes             | No       | No          | No      |
| Pure Python                    | No (C via cffi) | Yes      | Yes         | Mostly  |

Choose curl\_cffi when the site returns 403 to `requests` but 200 to a browser, when you need HTTP/2 without extras, or when you want proxy rotation baked into an async session.

Stick with `requests` or `httpx` for internal APIs, well-behaved public endpoints, or anywhere the fingerprint doesn't matter. The C dependency makes curl\_cffi slightly heavier to package (PyInstaller needs a flag; see troubleshooting), and that cost isn't worth paying for `api.github.com`.

The [repo's benchmark folder](https://github.com/lexiforest/curl%5Fcffi/tree/main/benchmark) puts it on par with aiohttp and pycurl and ahead of requests/httpx. Run it yourself before quoting numbers; results depend heavily on your target and network.

## How to install curl\_cffi

Python 3.10 is the floor since v0.14\. Python 3.9 is end-of-life and unsupported. Check first:

```bash
python --version   # needs 3.10 or newer

```

Then install into a virtual environment. This matters more than usual here because curl\_cffi bundles its own libcurl-impersonate, and mixing it with a system curl or an old `pycurl` in the same environment invites confusion.

```bash
python -m venv .venv
source .venv/bin/activate     # Windows: .venv\Scripts\activate
pip install curl_cffi --upgrade

```

macOS users can also `brew install lexiforest/tap/curl-cffi` if they want the CLI system-wide. Verify with a one-liner:

```bash
python -c "import curl_cffi; print(curl_cffi.__version__)"

```

Since v0.15 the package ships a CLI called `curl-cffi`. It's the fastest way to sanity-check an install and to see what a target sees:

```bash
curl-cffi get https://tls.browserleaks.com/json --impersonate chrome

```

You'll get JSON back with a `ja3n_hash`, `ja4`, and `akamai_hash`. Compare those to a real Chrome tab on the same site. If they match, the install works and the impersonation is sound.

Two more CLI commands you'll use: `curl-cffi list` shows every fingerprint preset on your machine, and `curl-cffi update` pulls newer Chrome, Safari, and Firefox presets without upgrading the package. That second one is new, and most tutorials don't mention it.

## curl\_cffi basic concepts

Four ideas carry the whole library.

### Impersonation targets

A target is a string like `chrome146`, `safari260`, or `firefox147` that selects a complete browser profile: cipher suites in browser order, TLS extension order, ALPN offer, HTTP/2 SETTINGS, and the matching default headers. The version-less aliases `chrome`, `safari`, and `safari_ios` resolve to the newest preset in your installed version.

The full preset table lives in the [curl-impersonate docs](https://curl-impersonate.readthedocs.io/en/latest/fingerprints.html). Firefox has been supported since the `firefox133` preset; older guides that say otherwise are out of date.

### Default headers

Setting `impersonate=` also injects that browser's headers (User-Agent, Accept, sec-ch-ua, and so on). This is a feature, and it's also the source of the most common self-inflicted wound in curl\_cffi code. More on that in the header trap section.

### Session vs one-shot calls

`curl_cffi.get()` works like `requests.get()`: new connection, no cookie jar. `curl_cffi.Session()` reuses connections and persists cookies. The maintainers say to always use a session where possible, and they're right; a fresh handshake per request is slower and looks less like a browser to anti-bot systems.

### Sync, async, and the low-level curl API

The requests-like layer is what you'll use 95% of the time. `AsyncSession` gives you the same API under asyncio. Beneath both sits a raw `Curl` object with `setopt()` for anything libcurl can do. You won't need it in this guide, but it's there when a weird option comes up.

## Your first curl\_cffi request

Suppose you're scraping a retail site that blocks `requests` outright. The plan: fetch with impersonation, confirm the status, and parse the title with BeautifulSoup.

### Step 1: Install the parser

```bash
pip install beautifulsoup4

```

### Step 2: Make an impersonated request

Save this as `first_request.py`. The only line that differs from a `requests` script is the `impersonate` argument.

```python
# first_request.py
import curl_cffi
from bs4 import BeautifulSoup

URL = "https://tls.browserleaks.com/json"

# impersonate="chrome" picks the newest Chrome preset you have installed
r = curl_cffi.get(URL, impersonate="chrome", timeout=20)
r.raise_for_status()   # raises curl_cffi HTTPError on 4xx/5xx

data = r.json()
print("Status:", r.status_code)
print("JA3N:", data.get("ja3n_hash"))
print("Akamai (HTTP/2):", data.get("akamai_hash"))

```

`raise_for_status()` behaves like the one in `requests`, and `r.json()`, `r.text`, `r.content`, and `r.headers` are all where you'd expect them.

### Step 3: Run it

```bash
python first_request.py

```

You should see a 200 and two hashes. Now change `impersonate="chrome"` to `impersonate="safari"` and run it again; the hashes change, because you're now presenting Safari's handshake. That's the whole mechanism in two runs.

### Step 4: Point it at HTML

Swap the URL for a real page and parse it. This example reads a public product listing and prints the `<title>`; replace the URL with your own target.

```python
import curl_cffi
from bs4 import BeautifulSoup

r = curl_cffi.get(
    "https://books.toscrape.com/",   # a public practice site
    impersonate="chrome",
    timeout=20,
)
r.raise_for_status()

soup = BeautifulSoup(r.text, "html.parser")
print(soup.title.get_text(strip=True))
for h3 in soup.select("article.product_pod h3 a")[:5]:
    print("-", h3["title"])

```

If a site that 403'd `requests` now returns 200, you were being TLS-fingerprinted. If it still 403s, the block is somewhere else (IP reputation, a JavaScript challenge, or missing cookies), and no amount of fingerprint tweaking will fix it. The [403 Forbidden guide](https://roundproxies.com/blog/403-forbidden-error/) walks through how to tell those apart.

## How to use curl\_cffi sessions and cookies

Most real scrapes make several requests against one site: a landing page that sets cookies, then a search, then detail pages. A `Session` keeps the cookies and reuses the TCP/TLS connection across all of them.

You can set `impersonate` once on the session instead of on every call:

```python
from curl_cffi import Session

with Session(impersonate="chrome", timeout=20) as s:
    s.get("https://httpbin.org/cookies/set/session_id/abc123")
    print(s.cookies)            # <Cookies[<Cookie session_id=abc123 ...>]>

    r = s.get("https://httpbin.org/cookies")
    print(r.json())             # {'cookies': {'session_id': 'abc123'}}

```

Two details worth knowing. `response.cookies` only holds cookies from that one response, and after a redirect chain it may be incomplete; read `session.cookies` instead. And if you want connection reuse without cookie persistence (some login flows want this), create the session with `discard_cookies=True`.

You can also seed a session with cookies you captured elsewhere, which is how people carry a solved `cf_clearance` from a browser into curl\_cffi. The [cf\_clearance guide](https://roundproxies.com/blog/cf-clearance/) covers that flow; the short version is that the cookie is tied to the fingerprint and IP that earned it, so present the same `impersonate` target and the same exit IP.

## How to use curl\_cffi with proxies

Fingerprints are half the fight. The other half is the IP. A perfect Chrome handshake from a datacenter range still gets rate-limited on aggressive sites, so at scale you'll route through a proxy pool you control.

curl\_cffi takes the same `proxies` dict as `requests`, and supports HTTP, HTTPS, and SOCKS5 schemes:

```python
from curl_cffi import Session

proxy = "http://user:pass@proxy.example.com:8080"

with Session(impersonate="chrome") as s:
    r = s.get(
        "https://httpbin.org/ip",
        proxies={"http": proxy, "https": proxy},
    )
    print(r.json())   # {'origin': '<proxy exit IP>'}

```

For SOCKS5, use `socks5h://` so DNS resolves through the proxy rather than leaking from your machine.

For rotation, keep a plain list and cycle through it. No framework needed:

```python
import itertools
from curl_cffi import Session

PROXIES = [
    "http://user:pass@203.0.113.10:8080",
    "http://user:pass@203.0.113.11:8080",
    "http://user:pass@203.0.113.12:8080",
]
pool = itertools.cycle(PROXIES)

with Session(impersonate="chrome", timeout=15) as s:
    for url in ["https://httpbin.org/ip"] * 3:
        p = next(pool)                       # round-robin
        r = s.get(url, proxies={"http": p, "https": p})
        print(r.json()["origin"])

```

Pin one proxy per session when the site uses cookies; a session whose IP jumps between cities every request looks nothing like a person. Rotate per request only for independent one-shot fetches.

The [proxies for web scraping guide](https://roundproxies.com/blog/proxies-web-scraping/) goes deeper on when to use each pattern. If you don't run your own pool, Roundproxies residential and ISP proxies plug into this exact `proxies=` dict with no adapter.

## Async scraping with AsyncSession

`AsyncSession` mirrors `Session` under asyncio and is where curl\_cffi pulls ahead of `requests`. This example fetches a batch of pages with a concurrency cap and per-request proxy rotation.

```python
import asyncio, itertools
from curl_cffi import AsyncSession

URLS = [f"https://books.toscrape.com/catalogue/page-{i}.html" for i in range(1, 11)]
PROXIES = itertools.cycle(["http://user:pass@203.0.113.10:8080"])  # add more
SEM = asyncio.Semaphore(5)   # never more than 5 in flight

async def fetch(s, url):
    p = next(PROXIES)
    async with SEM:
        r = await s.get(url, proxies={"http": p, "https": p})
        return url, r.status_code, len(r.text)

async def main():
    async with AsyncSession(impersonate="chrome", timeout=20) as s:
        results = await asyncio.gather(*(fetch(s, u) for u in URLS))
    for url, code, size in results:
        print(code, size, url)

asyncio.run(main())

```

Two things to notice. You must use a session in async mode; there's no `await curl_cffi.get()`. And the semaphore is doing real work: without it, `gather` fires all ten at once, which is fine for ten and a problem for ten thousand.

If you're on Windows and see event-loop errors, set `asyncio.WindowsSelectorEventLoopPolicy()` before `asyncio.run()`. It's a general asyncio quirk, not a curl\_cffi one.

## Retries, timeouts, and streaming

curl\_cffi has native retries, which most guides skip because they were written before the feature existed. Pass an integer for a simple count or a `RetryStrategy` for backoff:

```python
from curl_cffi import Session, RetryStrategy

strategy = RetryStrategy(
    count=3,
    delay=0.5,              # seconds before first retry
    jitter=0.2,             # random +/- to avoid thundering herds
    backoff="exponential",  # 0.5, 1.0, 2.0 ...
)

with Session(impersonate="chrome", retry=strategy, timeout=15) as s:
    r = s.get("https://httpbin.org/status/503")
    print(r.status_code)

```

Set `timeout=` on every session. curl's default is generous enough that a stalled proxy can hang a worker for a long time. Fifteen to thirty seconds is a sane starting point for HTML pages.

For large downloads, don't hold the body in memory. `stream=True` works for compatibility, but the docs warn that the response starts buffering immediately, so if you're slow to consume it you can hit an out-of-memory error. The native `content_callback` avoids that:

```python
import curl_cffi

def write_chunk(chunk: bytes, f=open("big.bin", "wb")):
    f.write(chunk)

curl_cffi.get(
    "https://httpbin.org/bytes/1048576",
    impersonate="chrome",
    content_callback=write_chunk,   # called per chunk as it arrives
)

```

One more API difference from `requests`: file uploads use `multipart=curl_cffi.CurlMime()` instead of `files=`. The `files=` keyword isn't supported and fails loudly. Posting JSON (`json=`) and forms (`data=`) work exactly as in `requests`; raw bytes should go through `content=`.

## The header trap: don't undo your own fingerprint

The mistake I see most in curl\_cffi code from people migrating off `requests` is this:

```python
# Don't do this
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120.0"}
r = curl_cffi.get(url, impersonate="chrome", headers=headers)

```

`impersonate="chrome"` already set a User-Agent, and it set one that matches the TLS profile: same Chrome major version, same platform as the preset. The override above now claims Chrome 120 on Windows over a handshake that belongs to Chrome 146 on macOS. That contradiction is exactly what a fingerprinting vendor scores.

Three rules keep you out of it:

1. Let curl\_cffi set the User-Agent and the `sec-ch-ua*` family. Only add headers the browser would send on top, like `Referer` or `Accept-Language` for a specific locale.
2. If you must control every header, pass `default_headers=False` and supply a complete, internally consistent set yourself. Half-and-half is the worst option.
3. Pin the preset. `impersonate="chrome"` moves forward as you upgrade, which is usually good, but a stale pin (`chrome110` in 2026) is itself a signal. Check `curl-cffi list` and pick something recent.

If you need a specific UA with a matching profile, the supported path is `get_fingerprint()`: fetch the preset, edit its headers, pass the object back to `impersonate=`. That keeps the TLS side and the header side from the same browser.

```python
import curl_cffi

fp = curl_cffi.get_fingerprint("chrome146")   # returns an editable profile
fp.headers["Accept-Language"] = "de-DE,de;q=0.9"
r = curl_cffi.get("https://httpbin.org/headers", impersonate=fp)
print(r.json()["headers"]["Accept-Language"])

```

Debugging this is easiest from the terminal: `curl-cffi get https://httpbin.org/headers --impersonate chrome` shows you the exact header set the preset sends, so you can see what you're overriding before you override it.

## Common curl\_cffi errors and how to fix them

### "ModuleNotFoundError: No module named 'curl\_cffi'"

**What it means:** Either the package isn't installed in the active environment, or you typed the import with a hyphen.

**How to fix it:** `pip install curl_cffi --upgrade` inside the venv you're running from, and make sure the import line reads `import curl_cffi`, underscore. Check `which python` matches the venv.

### "curl\_cffi.requests.exceptions.RequestsError: Failed to perform, curl: (35)"

**What it means:** Code 35 is a TLS handshake failure. Common causes: an HTTPS proxy that doesn't support the negotiated protocol, a corporate MITM certificate, or a proxy URL with the wrong scheme.

**How to fix it:** Try the request without the proxy first. If it works, the proxy is the problem; switch `https://` to `http://` in the proxy URL (most forward proxies expect that) or use `socks5h://`. For a MITM cert, set `verify="/path/to/ca.pem"`, or `verify=False` only while debugging.

### "curl: (28) Operation timed out" / "RequestsError: ... 28"

**What it means:** The server or proxy stopped responding before `timeout` elapsed. On heavily protected sites this is often a soft block: the WAF holds the connection open rather than sending a 403.

**How to fix it:** Lower the timeout, wrap the call in a `RetryStrategy`, and rotate the proxy on retry. If a specific IP times out repeatedly while others succeed, retire it from the pool.

### "TypeError: ... got an unexpected keyword argument 'files'"

**What it means:** You're using the `requests` upload API. curl\_cffi doesn't implement `files=`.

**How to fix it:** Build a `CurlMime` object and pass it as `multipart=`:

```python
import curl_cffi

mp = curl_cffi.CurlMime()
mp.addpart(name="file", filename="report.pdf",
           content_type="application/pdf", local_path="./report.pdf")
r = curl_cffi.post("https://httpbin.org/post", multipart=mp)

```

### "UnrewindableBodyError"

**What it means:** A retry or redirect needed to resend a request body that came from a one-shot iterator (a generator passed via `content=`).

**How to fix it:** Pass a seekable file object or bytes instead of a generator when retries or redirects are possible; curl\_cffi rewinds seekable bodies automatically.

### PyInstaller build runs but crashes on import

**What it means:** PyInstaller didn't collect the bundled libcurl-impersonate shared library or the cffi backend.

**How to fix it:**

```bash
pyinstaller -F app.py --hidden-import=_cffi_backend --collect-all curl_cffi

```

### General debugging tips

- Reproduce with the CLI before touching Python: `curl-cffi get URL --impersonate chrome -v` shows the handshake and headers.
- Compare hashes at `tls.browserleaks.com/json` between curl\_cffi and a real browser. If they differ, your preset is stale; run `curl-cffi update`.
- When a site blocks curl\_cffi but not a browser, log the response body. Cloudflare, Akamai, and DataDome each leave recognizable markers, and knowing which one you're facing changes the fix entirely.

## curl\_cffi best practices for scrapers

Once requests are going through, learning how to use curl\_cffi well is mostly about keeping your traffic consistent. The `impersonate` option gives you a browser-grade TLS and HTTP/2 fingerprint. Connection handling, IP choice, preset freshness, and request pacing are still up to you, and a mismatch in any one of them can undo a perfect Client Hello. These habits cover the mistakes that most often get a working scraper blocked.

### 1\. Always use a session

Connection reuse is faster and closer to how a browser behaves. A browser opens a connection to a host once and sends many requests over it. A scraper that performs a fresh handshake for every URL looks different on the wire and pays the TLS setup cost on each request.

Use one-shot `curl_cffi.get()` for quick checks, such as testing whether a preset gets past a site. For scrapers, create a session. It also carries cookies from one response to the next, so challenge cookies issued on the first request are sent back on the second without extra work from you.

### 2\. Keep the fingerprint and the IP coherent

Pick one `impersonate` target and one proxy, and keep both for the life of a logical session. Cookies like `cf_clearance` are bound to both. The clearance was issued to a specific fingerprint arriving from a specific IP. If you present it from another address or with another browser signature, expect a new challenge or a block.

In practice:

- When you rotate proxies, start a new session with an empty cookie jar. Don't swap the proxy under an existing one.
- Keep custom headers in line with the preset. A Chrome TLS fingerprint paired with a Safari User-Agent is an easy inconsistency for an anti-bot system to flag.

### 3\. Pin presets deliberately and refresh them

Use `curl-cffi list` to see which presets you have, pick a recent one, and run `curl-cffi update` on a schedule. Browser Client Hellos change every few releases. A preset that matched Chrome a year ago may now describe a version that few real users still run, and that makes it stand out.

There are two ways to choose a target, and the choice should be deliberate:

- **A versioned preset** (a specific Chrome release, for example) gives a fixed fingerprint. You control when it changes, which helps when you're debugging a block.
- **An unversioned target** such as chrome, safari or safari\_ios follows the newest fingerprint the library ships. Your scraper stays current as you upgrade curl\_cffi, but the fingerprint can change under you after an update.

### 4\. Set timeouts and retries on the session, not per call

Configure `timeout=` and `retry=RetryStrategy(...)` once when you construct the session. You write less code, and every request inherits the same limits, including requests added months later by someone who didn't know the rule. A missing timeout on one call is enough for a stalled proxy to hang a worker. Override the session values per call only for known exceptions, such as a large file download that needs longer.

### 5\. Respect the target

Read `robots.txt`, keep your request rate somewhere a human could plausibly produce, and back off on 429s. Pacing matters for detection as well as for courtesy. A flawless fingerprint that fires requests faster than any person could click still gets rate-limited, and aggressive retries after a 429 tend to escalate the block. The [429 error guide](https://roundproxies.com/blog/http-error-429/) covers rate-limit handling patterns that work with curl\_cffi's retry strategy.

## Where curl\_cffi stops working

curl\_cffi solves TLS and HTTP/2 fingerprinting. It does not run JavaScript, so it can't solve a Cloudflare Turnstile challenge, execute a DataDome sensor script, or render a page that builds its DOM client-side.

When the block is a JS challenge, your self-built options are a real browser (Playwright with a stealth patch, or a fingerprint-controlled browser like Camoufox) to earn the cookies, then hand those cookies to a curl\_cffi session for the volume work. That hybrid is the standard pattern: browser for the gate, curl\_cffi for the thousand pages behind it. The [Cloudflare bypass guide](https://roundproxies.com/blog/bypass-cloudflare/) shows the handoff in code.

It also doesn't help when the block is purely IP-based. A flawless fingerprint from a burned datacenter range is still a burned datacenter range.

## FAQ

### Is curl\_cffi free?

Yes. The library is MIT licensed and the Chrome, Safari, and Firefox fingerprint presets are free, including updates via `curl-cffi update`. The maintainers sell an extended fingerprint database and commercial support at impersonate.pro; you don't need it for anything in this guide.

### Does curl\_cffi support Firefox?

Yes, since the `firefox133` preset, with `firefox147` the newest at the time of writing. Guides that say curl\_cffi can't do Firefox predate that addition.

### Can curl\_cffi bypass Cloudflare?

It gets past Cloudflare's TLS-fingerprint check, which is what blocks plain `requests` on many sites. It cannot solve a managed JavaScript challenge or Turnstile on its own. For those, pair it with a browser that earns the clearance cookie, then reuse the cookie in a curl\_cffi session with the same fingerprint and IP.

### Is curl\_cffi faster than requests?

The maintainers' benchmarks show it ahead of `requests` and `httpx`, and roughly level with `aiohttp` and `pycurl`, largely because the heavy lifting happens in libcurl rather than Python. Your numbers will depend on the target, so run the benchmark script in the repo against your own endpoints.

### What Python version does curl\_cffi need?

Python 3.10 or newer since v0.14\. If you're on 3.8 or 3.9, upgrade Python before installing; there's no supported workaround.

### How do I migrate from requests to curl\_cffi?

Change `import requests` to `import curl_cffi`, add `impersonate="chrome"` to your calls or your `Session()`, and replace any `files=` uploads with `multipart=CurlMime()`. Most scripts need nothing else. The [python-requests proxy guide](https://roundproxies.com/blog/python-requests-proxy/) has proxy patterns that carry over unchanged.

## Wrapping up

You now have a curl\_cffi setup that impersonates a current browser, keeps cookies and connections in a session, rotates proxies, retries with backoff, and runs concurrently under asyncio. The one thing to remember: the fingerprint, the headers, and the IP all have to tell the same story, and curl\_cffi only controls the first two.

Next, point the async example at a real target and watch the block rate. If it's still high, the problem has moved to IP reputation or a JavaScript gate, and the guides linked above cover both.