Integration8 min read

curl_cffi proxies: auth, the 407 that ProxyError misses, and SOCKS

curl_cffi proxy setup on 0.16.3: credentials with impersonate, why a CONNECT 407 raises ConnectionError and not ProxyError, HTTP_PROXY rules, socks5h.

On this page

Put the credentials in the proxy URL and pass it as proxy:

python
from curl_cffi import requests

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

with requests.Session(impersonate="chrome", proxy=PROXY) as session:
    response = session.get("https://example.com/")
    print(response.status_code)

To catch proxy failures, catch ProxyError and ConnectionError, and check status_code for 407. In curl_cffi 0.16 a proxy that rejects CONNECT raises ConnectionError, not ProxyError.

In our lab this authenticated https:// and http:// requests against a local proxy that requires Basic authentication. Everything below was recorded on 10 October 2026 with curl_cffi 0.16.3 (libcurl 8.21.0) on Python 3.14.4. impersonate selects the browser profile curl_cffi copies for its TLS and HTTP/2 settings. Every result below was the same with and without it.

Three details before the errors:

  • Use proxy or proxies, not both. proxies={"http": PROXY, "https": PROXY} worked the same on a request and on a Session. The key is the scheme of the URL you request: with only an "https" key, an http:// request went direct. Passing both arguments raised TypeError: Cannot specify both 'proxy' and 'proxies'.
  • Percent-encode the password in the URL. p@ss:w/rd worked as p%40ss%3Aw%2Frd. Unencoded, the request raised ProxyError with curl: (5) Unsupported proxy syntax. proxy_auth=("user", "p@ss:w/rd") next to a proxy URL without credentials also worked and needs no encoding.
  • Write http:// for an HTTP proxy, for https:// URLs too. The scheme describes the connection to the proxy. An https:// proxy URL pointed at the lab's plain HTTP proxy raised SSLError with curl: (35) TLS connect error and WRONG_VERSION_NUMBER. SSL WRONG_VERSION_NUMBER behind a proxy covers that message.

CONNECT tunnel failed, response 407: ConnectionError, not ProxyError

code
curl_cffi.requests.exceptions.ConnectionError: Failed to perform, curl: (7) CONNECT tunnel failed, response 407. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.

For an https:// URL curl_cffi first sends CONNECT to the proxy. The lab proxy answered 407 and the request raised this error. The message was identical with no credentials and with a wrong password.

  • isinstance(error, ProxyError) is False. ProxyError and ConnectionError are siblings under RequestException, so except ProxyError does not catch it.
  • error.code is 7.
  • error.response is a Response whose status_code is 0, not 407. The status is only in the message.
  • Code 7 is not specific to a 407. When the lab proxy could not reach the target it answered 502, and the message was curl: (7) CONNECT tunnel failed, response 502. With nothing listening on the proxy port it was curl: (7) Failed to connect to 127.0.0.1:8443 over proxy 127.0.0.1 after 0 ms: Could not connect to server.

The class follows the curl error code, and that code changed between releases. We ran the same request on twelve of them:

curl_cffilibcurlRejected CONNECTSOCKS failure
0.16.0 to 0.16.38.21.0ConnectionError, curl code 7ProxyError, curl code 97
0.7.4 to 0.15.08.7.0 to 8.15.0ProxyError, curl code 56ProxyError, curl code 97

Up to 0.15.0 the message was curl: (56) CONNECT tunnel failed, response 407. The curl command line moved from exit code 56 to 7 in 8.18.0. Fix proxy error 407 has that table and the order of checks for the credentials.

This block handles a rejected CONNECT on both sides of the change, a SOCKS failure and a 407 response:

python
import re

from curl_cffi.requests.exceptions import ConnectionError, ProxyError

CONNECT_STATUS = re.compile(r"CONNECT tunnel failed, response (\d{3})")


class ProxyFailure(Exception):
    pass


def fetch(session, url):
    try:
        response = session.get(url)
    except ProxyError as error:
        raise ProxyFailure(f"proxy error, curl {error.code}") from error
    except ConnectionError as error:
        rejected = CONNECT_STATUS.search(str(error))
        if error.code == 7 and rejected:
            raise ProxyFailure(f"proxy answered CONNECT with {rejected[1]}") from error
        raise
    if response.status_code == 407:
        raise ProxyFailure("proxy answered 407")
    return response

In the lab it reported proxy answered CONNECT with 407, proxy answered CONNECT with 502, proxy answered 407 for an http:// URL and proxy error, curl 97 for a SOCKS failure. It re-raised the ConnectionError for a closed proxy port.

407 on http:// URLs: a response, not an exception

For an http:// URL there is no tunnel. curl_cffi sends the full URL to the proxy in a plain GET, as it has since 0.7.0b7 (issue #326), and the proxy's 407 comes back as the response.

No exception was raised. response.status_code was 407, response.ok was False and the headers carried Proxy-Authenticate: Basic realm="lab". Code that only catches exceptions treats this as a successful request with an empty body.

response.raise_for_status() raised curl_cffi.requests.exceptions.HTTPError: HTTP Error 407: Proxy Authentication Required. On that exception error.response.status_code is 407.

Environment variables: http_proxy is read, HTTP_PROXY is not

With no proxy in the code, libcurl picks one from the environment:

  • For an http:// URL, uppercase HTTP_PROXY was ignored and the request went direct. Lowercase http_proxy sent it through the proxy.
  • For an https:// URL, HTTPS_PROXY and https_proxy were both used. http_proxy alone left an https:// request direct.
  • ALL_PROXY and all_proxy applied to both schemes.
  • NO_PROXY=127.0.0.1 and lowercase no_proxy sent the request direct.

