Troubleshooting8 min read

SSL WRONG_VERSION_NUMBER behind a proxy: which hop failed

The same SSL wrong version number error comes from two hops behind a proxy. Tell which one failed in curl, Requests and HTTPX, then apply the right fix.

On this page

wrong version number is what OpenSSL reports when it starts a TLS handshake and the first bytes of the reply are not TLS. In practice they are plain HTTP: the client sent a ClientHello to a port that answers in cleartext, and a reply such as HTTP/1.1 400 Bad Request cannot be read as a TLS record. No certificate, protocol version or cipher is involved, so --tlsv1.2, --insecure and verify=False change nothing.

Behind a proxy that handshake can happen in two places, and most clients print the same text for both:

  1. Proxy hop. The proxy URL says https://, so the client starts TLS with the proxy itself, but the proxy port speaks plain HTTP.
  2. Target hop. The proxy URL says http://, CONNECT succeeds, and the client starts TLS through the tunnel with a target port that doesn't speak TLS, such as https://example.com:80/.
ClientCase 1: proxy hopCase 2: target hop
curl 8.18.0curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version numberThe same line
Requests 2.34.2ProxyError: Unable to connect to proxy. Your proxy appears to only use HTTP and not HTTPS, try changing your proxy URL to be HTTP.SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version number, with no hint
HTTPX 0.28.1ConnectError: [SSL: WRONG_VERSION_NUMBER] wrong version numberThe same line

Only Requests names the hop. With curl and HTTPX you need one more check, described next. The Python rows come from Python 3.12.3 with OpenSSL 3.0.13; newer OpenSSL builds word the same failure differently, and the last table on this page lists every string recorded.

Tell the two hops apart

Read the proxy URL first. Case 1 needs an https:// proxy URL. Print what the process really uses, including variables you didn't set yourself:

sh
env | grep -i _proxy

If every proxy URL already starts with http://, you are in case 2.

curl: read the CONNECT status. %{http_connect} is the status of the proxy's reply to CONNECT. Zero means curl never got that far.

sh
curl --silent --show-error --output /dev/null \
  --proxy "$PROXY_URL" \
  --write-out 'connect=%{http_connect} exit=%{exitcode}\n' \
  https://example.com/
  • connect=000 exit=35: the handshake with the proxy failed. Case 1.
  • connect=200 exit=35: the tunnel opened and the handshake with the target failed. Case 2.

curl -v shows the same thing. In case 2 these lines come before the error; in case 1 there is no CONNECT line at all:

code
> CONNECT 127.0.0.1:18000 HTTP/1.1
< HTTP/1.1 200 Connection established
* CONNECT tunnel established, response 200
* TLS connect error: error:0A00010B:SSL routines::wrong version number

Verbose output also prints the Proxy-Authorization header, so share the write-out line, not the trace.

Requests: read the exception class. requests.exceptions.ProxyError means the connection to the proxy failed. requests.exceptions.SSLError means TLS failed after the tunnel was open.

python
import requests

proxy_url = "http://USERNAME:PASSWORD@proxy.example.net:8080"
proxies = {"http": proxy_url, "https": proxy_url}
try:
    requests.get("https://example.com/", proxies=proxies, timeout=(10, 20))
except requests.exceptions.ProxyError as error:
    print("proxy hop:", type(error).__name__)
except requests.exceptions.SSLError as error:
    print("target hop:", type(error).__name__)

The hint comes from urllib3, which adds it only when the proxy URL scheme is https and the TLS error contains wrong version number, unknown protocol or record layer failure. Older releases were less careful: urllib3 1.26.8 printed the hint for http:// proxy URLs too (issue #2564). On an old urllib3, trust the proxy URL you printed, not the hint.

HTTPX: change the scheme, or read the httpcore log. HTTPX raises the same ConnectError for both. If the proxy URL is https://, change it to http:// and run again: when the error goes away it was case 1, and when the same error remains it is case 2. For a direct answer, turn on debug logging:

python
import logging

logging.basicConfig(level=logging.DEBUG)

Case 2 logs a CONNECT that returns 200, then a failure in httpcore.proxy:

code
DEBUG:httpcore.http11:receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'Connection established', [])
DEBUG:httpcore.proxy:start_tls.failed exception=ConnectError(SSLError(1, '[SSL: RECORD_LAYER_FAILURE] record layer failure (_ssl.c:1081)'))

Case 1 logs no CONNECT, and the failing line is httpcore.connection:start_tls.failed. This log is from Python 3.14.4 with OpenSSL 3.5.5, which is why it says RECORD_LAYER_FAILURE.

Fix case 1: write http:// in the proxy URL

The scheme in a proxy URL describes how the client talks to the proxy, not what it fetches through it. An http:// proxy URL still carries HTTPS traffic: the client sends CONNECT in cleartext, then runs TLS with the target inside the tunnel. Use https:// only when your provider documents a TLS proxy port. curl's --proxy documentation treats a missing scheme or http:// as an HTTP proxy and https:// as an HTTPS proxy.

sh
curl --proxy http://USERNAME:PASSWORD@proxy.example.net:8080 https://example.com/
python
import requests

proxy_url = "http://USERNAME:PASSWORD@proxy.example.net:8080"
response = requests.get(
    "https://example.com/",
    proxies={"http": proxy_url, "https": proxy_url},
    timeout=(10, 20),
)
python
import httpx

with httpx.Client(proxy="http://USERNAME:PASSWORD@proxy.example.net:8080") as client:
    response = client.get("https://example.com/")
sh
export HTTPS_PROXY=http://USERNAME:PASSWORD@proxy.example.net:8080
export HTTP_PROXY=http://USERNAME:PASSWORD@proxy.example.net:8080

