Put the credentials in the proxy URL and pass it as proxy:
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
proxyorproxies, not both.proxies={"http": PROXY, "https": PROXY}worked the same on a request and on aSession. The key is the scheme of the URL you request: with only an"https"key, anhttp://request went direct. Passing both arguments raisedTypeError: Cannot specify both 'proxy' and 'proxies'. - Percent-encode the password in the URL.
p@ss:w/rdworked asp%40ss%3Aw%2Frd. Unencoded, the request raisedProxyErrorwithcurl: (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, forhttps://URLs too. The scheme describes the connection to the proxy. Anhttps://proxy URL pointed at the lab's plain HTTP proxy raisedSSLErrorwithcurl: (35) TLS connect errorandWRONG_VERSION_NUMBER. SSL WRONG_VERSION_NUMBER behind a proxy covers that message.
CONNECT tunnel failed, response 407: ConnectionError, not ProxyError
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)isFalse.ProxyErrorandConnectionErrorare siblings underRequestException, soexcept ProxyErrordoes not catch it.error.codeis 7.error.responseis aResponsewhosestatus_codeis 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 wascurl: (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_cffi | libcurl | Rejected CONNECT | SOCKS failure |
|---|---|---|---|
| 0.16.0 to 0.16.3 | 8.21.0 | ConnectionError, curl code 7 | ProxyError, curl code 97 |
| 0.7.4 to 0.15.0 | 8.7.0 to 8.15.0 | ProxyError, curl code 56 | ProxyError, 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:
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 responseIn 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, uppercaseHTTP_PROXYwas ignored and the request went direct. Lowercasehttp_proxysent it through the proxy. - For an
https://URL,HTTPS_PROXYandhttps_proxywere both used.http_proxyalone left anhttps://request direct. ALL_PROXYandall_proxyapplied to both schemes.NO_PROXY=127.0.0.1and lowercaseno_proxysent 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_PROXYoverrides theproxyargument. WithNO_PROXY=127.0.0.1set,requests.get(url, proxy=PROXY)went direct for both schemes. Addingcurl_options={CurlOpt.NOPROXY: ""}sent it through the proxy again. Againsthttp_proxythe argument won.trust_env=Falsedid not turn the variables off. ASession(trust_env=False)still usedhttp_proxyandHTTPS_PROXY. So didproxy="".
Two settings did send the request direct with the variables set:
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:1080the server loggedCONNECT atyp=IPv4 dest=127.0.0.2:8080. curl_cffi resolved the name itself. - With
socks5h://127.0.0.1:1080it loggedCONNECT 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:
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 raisedProxyErrorwithcurl: (97) Received invalid version in initial SOCKS5 response.http://pointed at the SOCKS port raisedProxyErrorwithcurl: (56) Proxy CONNECT abortedfor anhttps://URL.- With nothing listening on the SOCKS port, the error was the
ConnectionErrorwith code 7 again.
AsyncSession: the same setup, the same errors
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.
- curl_cffi documentation: Proxies
- curl_cffi API reference: Session (proxy, proxies, proxy_auth, trust_env, retry)
- curl_cffi API reference: AsyncSession
- curl_cffi API reference: Exceptions and warnings (ProxyError, ConnectionError)
- curl_cffi documentation: Using CURLOPT_* in requests API (curl_options)
- curl_cffi issue #326: CONNECT was sent for http:// URLs, changed in 0.7.0b7
- libcurl: CURLOPT_PROXY (schemes, the empty string, socks5h)
- libcurl: CURLOPT_NOPROXY and the no_proxy variable
- libcurl environment variables: http_proxy is only used lowercase
- Everything curl: proxy environment variables and the CGI reason
- libcurl error codes: CURLE_COULDNT_CONNECT (7), CURLE_RECV_ERROR (56), CURLE_PROXY (97)