Skip to main content

curl-solve

Challenge-aware HTTP sessions on curl_cffi.

curl-solve wraps curl_cffi with challenge detection, a cookie-aware retry loop, and pluggable solvers.

It is a lightweight middle tier — not a Cloudflare bypass:

fast HTTP (curl_cffi)  →  curl-solve  →  real browser

Comparison

Feature curl-solve curl_cffi cloudscraper requests Playwright
TLS / JA3 impersonation ✅ ✅ ❌ ❌ ⚠️ real Chrome
HTTP/2 ✅ ✅ ❌ ❌ ✅
HTTP/3 (QUIC) ✅ ✅ ❌ ❌ ✅
Impersonate headers (default_headers) ✅ ✅ ⚠️ UA only ❌ n/a
Cookie session ✅ ✅ ✅ ✅ ✅
Challenge detection (kind) ✅ ❌ ❌ ❌ ❌
Pluggable solvers ✅ ❌ ❌ ❌ n/a
DNS overrides (CURLOPT_RESOLVE) ✅ ✅ ❌ ❌ ⚠️
Executes page JavaScript ❌ ❌ ⚠️ legacy IUAM ❌ ✅
Cloudflare managed / Turnstile ❌ browser ❌ ❌ ❌ ✅
Legacy CF IUAM (jschl) ⚠️ plugin hook ❌ ✅ ❌ ✅
Scrapy-free standalone ✅ ✅ ✅ ✅ ✅
Memory footprint 🟢 Low 🟢 Low 🟢 Low 🟢 Low 🔴 High

curl-solve sits on curl_cffi: same impersonated TLS, plus classification and a solver retry loop. cloudscraper can still clear old IUAM JS puzzles; it does not impersonate TLS and does not solve modern managed / Turnstile pages. When both return HTTP 200 product HTML, Cloudflare never issued that interstitial — impersonation (or a clean IP) passed, nothing “solved” the widget.

Install

pip install curl-solve

Requires Python 3.11+ and curl_cffi>=0.16.1.

Quick start

from curl_solve import BROWSER_ONLY_KINDS, ChallengeSession

with ChallengeSession(impersonate="chrome") as session:
    resp = session.get("https://example.com")
    print(resp.status_code, resp.challenge.kind)
    if resp.challenge.kind in BROWSER_ONLY_KINDS:
        print("Escalate to a real browser.")

ChallengeSession keeps one impersonated TLS session. After each request it classifies the response. If a registered solver can handle the challenge, cookies are applied and the original request is retried.

ChallengeSession

from curl_solve import ChallengeSession