libcurl documents the exception: the variables are also checked in uppercase, "except the http_proxy one which is only used lowercase" (libcurl environment variables). The reason is CGI: a server turns a Proxy: request header into an HTTP_PROXY variable for the script (Everything curl).

Scrapy reads uppercase HTTP_PROXY (Scrapy proxies in 2.19), and aiohttp reads no variable unless you pass trust_env=True (aiohttp proxies in 3.14). Proxy environment variables compares more clients.

Two results are easy to miss:

  • NO_PROXY overrides the proxy argument. With NO_PROXY=127.0.0.1 set, requests.get(url, proxy=PROXY) went direct for both schemes. Adding curl_options={CurlOpt.NOPROXY: ""} sent it through the proxy again. Against http_proxy the argument won.
  • trust_env=False did not turn the variables off. A Session(trust_env=False) still used http_proxy and HTTPS_PROXY. So did proxy="".

Two settings did send the request direct with the variables set:

python
from curl_cffi import CurlOpt, requests

requests.get("https://example.com/", proxies={"all": ""})
requests.get("https://example.com/", curl_options={CurlOpt.NOPROXY: "*"})

Both also work as Session arguments. An empty proxy string "explicitly disables the use of a proxy, even if there is an environment variable set for it" (CURLOPT_PROXY).

SOCKS: socks5:// resolves locally, socks5h:// sends the hostname

The lab requested http://dnsprobe.example:8080/, a name that exists only in the hosts file as 127.0.0.2, through a SOCKS5 server that logs each destination:

  • With socks5://127.0.0.1:1080 the server logged CONNECT atyp=IPv4 dest=127.0.0.2:8080. curl_cffi resolved the name itself.
  • With socks5h://127.0.0.1:1080 it logged CONNECT atyp=DOMAIN dest=dnsprobe.example:8080. The proxy received the hostname.

An https:// URL gave the same two results. This is curl's own convention (Use a proxy with curl). socks5 vs socks5h lists clients that do not follow it.

A failed SOCKS handshake is the case that does raise ProxyError:

code
curl_cffi.requests.exceptions.ProxyError: Failed to perform, curl: (97) cannot complete SOCKS5 connection to 127.0.0.1. (1). See https://curl.se/libcurl/c/libcurl-errors.html first for more details.

error.code is 97. The last number in parentheses is the reply code the SOCKS server sent: the lab server answered 1, general failure. When the target port was closed and the server answered 5, the message ended in (5).

A scheme that does not match the port fails in other ways:

  • socks5:// pointed at the HTTP proxy port raised ProxyError with curl: (97) Received invalid version in initial SOCKS5 response.
  • http:// pointed at the SOCKS port raised ProxyError with curl: (56) Proxy CONNECT aborted for an https:// URL.
  • With nothing listening on the SOCKS port, the error was the ConnectionError with code 7 again.

AsyncSession: the same setup, the same errors

python
import asyncio

from curl_cffi import AsyncSession

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


async def main():
    async with AsyncSession(impersonate="chrome", proxy=PROXY) as session:
        response = await session.get("https://example.com/")
        print(response.status_code)


asyncio.run(main())

Every async case matched its sync twin. A rejected CONNECT raised the same ConnectionError with code 7. An http:// URL returned the 407 as a response. A SOCKS failure raised ProxyError with code 97. socks5:// and socks5h:// sent the same address types, and HTTP_PROXY was ignored while http_proxy was used.

Retries: retry repeats POST requests

curl_cffi does not retry by default. The lab sent a POST through the proxy to a target that reads the request and closes the connection without answering. The call raised ConnectionError with curl: (52) Empty reply from server, and the target had received one POST.

With requests.Session(proxy=PROXY, retry=1) the same call returned 201, and the target had received the body twice. retry repeated the POST the way it repeats a GET.

retry=2 on a rejected CONNECT sent three CONNECT requests and raised the same error, with Session and with AsyncSession. A retry does not fix wrong credentials.

Keep retry at 0 for requests that are not safe to repeat, or send an idempotency key. Proxy retries: one lost response, two created jobs shows what a key changes. For diagnostic help inside a coding agent, Proxy Toolkit MCP accepts sanitized error descriptions. Keep credentials out of its arguments.

How this was tested

On Ubuntu 26.04.1 with Python 3.14.4 and curl_cffi 0.16.3 (libcurl 8.21.0-IMPERSONATE with BoringSSL), everything on loopback: python -m http.server as the HTTP and HTTPS targets, three proxies that require Basic authentication and log every request, a SOCKS5 server that logs each destination and one that answers every request with reply code 1, and a target that loses the response to the first POST. dnsprobe.example existed only in a private copy of the hosts file. Each of the 86 cases ran in a new interpreter with a clean environment. The version comparison installed twelve releases from 0.7.4 to 0.16.3. No proxy account was used.

The lab archive contains the runner, the cases, the proxy, the SOCKS5 server, the README, the recorded results and the version comparison.

Not tested: macOS and Windows, Digest and NTLM proxy authentication, authenticated SOCKS proxies, proxies reached over TLS, HTTP/3 and WebSockets.

ipvolt is a proxy service for developers that isn't open yet; join the early-access list for one email when it opens.

Sources & further reading

Technical references used for this guide. Check the documentation for your installed version and your provider’s supported configuration.