๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Guides

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.

S SparkProxy 2 20 min read
Share

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.

Free trial

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.

  1. Read the inner exception. WRONG_VERSION_NUMBER, record layer failure or unknown protocol next to the "only use HTTP" hint is a scheme mismatch. UNEXPECTED_EOF_WHILE_READING is a closed connection (see the EOF section). CERTIFICATE_VERIFY_FAILED on a proxy URL means you reached a real TLS proxy and it's a trust problem.
  2. Print every proxy URL your process will use, not just the one in your code. Environment variables fill gaps silently.
  3. 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://.
  4. 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 gave ConnectionResetError(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 produced CERTIFICATE_VERIFY_FAILED ... self-signed certificate, wrapped as ProxyError on urllib3 2.6.3 and as SSLError on 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 as verify=, 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:

  1. 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.
  2. 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.
  3. 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)CauseDoes switching proxy help?Real fix
`WRONG_VERSION_NUMBER` + "only use HTTP"`https://` in URL for an HTTP proxyNoChange to `http://` in code, env vars, pip config
`record layer failure` / `unknown protocol`Same mismatch, other OpenSSL buildsNoSame as above
`UNEXPECTED_EOF_WHILE_READING`, `https://` proxy URLProxy closed the TLS attemptRarelyCheck scheme, then credentials and whitelist
`ConnectionResetError` with `http://` to a TLS proxyReverse mismatchNoUse `https://` for proxies that really speak TLS
`CERTIFICATE_VERIFY_FAILED` on the proxyProxy CA not trustedNoCombined CA bundle, or `proxy_ssl_context`
`UNEXPECTED_EOF_WHILE_READING`, `http://` proxy URLTarget or middlebox cut the handshakeOftenNew exit IP, slower rate, check fingerprint
Handshake failure after a Python upgradeLegacy TLS on the proxyNoUpgrade 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.

Special Discount ยท 20% off

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

Claim Discount

About the Author

The SparkProxy Technical Team builds and runs SparkProxy's datacenter proxy network, its gateway endpoints and the Scraping API. The tracebacks in this guide come from our own reproduction runs on 2026-10-30, with the Python, OpenSSL and urllib3 versions stated so you can match them against your own output.

Keep reading

Related articles