session = ChallengeSession(
    impersonate="chrome",
    proxy=None,
    timeout=30,
    http2=True,
    http3=False,
    dns_overrides=None,
    solvers=None,
    max_solves=1,
    raise_on_unsolved=False,
    default_headers=True,
    persist_cookies=True,
    # extra kwargs go to curl_cffi.requests.Session
)
Argument Default Description
impersonate "chrome" curl_cffi browser target (TLS + JA3 + HTTP/2 fingerprint). Any BrowserTypeLiteral such as "chrome", "chrome150", "firefox", "safari".
proxy None Session proxy URL (http://, https://, socks4://, socks5://). Applied to every request unless overridden.
timeout 30 Default request timeout in seconds.
http2 True Use HTTP/2 (CurlHttpVersion.V2_0) when http3 is false.
http3 False Use HTTP/3 / QUIC (CurlHttpVersion.V3). Needs a UDP-capable path.
dns_overrides None Host→IP map pinned via libcurl CURLOPT_RESOLVE (TLS SNI / Host stay the hostname). Example: {"example.com": "203.0.113.10"}.
solvers builtin_solvers() (empty) Plugin list. None uses builtins; [] disables plugins.
max_solves 1 Max solver attempts per request before giving up.
raise_on_unsolved False If True, raise ChallengeUnsolved when a challenge remains. If False, return the challenged response.
default_headers True Let curl_cffi inject impersonate headers (User-Agent, sec-ch-ua, Accept, …).
persist_cookies True True: send cookies on later requests. False: start each request with an empty jar; session.cookies then holds only this response's Set-Cookie. Per-call override: persist_cookies=.
**session_kwargs — Forwarded to curl_cffi.requests.Session (for example extra curl_options).

Session properties

Property Description
session.http Underlying curl_cffi.requests.Session. Extra fetches here skip the detect/solve loop (use this from solvers).
session.cookies Shared cookie jar (readable and writable).
session.headers Default request headers on the inner session (readable and writable).
session.impersonate Browser target used for this session.
session.proxy Default proxy URL, or None.
session.timeout Default timeout in seconds.
session.http_version Resolved CurlHttpVersion (v1.1 / v2 / v3).
session.max_solves Solver attempt limit.
session.raise_on_unsolved Whether unsolved challenges raise.
session.dns_overrides Normalized host→IP map.
session.solvers Active ChallengeSolver list.
session.persist_cookies Whether this session persists cookies on later requests.

Lifecycle

Method Description
session.close() Close the inner curl_cffi session and free connections.
with ChallengeSession(...) as session: Context manager; calls close() on exit.
session.inspect(response) Run detect() on any response-like object; returns a Detection. Does not send a request.

Request methods

All of these go through session.request(...), so they share the same detect → solver → retry loop, cookie jar, impersonate fingerprint, and return type (SolvedResponse).

Method Description
session.get(url, **kwargs) HTTP GET. Fetch a document or API resource.
session.post(url, **kwargs) HTTP POST. Submit a body (data=, json=, or files=).
session.put(url, **kwargs) HTTP PUT. Replace a resource.
session.patch(url, **kwargs) HTTP PATCH. Partial update.
session.delete(url, **kwargs) HTTP DELETE. Remove a resource.
session.head(url, **kwargs) HTTP HEAD. Headers only; body is empty. Detection still runs on status/headers.
session.options(url, **kwargs) HTTP OPTIONS. CORS / allowed-method probe.
session.request(method, url, **kwargs) Generic verb ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", or any string curl_cffi accepts).
resp = session.get(url)
resp = session.post(url, json={"q": "sku"})
resp = session.put(url, data=b"raw")
resp = session.patch(url, json={"stock": 1})
resp = session.delete(url)
resp = session.head(url)
resp = session.options(url)
resp = session.request("GET", url, solve=False)

Shared request arguments

These work on every method above (get / post / … / request).

Argument Description
url Target URL (required).
solve Default True. Run detect + solver + retry. False is a raw impersonated fetch (no solve loop).
proxy Per-request proxy; overrides the session default for this call.
timeout Per-request timeout in seconds.
http_version Per-request HTTP version. Accepts 1 / 2 / 3, "v1.1" / "v2" / "v3" / "http2" / "http3", or a CurlHttpVersion value.
persist_cookies Override session cookie persistence for this call only. False does not send the existing jar; this response's cookies are not kept.

Passed through to curl_cffi

Any other keyword is forwarded to curl_cffi.requests.Session.request:

Argument Description
params Query string dict or list of pairs.
data Form fields or raw request body.
json JSON body; sets Content-Type: application/json.
headers Extra headers for this call (merged with impersonate defaults).
cookies Extra cookies for this call (merged into the jar for the request).
files Multipart file upload.
auth HTTP basic/digest auth if curl_cffi supports it on this call.
allow_redirects Follow redirects (curl_cffi default applies if omitted).
verify TLS certificate verification.
max_redirects Redirect cap.
**kwargs Any other curl_cffi request option.

Response: SolvedResponse

Every request method returns a SolvedResponse. It wraps the curl_cffi response and adds challenge metadata.

print(resp.status_code)
print(resp.challenge.kind)
print(resp.text[:200])

Challenge

resp.challenge is a Detection with a single field:

Attribute Type Description
resp.challenge.kind ChallengeKind Classified page type. none means a normal page.
resp.raw curl_cffi Underlying response.
resp.challenge Detection Classification (always present).

Escalate with resp.challenge.kind in BROWSER_ONLY_KINDS. A plugin may attempt resp.challenge.kind in SOLVABLE_KINDS (currently only cloudflare_iuam).

Body / HTTP fields (delegated)

Unknown attributes are forwarded to resp.raw (the curl_cffi response):

Attribute Description
resp.status_code HTTP status code.
resp.url Final URL after redirects.
resp.text Body decoded as text.
resp.content Body as bytes.
resp.headers Response headers.
resp.cookies Cookies set by this response.
resp.encoding Declared or guessed encoding.
resp.ok True if status is under 400 (curl_cffi).
resp.elapsed Transfer time, if curl_cffi provides it.
resp.json() Parse body as JSON (curl_cffi method).

ChallengeKind

Kind Value Typical markers Path
NONE none Real content done
CLOUDFLARE_IUAM cloudflare_iuam jschl-answer, cf-browser-verification solvable plugin (none shipped yet)
CLOUDFLARE_MANAGED cloudflare_managed just a moment, __cf_chl, challenges.cloudflare.com, cf-mitigated browser
CLOUDFLARE_TURNSTILE cloudflare_turnstile cf-turnstile, turnstile widget browser
AKAMAI akamai sec-if-cpt-container, ak-challenge browser
DATADOME datadome short-body datadome / x-datadome header browser
PERIMETERX perimeterx px-captcha browser
GENERIC_JS generic_js short-body location.reload(true) browser
BLOCKED blocked HTTP 403 / 429 / 503, or short-body block keywords caller decides

Large product pages that only mention a vendor name (for example datadome in a long 200 body) are not treated as challenges.

How to read a result:

kind Meaning
none Clean page.
cloudflare_iuam Plugin may try (SOLVABLE_KINDS). None ships yet.
cloudflare_managed / cloudflare_turnstile / akamai / datadome / perimeterx / generic_js Escalate to browser (BROWSER_ONLY_KINDS).
blocked HTTP 403 / 429 / 503 or a short block page.

Detection without a session

from curl_solve import detect, ChallengeKind, is_js_challenge, is_blocked, is_session_ban

result = detect(status=403, body="<html>just a moment</html>")
assert result.kind is ChallengeKind.CLOUDFLARE_MANAGED
Function Description
detect(response=None, *, status=None, headers=None, body=None) Classify a response object or raw status / headers / body. Accepts Scrapy-style (status, body) or requests-style (status_code, text / content) objects. Returns Detection.
is_js_challenge(body) True if the HTML looks like a JS challenge page (not a plain block keyword).
is_blocked(response=None, *, status=None, body=None) True for HTTP 403 / 429 / 503 or anti-bot keywords (any body size).
is_session_ban(response=None, *, status=None, body=None) True when the HTTP session should be recycled: block status always; challenge/keyword heuristics only on short bodies.

Pluggable solvers

from curl_solve import ChallengeSession, ChallengeSolver, SOLVABLE_KINDS, SolveOutcome


class MySolver(ChallengeSolver):
    name = "my-solver"

    def can_handle(self, response, detection):
        return detection.kind in SOLVABLE_KINDS

    def solve(self, client, response, detection):
        # client.http.get(...) — extra fetches skip the solve loop
        return SolveOutcome.ok(cookies={"cf_clearance": "..."})
        # or: return SolveOutcome.failed("could not parse challenge")


session = ChallengeSession(solvers=[MySolver()])
Piece Description
ChallengeSolver.can_handle(response, detection) Return True to take this challenge.
ChallengeSolver.solve(client, response, detection) Attempt a solve; return SolveOutcome.
SolveOutcome.ok(cookies=..., retry=True, reason="") Success. Cookies are merged into the session; original request is retried when retry is true.
SolveOutcome.failed(reason="", retry=False) Failure. No retry unless you set retry=True.
builtin_solvers() Default plugin list. Phase 1 returns [].

Unsolved challenges are returned (or raised when raise_on_unsolved=True) so a caller — later, scrapy-stealth driver="auto" — can escalate: turbo → curl-solve → browser.


Exceptions

Exception When
CurlSolveError Base class.
ChallengeUnsolved Challenge left unsolved and raise_on_unsolved=True. Has .detection and .response.
CurlSolveTimeoutError curl_cffi timed out. Also a TimeoutError.
CurlSolveConnectionError DNS, proxy, or connection failure. Also a ConnectionError.
DependencyError curl_cffi failed to import (often a missing VC++ runtime on Windows).

License

MIT

Metadata

Release files for curl-solve 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for curl-solve 0.1.0
File Size Uploaded
curl_solve-0.1.0.tar.gz 29.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for curl-solve 0.1.0
File Interpreter ABI Platform
curl_solve-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 51.7 kB

Release files / curl_solve-0.1.0.tar.gz

Download URL curl_solve-0.1.0.tar.gz
Size 29.2 kB
Tags Source
SHA-256 checksum
How to use checksums
b1ad465f57935dd4963efc40fe7047834fb4e2062259974820018cbe4fdb76cf
BLAKE2b-256 checksum
How to use checksums
7afc60e991dc087a9cb37f8e1ab413a6c6e85b32d442d2d96ffba07100280669
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release files / curl_solve-0.1.0-py3-none-any.whl

Download URL curl_solve-0.1.0-py3-none-any.whl
Size 22.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f5d38bf0a7073d8171c86236995db34aaf560ce19b34dde6a6de0dc7c05d72a3
BLAKE2b-256 checksum
How to use checksums
66a3c62f75fc8ba72bfab3c1efac6daf8a506363d1a05f499560cf25b9d840bd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page