SSL WRONG_VERSION_NUMBER With a Proxy: The Fix
Getting SSL WRONG_VERSION_NUMBER through a proxy in Python? It's almost always https:// in the proxy URL. The one-character fix, plus every SSLEOFError cause.
An ssl wrong_version_number proxy error in Python almost never means your TLS library is broken or the target site changed its certificate. Usually your proxy URL starts with https:// while the proxy itself speaks plain HTTP, so your client opens a TLS handshake with something that answers in text. Delete one letter and the error goes away. This guide shows how to confirm that's your case in under a minute, where the stray https:// usually hides, when a real HTTPS proxy is the right call, and what the related SSLEOFError / UNEXPECTED_EOF_WHILE_READING failures mean when the scheme is already correct.
Every error string below was pasted from runs on 2026-10-30 using Python 3.14.2 with OpenSSL 3.0.18, requests 2.32.5 on urllib3 2.6.3, and a second virtualenv on urllib3 1.26.20. The proxies were SparkProxy's live gateway plus a few small local servers built to misbehave on purpose.
What the traceback says, verbatim
The common version, from requests 2.32.5 on urllib3 2.6.3, with https://gateway.sparkproxy.io:11000 as the proxy:
requests.exceptions.ProxyError: HTTPSConnectionPool(host='www.sparkproxy.io', port=443): Max retries exceeded with url: /robots.txt (Caused by ProxyError('Unable to connect to proxy. Your proxy appears to only use HTTP and not HTTPS, try changing your proxy URL to be HTTP. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#https-proxy-error-http-proxy', SSLError(SSLError(1, '[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1081)'))))
The same request under urllib3 1.26.20 drops the "Unable to connect to proxy" prefix:
ProxyError('Your proxy appears to only use HTTP and not HTTPS, try changing your proxy URL to be HTTP. See: https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html#https-proxy-error-http-proxy', SSLError(SSLError(1, '[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1081)')))
That hint is urllib3 diagnosing the problem for you. It arrived in urllib3 1.26.8 (2022-01-07, per the project's changelog), so older installs show only the bare WRONG_VERSION_NUMBER line.
The OpenSSL wording varies too. urllib3's detection code (urllib3/connection.py in 2.6.3) treats three phrases as "this proxy is HTTP-only": wrong version number, unknown protocol, and record layer failure. The last comes from OpenSSL 3.2+, and urllib3 2.2.0 (2024-01-30) learned it. The OpenSSL 3.2.4 command-line client, pointed at the gateway's HTTP port, printed packet length too long then record layer failure.
The second family looks like this, from a local test proxy that accepts the connection and closes it without a word:
ProxyError('Unable to connect to proxy', SSLError(SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1081)')))
Under urllib3 1.26.20 the identical failure surfaces as requests.exceptions.SSLError, not ProxyError. Code that catches only one of those classes will miss the other after an upgrade or downgrade. The _ssl.c:1081 number is a source line in CPython's _ssl.c, so it varies between Python builds and tells you nothing about the cause.
Why one letter in the proxy URL breaks TLS
In a proxy URL, the scheme describes the hop between you and the proxy. http:// means "connect over plain TCP and speak HTTP to the proxy." https:// means "open a TLS session to the proxy first, then speak HTTP inside it." For an https:// target with an http:// proxy, the client sends CONNECT www.sparkproxy.io:443 in clear text, then runs its TLS handshake with the target through that tunnel, so the page is still encrypted end to end. The CONNECT method explained post walks through that exchange.
For years the distinction barely mattered in Python. urllib3 1.25.9 (2020-04-16) only warned about an HTTPS proxy URL "specified in error". Then urllib3 1.26.0 (2020-11-10) "Added support for HTTPS proxies contacting HTTPS servers." From that release on, https:// in the proxy URL is taken literally: urllib3 opens TLS to the proxy.
Here's what goes wrong on the wire. Your client sends a TLS ClientHello. A plain HTTP proxy doesn't recognise it as a request and replies with something like HTTP/1.1 400 Bad Request. OpenSSL is expecting a TLS record header, reads the first bytes of that ASCII text in its place, and finds a protocol version that doesn't exist. Hence "wrong version number." A tiny local server that only sends HTTP/1.1 400 Bad Request produced the identical traceback to the gateway.
HTTP, HTTPS and SOCKS5 proxies are compared in understanding proxy protocols. Here you need one rule: the proxy URL's scheme must match what the proxy port speaks, whatever the target's scheme.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Four checks to confirm the scheme mismatch
They run in order of effort, so the quick ones come first. Stop once one of them points at the cause.
- Read the inner exception.
WRONG_VERSION_NUMBER,record layer failureorunknown protocolnext to the "only use HTTP" hint is a scheme mismatch.UNEXPECTED_EOF_WHILE_READINGis a closed connection (see the EOF section).CERTIFICATE_VERIFY_FAILEDon a proxy URL means you reached a real TLS proxy and it's a trust problem. - Print every proxy URL your process will use, not just the one in your code. Environment variables fill gaps silently.
- Test the proxy port with a TLS client. If a handshake to the port fails, the port is plain HTTP and your URL must say
http://. - Retry once with
http://. If the error changes to a 407 or a 200, you've found it.
This script covers steps 2 and 3 in one go. It lists the proxy URLs from your environment (and the Windows registry, which urllib.request.getproxies() falls back to), flags any https:// value, and probes whether the proxy port completes a TLS handshake:
import socket
import ssl
import urllib.request
from urllib.parse import urlsplit
CODE_PROXY = "https://gateway.sparkproxy.io:11000" # paste what your code passes
found = {"code": CODE_PROXY, **urllib.request.getproxies()}
for source, url in found.items():
if not isinstance(url, str) or "://" not in url:
continue
parts = urlsplit(url)
flag = " <-- TLS to the proxy" if parts.scheme == "https" else ""
print(f"{source:>6}: {parts.scheme}://{parts.hostname}:{parts.port}{flag}")
if parts.scheme in ("http", "https") and parts.hostname and parts.port:
ctx = ssl.create_default_context()
try:
with socket.create_connection((parts.hostname, parts.port), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=parts.hostname):
print(" port completed a TLS handshake: https:// is plausible")
except ssl.SSLError as e:
print(f" no TLS on this port ({e.reason}): use http://")
except OSError as e:
print(f" could not connect: {e!r}")
Against the gateway it printed no TLS on this port (WRONG_VERSION_NUMBER): use http://. A probe that ends in CERTIFICATE_VERIFY_FAILED instead means TLS did start, so the port really is a TLS proxy and the fix lives in the HTTPS proxy section, not in the scheme.
The one-character fix, everywhere it hides
For any proxy whose port speaks plain HTTP (SparkProxy's gateway ports 11000 and 11002 included; 13000 is the SOCKS5 port), change https:// to http:// in the proxy URL. Keep the "https" key in the dict. The key chooses which target URLs go through the proxy; the scheme inside the value says how to talk to the proxy.
import requests
PROXY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000" # http://, not https://
proxies = {"http": PROXY, "https": PROXY}
r = requests.get("https://www.sparkproxy.io/robots.txt", proxies=proxies, timeout=(5, 30))
print(r.status_code)
With that change our failing request stopped raising the SSL error and moved on to Tunnel connection failed: 407 Proxy Authentication Required, because the test box wasn't whitelisted. That's progress: a 407 proves TLS is no longer the problem, and how proxy authentication works covers the rest.
Code is the easy place. These are the places the s usually survives a fix:
Environment variables. A shell profile, Dockerfile or CI setting with HTTPS_PROXY=https://... looks right because of the variable's name. We set exactly that, with no proxy code at all: get_environ_proxies() returned {'https': 'https://gateway.sparkproxy.io:11000'} and the request failed with the "only use HTTP" error. urllib3's docs say every such variable should start "with http:// and not https://."
export HTTPS_PROXY="http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000"
export HTTP_PROXY="$HTTPS_PROXY"
env | grep -i _proxy # check lowercase https_proxy too; both are read
$env:HTTPS_PROXY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000"
[Environment]::GetEnvironmentVariable("HTTPS_PROXY", "User") # a persistent copy may still say https://
pip. pip install behind a proxy hits the same bug, because pip ships its own copy of urllib3. pip 25.3 on our box vendors urllib3 1.26.20, so you'll see the 1.26 wording even if your project uses urllib3 2.x. Fix the proxy line in pip.conf / pip.ini, or pass --proxy http://....
curl, for comparison. curl treats https:// in -x as a TLS proxy (since 7.52.0 for OpenSSL builds, 7.87.0 for Schannel, per its --proxy docs). Windows curl 8.15.0 reported the mismatch as curl: (35) schannel: next InitializeSecurityContext failed: SEC_E_INVALID_TOKEN (0x80090308) - The token supplied to the function is invalid; with -x http:// it returned CONNECT tunnel failed, response 407.
If proxy URLs come from config you don't control, catch the mistake at startup:
from urllib.parse import urlsplit
HTTP_ONLY_HOSTS = {"gateway.sparkproxy.io"} # proxies known to speak plain HTTP
def check_proxy_url(url: str) -> str:
parts = urlsplit(url)
if parts.scheme == "https" and parts.hostname in HTTP_ONLY_HOSTS:
raise ValueError(f"{parts.hostname} speaks plain HTTP; use http:// in the proxy URL")
return url
PROXY = check_proxy_url("http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000")
When you really do need an HTTPS proxy
Some proxies do expect TLS on the client side, typically corporate egress proxies that keep credentials and the CONNECT line off the local network. For those, https:// is correct, and urllib3 builds what its docs call a TLS-in-TLS tunnel: "An initial TLS connection will be established to the proxy, then an HTTP CONNECT will be sent and finally a second TLS connection will be established to the destination."
To test this properly we ran a small TLS-wrapped CONNECT proxy locally with a self-signed certificate. Three findings:
- The reverse mismatch fails differently. Using
http://for a TLS proxy gaveConnectionResetError(10054, 'An existing connection was forcibly closed by the remote host')on the client, while the proxy logged[SSL: HTTPS_PROXY_REQUEST] https proxy request. No WRONG_VERSION_NUMBER at all, so don't go looking for it. - With the right scheme, the next wall is trust.
https://plus default verification producedCERTIFICATE_VERIFY_FAILED ... self-signed certificate, wrapped asProxyErroron urllib3 2.6.3 and asSSLErroron 1.26.20. - requests checks the proxy's certificate against the same
verify=bundle as the target. We built a single PEM containing certifi's roots plus the proxy's certificate, passed it asverify=, and the request returned 200.
import certifi
import requests
with open("combined-ca.pem", "w") as out: # public roots + your proxy's CA
out.write(open(certifi.where()).read())
out.write(open("corp-proxy-ca.pem").read())
PROXY = "https://corp-proxy.sparkproxy.io:8443" # placeholder: your TLS-speaking proxy
r = requests.get("https://www.sparkproxy.io/robots.txt",
proxies={"https": PROXY}, verify="combined-ca.pem", timeout=(5, 30))
print(r.status_code)
Don't reach for verify=False here. It switches off verification for the target too, so you lose the protection the HTTPS proxy was meant to add. If you need separate settings for the proxy hop, drop down to urllib3, whose ProxyManager takes a proxy_ssl_context:
import ssl
import urllib3
proxy_ctx = ssl.create_default_context(cafile="corp-proxy-ca.pem")
pm = urllib3.ProxyManager("https://corp-proxy.sparkproxy.io:8443", proxy_ssl_context=proxy_ctx)
resp = pm.request("GET", "https://www.sparkproxy.io/robots.txt", timeout=30.0)
print(resp.status)
urllib3 also offers use_forwarding_for_https=True, which skips the inner tunnel and hands the proxy your full HTTPS request. Its docs strongly recommend using it "only with trusted or corporate proxies", since the proxy then sees everything. If you're working out how such a proxy inspects traffic, see how enterprise proxies intercept and re-sign TLS.
For SparkProxy you don't need any of this. "HTTPS support" on the gateway means HTTPS targets through CONNECT; the ports themselves speak plain HTTP, as our TLS probe showed.
SSLEOFError: the other side hung up mid-handshake
UNEXPECTED_EOF_WHILE_READING means the TCP connection closed while OpenSSL was still waiting for handshake bytes. Which hop closed it depends on your scheme.
With an https:// proxy URL, the TLS session is with the proxy, so the proxy hung up. Our local proxy that reads the ClientHello and closes produced exactly the SSLEOFError(8, ...) shown earlier. Some proxies do this instead of sending a 400, which is why a scheme mismatch can surface as EOF rather than WRONG_VERSION_NUMBER. Apply the http:// fix first.
With an http:// proxy URL, the same silent-closing proxy produced no SSL error. urllib3 2.6.3 gave ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')), and 1.26.20 wrapped it as ProxyError('Cannot connect to proxy.', RemoteDisconnected(...)). So an SSLEOFError with a correct http:// URL points past the proxy: the tunnel opened and the target's handshake was cut off.
The usual causes, in the order worth checking:
- The proxy rejects you by closing, not by answering 407. Some proxies drop unauthenticated or over-limit clients without explanation. SparkProxy's gateway does answer with a proper
407 Proxy Authentication Required(we got one with no credentials and again with deliberately wrong ones), so an EOF from it is not an auth signal. With other providers, check credentials and your IP whitelist before anything else. - The target or a middlebox resets connections from that exit IP. Anti-bot edges sometimes kill the handshake instead of returning 403. A different exit IP changes the outcome, which is the one case here where rotation helps. If the same IP works in a browser but not in Python, the TLS fingerprint may be the trigger; what is TLS fingerprinting explains how that's detected.
- Too many parallel tunnels. A proxy at its connection limit may refuse new tunnels by closing them. We didn't reproduce this one, so treat it as a suspect rather than a diagnosis: count your in-flight requests against your plan before blaming the network.
One caution: when our test proxy sent 200 Connection established and closed at once, Windows reported ConnectionAbortedError(10053, ...), not an SSL error, most likely because the ClientHello hit a socket already closing. The exception for "tunnel opened then died" depends on timing and OS, so catch requests.exceptions.ConnectionError (it covers all of these) rather than matching one string.
Python 3.10, OpenSSL 3 and louder EOF errors
If your scraper ran quietly on an old interpreter and started throwing UNEXPECTED_EOF_WHILE_READING after an upgrade, two changes are behind it.
OpenSSL 3.0 made unexpected EOF an explicit error. The SSL_get_error man page for 3.0 says that before 3.0, an unexpected EOF "returned SSL_ERROR_SYSCALL, nothing was added to the error stack, and errno was 0," while "since OpenSSL 3.0 the returned error is SSL_ERROR_SSL with a meaningful error on the error stack (SSL_R_UNEXPECTED_EOF_WHILE_READING)." Python surfaces that reason code inside the SSLEOFError message. Same network event, new label.
Python 3.10 tightened TLS defaults. The What's New in 3.10 notes say the ssl module now requires OpenSSL 1.1.1 or newer (PEP 644), that SSLContext "defaults to minimum protocol version TLS 1.2," and that security level 2 "prohibits weak RSA, DH, and ECC keys with less than 112 bits of security." An old HTTPS proxy limited to TLS 1.0 or 1.1 now fails the handshake, and the fix belongs on the proxy, not in a weaker client. Python 3.13 also added VERIFY_X509_STRICT to the default context (ssl docs), which can reject older self-issued proxy certificates.
Python 3.10 also added ssl.OP_IGNORE_UNEXPECTED_EOF (OpenSSL 3.0+ only), and it's tempting to switch it on. We did, on the proxy hop through urllib3's proxy_ssl_context, against our silently-closing proxy. The request still failed; only the label changed, to SSLZeroReturnError(6, 'TLS/SSL connection has been closed (EOF)'). The option is for servers that skip the TLS close notification after sending a full response. It cannot complete a handshake the other side abandoned, so it won't fix any proxy failure in this guide.
Check what your interpreter actually links against before trusting anyone's version advice, including ours:
import ssl
import sys
import urllib3
print(sys.version.split()[0], ssl.OPENSSL_VERSION, "urllib3", urllib3.__version__)
print("TLS minimum:", ssl.TLSVersion(ssl.create_default_context().minimum_version).name)
Config bug or proxy problem? A lookup table
| Symptom (inner error) | Cause | Does switching proxy help? | Real fix |
|---|---|---|---|
| `WRONG_VERSION_NUMBER` + "only use HTTP" | `https://` in URL for an HTTP proxy | No | Change to `http://` in code, env vars, pip config |
| `record layer failure` / `unknown protocol` | Same mismatch, other OpenSSL builds | No | Same as above |
| `UNEXPECTED_EOF_WHILE_READING`, `https://` proxy URL | Proxy closed the TLS attempt | Rarely | Check scheme, then credentials and whitelist |
| `ConnectionResetError` with `http://` to a TLS proxy | Reverse mismatch | No | Use `https://` for proxies that really speak TLS |
| `CERTIFICATE_VERIFY_FAILED` on the proxy | Proxy CA not trusted | No | Combined CA bundle, or `proxy_ssl_context` |
| `UNEXPECTED_EOF_WHILE_READING`, `http://` proxy URL | Target or middlebox cut the handshake | Often | New exit IP, slower rate, check fingerprint |
| Handshake failure after a Python upgrade | Legacy TLS on the proxy | No | Upgrade the proxy's TLS |
Five of the seven rows are configuration. Buying a different proxy fixes none of them, and that's worth knowing before you open a ticket with any provider.
A working SparkProxy setup with sane retries
This is the shape we'd ship: http:// scheme, Sticky Sessions on port 11002 when a job needs the same IP for up to five minutes (port 11000 gives Random Rotation per request), no retries for configuration errors, and a small number of retries for connections cut mid-handshake. Async code follows the same pattern; the aiohttp side is in using proxies with requests and aiohttp.
import time
import requests
PROXY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11002" # Sticky Sessions port
FATAL_MARKERS = ("WRONG_VERSION_NUMBER", "only use HTTP", "record layer failure",
"CERTIFICATE_VERIFY_FAILED", "407")
def fetch(url: str, attempts: int = 3) -> requests.Response:
session = requests.Session()
session.trust_env = False # ignore stray HTTPS_PROXY=https://... in the environment
for n in range(1, attempts + 1):
try:
return session.get(url, proxies={"http": PROXY, "https": PROXY}, timeout=(5, 30))
except requests.exceptions.ConnectionError as e: # ProxyError and SSLError included
text = str(e)
if any(m in text for m in FATAL_MARKERS) or n == attempts:
raise # config problems never fix themselves on attempt two
time.sleep(2 ** n)
print(fetch("https://www.sparkproxy.io/robots.txt").status_code)
trust_env = False matters here: it stops requests from reading environment proxies, so a leftover https:// variable can't come back. It also disables .netrc and REQUESTS_CA_BUNDLE, so pass verify= yourself if you depend on a custom bundle. For how long to wait between attempts, see proxy timeouts and retry logic.
If the handshake keeps getting cut on a particular target even with fresh IPs, the Scraping API moves the TLS work off your machine. You make one ordinary HTTPS request to SparkProxy and the fetch runs on our side:
import requests
r = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={"url": "https://www.sparkproxy.io/robots.txt",
"render_js": "false", # plain fetch, 1 credit; render_js defaults to true (5 credits)
"proxy_type": "premium"},
timeout=(5, 60),
)
print(r.status_code, r.text[:120])
Leave render_js at its default only when the page needs a browser, because rendering costs 5 credits instead of 1. New accounts get 1,000 free credits. Proxy plans start from $75/month (USD, from sparkproxy.io as read on 2026-10-09) with unlimited bandwidth, so retries don't add traffic charges, and the 24-hour free trial needs no credit card. That's enough time to run the diagnostic script from every machine and container that will use the proxy.
Frequently asked questions
FAQ
Your client started a TLS handshake with something that answered in a different protocol, almost always a plain HTTP proxy replying in text. In Python the usual cause is https:// at the start of the proxy URL. Change it to http:// and HTTPS sites still stay encrypted through the CONNECT tunnel.
urllib3 spotted an SSL error such as wrong version number, unknown protocol or record layer failure on a proxy configured as https://, and it's telling you the proxy port speaks plain HTTP. The hint was added in urllib3 1.26.8. Fix the scheme in code, environment variables and pip config.
Yes. The http:// only describes the hop to the proxy. Your client asks the proxy to open a tunnel with CONNECT and then negotiates TLS directly with the website through it, so the proxy relays encrypted bytes it can't read.
First check the scheme, because a proxy that closes on a TLS attempt gives this error instead of WRONG_VERSION_NUMBER. If the URL already says http://, the target's handshake was cut off: check credentials and whitelist, reduce concurrency, and retry with a different exit IP.
No. In our test it only changed the error to SSLZeroReturnError. The option tolerates servers that close without a TLS close notification after a complete response, and it cannot rescue a handshake the proxy or target abandoned.
OpenSSL 3.0 reports an unexpected EOF as a named error where 1.1.1 returned a bare syscall error, and Python 3.10 raised the default minimum to TLS 1.2 with stricter key sizes. Connections that failed silently, or that relied on legacy TLS at an old proxy, now raise visible SSL errors.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
pip install Proxy Setup: --proxy, pip.conf and Env Vars
pip install proxy setup that works: the --proxy flag, pip.conf, PIP_PROXY vs HTTPS_PROXY precedence, encoded passwords, and the SSL error a wrong scheme causes.
Proxy Not Working on Certain Websites? How to Fix It
Proxy not working on certain websites but fine everywhere else? Tell a block from a port, TLS/SNI or DNS fault with one command each, then apply the right fix.
curl Proxy CONNECT Aborted? Exit Codes 5, 7, 35, 56 Fixed
curl proxy CONNECT aborted, (7) failed to connect, or a 407 after CONNECT? Map each curl exit code to the hop that failed, read the -v line, and fix it.