In the lab, the proxy port that failed with https:// returned 200 in all three clients once the scheme was http://, set in code or through HTTPS_PROXY. If the error survives the change, an environment variable may still be supplying the old value: Requests and HTTPX both read HTTPS_PROXY by default (trust_env). Proxy environment variables covers which variable each client reads, and the curl, Requests and HTTPX guides have complete setups.

The trap: the dictionary key is not the proxy's scheme

In proxies={"https": ...} the key is the scheme of the URL you are requesting. The value is the proxy URL, with its own scheme. The two don't have to match, and for a plain HTTP proxy port they must not:

python
# Case 1: TLS to a proxy port that speaks plain HTTP
proxies = {"https": "https://USERNAME:PASSWORD@proxy.example.net:8080"}

# Correct: HTTPS targets through an HTTP proxy
proxies = {"https": "http://USERNAME:PASSWORD@proxy.example.net:8080"}

Environment variables work the same way. The name HTTPS_PROXY selects which requests use the proxy, and the value says how to reach it.

Fix case 2: correct the target URL

The proxy is fine. The URL asks for TLS on a port that serves plain HTTP. Typical causes are https:// combined with port 80 or an internal HTTP port, and a base URL assembled from a scheme and a port that are configured separately. Confirm it by sending plain HTTP through the same tunnel:

sh
curl --silent --show-error --proxy "$PROXY_URL" --proxytunnel \
  --write-out 'connect=%{http_connect} status=%{http_code}\n' \
  http://example.com:80/

In the lab this printed the page and connect=200 status=200 for the port that had failed with https://, while a port that really speaks TLS gave curl: (52) Empty reply from server. An HTTP status here means the port speaks plain HTTP: change the URL to http://, or point it at the TLS port. If you are allowed to connect without the proxy, the same URL fails the same way directly, which also clears the proxy.

If the URL is right and the error comes and goes, the bytes arriving through the tunnel are not from your target's TLS server. urllib3 issue #2564 reports that pattern with an unstable proxy. Record the connect= line with a timestamp and send it to the provider.

Retries won't fix either case

Both cases are configuration errors, and every attempt fails the same way. The Max retries exceeded in the Requests message doesn't mean retries ran: the default adapter is configured for zero retries and prints that text anyway. A retry policy only delays the same exception. Fix the URL, and keep retries for failures that can change between attempts.

Inside an MCP-compatible coding agent, the free Proxy Toolkit MCP diagnoses proxy errors by request phase. Give it the client, the exception and the connect= value, without credentials.

The wording depends on the OpenSSL build

The text comes from the TLS library, not from the client, so the same failure reads differently across builds. Each build printed the same string at both hops unless noted:

BuildError text
curl 8.18.0 (OpenSSL 3.5.5), curl 8.17.0 (OpenSSL 3.6.0), curl 8.22.0 (OpenSSL 4.0.2)curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number
curl 8.12.1 (OpenSSL 3.4.1)curl: (35) TLS connect error: error:0A0000C6:SSL routines::packet length too long
curl 8.7.1 (OpenSSL 3.2.1)curl: (35) OpenSSL/3.2.1: error:0A0000C6:SSL routines::packet length too long
Python 3.11.3 (OpenSSL 1.1.1s), Python 3.12.3 (OpenSSL 3.0.13)[SSL: WRONG_VERSION_NUMBER] wrong version number
Python 3.14.4 (OpenSSL 3.5.5)[SSL: RECORD_LAYER_FAILURE] record layer failure
Node.js 24.20.0 fetch (OpenSSL 3.5.7)TypeError: fetch failed, cause code ERR_SSL_WRONG_VERSION_NUMBER
Playwright 1.63.0 (Chromium 153)Case 1: net::ERR_PROXY_CONNECTION_FAILED. Case 2: net::ERR_SSL_PROTOCOL_ERROR

So packet length too long and record layer failure with a proxy in the path are the same two cases. On all three Python builds, Requests raised ProxyError with the hint in case 1 and a bare SSLError in case 2. unknown protocol, the third phrase urllib3 looks for, did not appear on any build tested. Chromium is the one client here that separates the hops by itself; Fix ERR_TUNNEL_CONNECTION_FAILED covers its proxy errors, and Node.js fetch failed behind a proxy covers reading the cause chain.

How this was tested

On 4 October 2026, on an Ubuntu 26.04 VPS, with everything on 127.0.0.1: a listener that answers any input with a plaintext HTTP/1.1 400 Bad Request, a minimal plain-HTTP CONNECT proxy, python -m http.server as the plain HTTP target, and the same handler behind a throwaway certificate as a working TLS target. No proxy account and no outbound traffic. Case 1 pointed an https:// proxy URL at the plaintext listener and at the CONNECT proxy. Case 2 used the CONNECT proxy with an https:// URL for the plain HTTP port. A control with the http:// proxy URL and the TLS target returned 200 in every client.

Clients: the curl builds above; Requests 2.34.2 with urllib3 2.8.0 and HTTPX 0.28.1 with httpcore 1.0.9 on the three Python builds; Node.js 24.20.0 fetch with NODE_USE_ENV_PROXY=1; Playwright 1.63.0 with Chromium 153.0.8010.12. The lab archive contains the lab script, the client scripts, the README and the recorded results.

Not tested: macOS and Windows, TLS libraries other than OpenSSL and Chromium's own, SOCKS proxies, real TLS proxy ports and any commercial proxy network. A proxy port that closes the connection or stays silent, with no plaintext reply, was not tested either and may produce a different error.

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.