A failure in the TLS handshake means that attempt was not sent, unless you turned on TLS early data. A failure after the handshake means the outcome is unknown, and the target may already have your request. Either way your last error line describes one attempt, not the whole job. The string says the connection ended while TLS still expected data, and it doesn't say who ended it.
No proxy that you know of? Check that first: many public reports of these strings involve none.
Earlier attempts are a separate question. A mounted Retry or max_retries, urllib3 used directly, an SDK with built-in retries or a followed redirect can put a POST at the target even when the last line reads as a handshake EOF. Behind an HTTP proxy those retry settings can re-send a POST by themselves, including Retry(total=3, allowed_methods={"GET"}).
| Your last line | This attempt |
|---|---|
SSLError(SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1081)')), <urlopen error [SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1081)>, or that text with no prefix, with a low _ssl.c number (we saw 1000 to 1147) | Not sent |
httpx.ConnectError: [SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1081) | Not sent |
ProxyError('Unable to connect to proxy', SSLError(SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1081)'))) | Not sent. If your proxy URL says https:// and the port speaks plain HTTP, write http:// |
curl: (35) TLS connect error: error:0A000126:SSL routines::unexpected eof while reading, curl: (35) Send failure: Broken pipe or curl: (35) Recv failure: Connection reset by peer | Not sent. With --tls-earlydata, unknown |
curl: (56) OpenSSL SSL_read: OpenSSL/3.5.5: error:0A000126:SSL routines::unexpected eof while reading, errno 0 | Unknown |
ProxyError('Unable to connect to proxy', RemoteDisconnected('Remote end closed connection without response')) | Unknown |
SSLEOFError with a high _ssl.c number (we saw 2406 to 3042), or [SSL: UNEXPECTED_EOF_WHILE_READING] unexpected eof while reading (_ssl.c:2711) | Unknown |
These results are synthetic: our own scripted proxies and target on one machine (the fixture) and named proxy software on loopback.
- Not sent, on a first attempt with no redirect: a retry can't duplicate anything.
- Not sent after retries or a redirect, or Unknown: look for the result at the target before you send a POST again. Give it time: on the fixture a relay that held the request delivered it after the client had printed its error (scripted timing), so an empty lookup straight after the failure proves nothing. With no lookup or idempotency key at the target, the outcome stays unknown: ask its operator, or decide whether a duplicate is worse than a lost request.
A followed redirect counts as an earlier attempt: the POST may already sit at the first URL, and the last line can't rule that out. Requests and urllib3 follow them by default and print the next hop, which can be the address you posted to. urllib.request, aiohttp, HTTPX's follow_redirects=True and curl -L print no path. If this endpoint can redirect, treat the POST as unknown, or send it with redirects off.
For a safe GET or HEAD a duplicate shouldn't change anything at the target, so go straight to the comparison below. No client-side check can always name the side that closed; the comparison narrows it, and the table by path gives the next action.
pip ends on ERROR: No matching distribution found, so read the WARNING: Retrying lines above it. Its There was a problem confirming the ssl certificate line blames the certificate, but no certificate was involved. A package download has nothing to duplicate, so a retry is safe, but pip has already retried: next, check which proxy pip picks up.
The _ssl.c numbers change between builds, but CPython's source has kept handshake lines at or below 1176 and read or write lines at or above 1844 from 3.5 to main. PyPy prints no number and can swap the wording, so treat its line as unknown. The prefix alone doesn't show the stage: [SSL: UNEXPECTED_EOF_WHILE_READING] also printed on a cut upload on Linux, with the headers at the target.
Turning off certificate checks doesn't help: verify=False, curl --insecure and pip's --trusted-host printed the same line on the fixture, and ssl.OP_IGNORE_UNEXPECTED_EOF, where the build has it, only changed it to SSLZeroReturnError.
Is a proxy in your path at all?
env | grep -i _proxy
python3 -c "import urllib.request; print(urllib.request.getproxies())"
pip config listThe output can hold the proxy password, so don't paste it anywhere. If a proxy shows up that you didn't expect and don't need, remove it (unset HTTPS_PROXY, pip config --user unset global.proxy), then try a safe request again. The second line also reads the Windows registry and a macOS system proxy. The third covers pip.
If nothing is set and you run no local proxy client, this page isn't your main path. Many public reports of these strings involve no proxy. Look here instead:
- A large upload or a body limit: CPython #110467, UbiOps.
- A service refusing your IP: a Let's Encrypt thread.
- An old server with no shared cipher: urllib3 #3100, bpo-45487.
- A peer that closes without
close_notify: OpenSSL. - A firewall in the path: Microsoft Q&A.
Check the next attempt with a HEAD or GET, never the failed POST
A probe describes only the attempt it makes, and it can pass on an intermittent fault. Your last line stays the evidence for the request that failed.
Set PROXY_URL to the proxy your failing job uses and TARGET_URL to the HTTPS address that failed. An empty PROXY_URL would turn the proxy off, so these commands stop with an error instead.
curl --silent --show-error --head --output /dev/null --max-time 20 \
--noproxy '' --proxy "${PROXY_URL:?}" \
--write-out 'connect=%{http_connect} exit=%{exitcode} appconnect=%{time_appconnect} http=%{http_code}\n' \
"${TARGET_URL:?}"connect is the status of the proxy's reply to CONNECT (000 if curl read none). appconnect is the seconds until the TLS handshake with the target finished (through a TLS-inspecting proxy, with that proxy). exit is curl's exit code: %{exitcode} needs curl 7.75 or later; before that, read $?.
Read appconnect before the exit code. On a new connection to an https:// target with TLS early data off (curl's default), appconnect 0 with a non-zero exit meant the attempt that printed it was not sent, on every curl build we ran. A reused connection also reports 0, even with the POST delivered, so use this only on a one-URL run like the one above. Exit 35 on a one-URL HTTP/1.1 run showed 0 unless -L or auth negotiation came first. Exit 56 occurred with 0 receipts and with 1 or more (a receipt is one complete request the target read).
| Write-out | Meaning | Next |
|---|---|---|
connect=200 exit=35, appconnect 0 | Tunnel opened, handshake through it failed. Not sent | Compare |
connect=200 exit=28, appconnect 0 | Tunnel opened, handshake went silent. Not sent | Timeouts |
connect=000 exit=35, https:// proxy URL | TLS to the proxy failed. Not sent | Check the scheme |
connect=000 exit=7 | Proxy port closed. Not sent | Host and port |
connect=000 exit=56, appconnect 0, curl: (56) Proxy CONNECT aborted or Recv failure: Connection reset by peer | Proxy never answered CONNECT. Not sent | Proxy's log or provider |
connect=000 exit=97, SOCKS5 | Proxy refused or couldn't reach the target. Not sent | Proxy's log or provider |
connect=502 exit=7 (exit 56 on curl 8.7.1) | Proxy refused the tunnel. Not sent | Tunnel 502, other codes |
appconnect above 0, any non-zero exit (we saw 16, 18, 28, 52, 55, 56, 92) | Handshake done, then it ended. Unknown | Compare |
connect=200 exit=0, appconnect above 0, any http | This attempt got a response, and http is its status. It says nothing about the attempt that failed | Repeat it |
Through SOCKS5 connect is always 000, even with the request delivered (120 of 120 calls on loopback), so go by appconnect there.
No curl? ssl_eof_probe.py needs only Python and says in words how far one HEAD got: HTTPS_PROXY="$PROXY_URL" python3 ssl_eof_probe.py "$TARGET_URL". With no proxy set it stops with an error. We ran it on Linux and macOS, not Windows, and it doesn't test SOCKS5.
A wrong scheme can print this string too: an https:// proxy URL whose handshake closes gives the ProxyError with SSLError row in the first table. On loopback, 6 of 15 proxy ports with no TLS did this at connect=000 exit=35 (rows). If your port has no TLS, write http:// in the proxy URL, or socks5h:// if it's a SOCKS5 port.
Share the write-out line, not a verbose trace: curl -v prints a Proxy-Authorization: Basic token that decodes to the password, and --proxy shows the password in the process list while curl runs. aiohttp's ClientHttpProxyError prints url='http://USERNAME:PASSWORD@...' in str(). With a well-formed proxy URL, Requests, urllib3, HTTPX and curl's error lines showed no password. A proxy URL a client can't parse is different: with an unencoded / in the password, curl printed the whole URL, password included, and Requests, urllib3 and HTTPX printed part or all of it. Requests and urllib3 also print the query string, in the error and in the logging below, so a token in the URL lands in your log.
ProxyError with RemoteDisconnected('Remote end closed connection without response'): the request may have been sent
This is the line you see when a tunnel ends with no response after the handshake. The label Unable to connect to proxy can be false: the tunnel had connected, and across 540 single-attempt calls on loopback the target held the complete request in 309, headers only in 179, and nothing in 52. Those count failures we provoked, not rates. urllib3 1.26.20 prints Cannot connect to proxy. for this drop and also for a proxy that closes before any tunnel opens, so that line is unknown too.
| These read the same way | This attempt |
|---|---|
ProxyError('Unable to connect to proxy', ConnectionResetError(104, 'Connection reset by peer')) | Unknown |
('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) | Unknown |
httpx.RemoteProtocolError: Server disconnected without sending a response. | Unknown |
httpx.ReadError | Unknown |
Read timed out. (read timeout=3.0) | Unknown. If your connect and read timeouts differ and the number is the connect one, that case was not sent |
curl: (52) Empty reply from server | Unknown |
curl: (56) Recv failure: Connection reset by peer | Unknown, unless the write-out shows appconnect 0 |
Treat this line and SSLEOFError as one incident. On the fixture, a job sending GETs while the proxy's exit IP changed saw the in-flight request fail with this ProxyError, then SSLEOFError on each new tunnel, then 200 (3 of 3 runs on Linux and on macOS). So when a log shows SSLEOFError, look one line up. An earlier ProxyError is the request that may have landed. To see which side closed it, run the comparison below.
Retries can hide that line, and Max retries exceeded doesn't show them: it printed after 1 CONNECT on a default session and after 4 on max_retries=3. Add this before the job's next run:
import logging
logging.basicConfig(level=logging.WARNING)Then look for a line like this:
WARNING:urllib3.connectionpool:Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'RemoteDisconnected('Remote end closed connection without response')': /drop-finOn the fixture a plain script printed no Retrying lines and logging printed 3, the first naming RemoteDisconnected, and both runs ended on SSLEOFError with 1 receipt at the target. Treat any RemoteDisconnected or reset line as unknown, whatever the last line says. For a run that already happened with retries on and logging off, treat the earlier attempts as unknown. curl's --retry-all-errors re-sends the same way: --show-error prints every attempt, and --silent alone prints none.
What each retry setting re-sends
Receipts at the target for a POST delivered and then dropped with no response, recorded on the fixture on 5 November 2026, 3 calls per cell. The curl and HTTPX rows come from the named-software run on 4 November (curl flag rows: 18, 12 and 2 calls, left to right).
| Setting | HTTP proxy | SOCKS5 (socks5h://) | No proxy |
|---|---|---|---|
| Requests default session | 1 | 1 | 1 |
HTTPAdapter(max_retries=3) | 4 | 1 | 1 |
Retry(total=3) | 4 | 1 | 1 |
Retry(total=3, allowed_methods={"GET"}) | 4 | 1 | 1 |
Retry(total=3, status_forcelist=[429,500,502,503,504]) | 4 | 1 | 1 |
urllib3 with no retries argument | 4 | 1 | 1 |
Retry(connect=3) or Retry(read=3) | 11 | 1 | 1 |
Retry(total=3, allowed_methods={"GET", "POST"}), or allowed_methods=[] | 4 | 4 | 4 |
Retry(total=3, other=0) | 1 | 1 | 1 |
HandshakeEOFRetry(total=3, other=0), below | 1 | 1 | 1 |
HTTPX default or HTTPTransport(retries=3) | 1 | 1 | 1 |
| aiohttp default | 1 | not run | 1 |
curl, with or without --retry 3 | 1 | 1 | 1 |
curl --retry 3 --retry-all-errors | 4 | 4 | 4 |
Retry(connect=3) leaves total at its default of 10, and this drop spends total. These counts need a close or reset with no response bytes. Other settings and failures:
- Zeroing the rest doesn't help:
Retry(total=3, connect=0, read=0, redirect=0, status=0)still went 4 times through the HTTP proxy. - With
total=Noneno retry counter limits it:Retry(total=None, connect=3, read=3)was still re-sending there after 15 s, at 374 to 641 receipts, when we stopped it. - A read timeout or a partial status line stayed at 1 on every path. A
100 Continueand then a close still went 4 times through the HTTP proxy. - A generator body can't be rewound, so its 3 re-sends were empty POSTs.
- A delivered PUT or DELETE went 4 times on every path, by design.
- With POST in
allowed_methods,other=0stops only the HTTP proxy re-send: through SOCKS5 and with no proxy it still went 4 times. - A loop of your own that catches
requests.exceptions.ConnectionErrorre-sends after a drop like this, becauseProxyErrorandSSLErrorare subclasses of it.
Versions: Requests 2.34.2, urllib3 2.8.0, HTTPX 0.28.1, aiohttp 3.14.5 and curl 8.18.0 on Python 3.14.4 with OpenSSL 3.5.5. urllib3 1.26.20 and main at commit c5347bb gave the same urllib3 counts, and every release from 1.25.1 to 2.8.0 gave 4 in a separate sweep (other needs 1.26). HTTPX passes retries to its direct connection pool only, so it does nothing through a proxy.
On loopback (tables), tinyproxy, Squid, gost, proxy.py, mitmproxy in tunnel mode, mihomo, sing-box and Xray gave 4 receipts in 351 of 351 calls across HTTPAdapter(max_retries=3), Retry(total=3) and Retry(total=3, allowed_methods={"GET"}). Dante and every SOCKS5 port gave 1. When the first attempt was delivered and each retry died in its handshake, those eight ended on SSLEOFError with 1 receipt (78 of 78). A retry can also succeed and duplicate: with the proxy killed after delivery and restarted, Retry(total=3, allowed_methods={"GET"}, backoff_factor=1) returned HTTP 200 while the target held the POST twice (23 of 23, on those eight plus TLS-inspecting mitmproxy). None of it says how often this happens on a real path, or whether any commercial service behaves this way.
Whether upstream intends this is open, so plan for what the installed code does. Requests documents that max_retries "applies only to failed DNS lookups, socket connections and connection timeouts, never to requests where data has made it to the server", and these counts contradict that. urllib3 warns of its other counter: "These errors might be raised after the request was sent to the server, so the request might have side-effects."
The mechanism: behind an HTTP proxy this drop arrives as ProxyError, which counts as other, and among errors urllib3 checks the method only for read errors, so allowed_methods is never consulted. Through SOCKS5 or with no proxy the same drop is a ProtocolError, a read error, and POST stops. On urllib3 2.x ProxyError appears because http.client closes the connection first and close() clears the flag urllib3 uses to tell proxy errors from tunnel errors: keeping the flag in a local patch took this POST from 4 receipts to 1. urllib3 1.26 wraps any socket error behind a proxy as ProxyError. On 5 November 2026 we found no maintainer ruling in either project's tracker. A related thread is urllib3 #1744, where maintainers called a ProxyError hiding a connect failure from Retry a bug and fixed the connect side.
curl re-sends a POST in other ways too:
- Two URLs in one invocation, with the POST lost on the reused connection: 2 receipts with no retry flag on loopback (52 of 52 calls). TLS-inspecting mitmproxy stayed at 1, and so did a single HTTP/1.1 URL in a fresh process with no
--retry. If the fresh connection then dies in the handshake, the last line iscurl: (35)with the POST already at the target. - A long-lived libcurl client: a
curl_cffi0.17.0 session with no retry option did the same on the fixture, on all three paths. --retryalone on a timeout or a transient status:--retry 2on exit 28 after delivery gave 3 receipts on the fixture and--retry 3on 408, 429, 500, 502, 503 or 504 gave 4 with exit 0. A TLS-inspecting proxy can turn the drop into that 502.
Fixes and what they cost
If you can't look up or de-duplicate the POST at the target, use no retries or Retry(total=3, other=0), and add HandshakeEOFRetry only if you need retries through an outage.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(total=3, other=0)))With urllib3 used directly, pass retries=False or retries=Retry(total=3, other=0) to the request. retries=False also stops it following redirects.
| Fix | What it stops | Cost |
|---|---|---|
| No automatic retry on non-idempotent calls: keep them on a session with no mounted retries, as RFC 9110 section 9.2.2 asks | Every re-send except a followed 307 or 308, which also needs allow_redirects=False | You handle each failure yourself |
Retry(total=3, other=0) | The POST re-send after a drop or a corrupted record, on every path | Also stops retrying a handshake EOF (1 CONNECT and a failure, where Retry(total=3) took 3 and got a 200) and a delivered GET behind an HTTP proxy. Through SOCKS5 or with no proxy that GET still went 4 times |
| Idempotency keys | The effect of a duplicate | Works only where the endpoint implements the key. See what a key does and doesn't do |
HandshakeEOFRetry(..., other=0) | Every POST re-send except after a handshake that died or a connect that failed, and a followed 307 or 308, which also needs allow_redirects=False. If you add POST to allowed_methods, it re-sends through SOCKS5 and with no proxy | Doesn't retry a stalled handshake or a CONNECT the proxy refuses or drops. It relies on a private urllib3 method: if a later version stops calling it, handshakes stop being retried with no warning, and no duplicates follow. Rerun the fixture when you upgrade |
retry_handshake_eof.py tests where the error was raised (do_handshake), not what it says. Recorded on 5 November 2026 in 15 pairings of OpenSSL 1.1.1s to 4.1.0, LibreSSL 2.8.3 and urllib3 1.26.20 to main, it recovered from handshake failures and its retries never re-sent a POST after delivery.
In your own retry loop, died_in_handshake(error) from the same file tests the last attempt only, so call it with no mounted retries and allow_redirects=False. On a default session a 303 or 307 whose next hop died in the handshake made it return True with the POST at the first URL, and a loop that re-sent on True put it there 3 times on the fixture.
Simpler tests fail. Matching the SSLEOFError class sent a cut upload 4 times on OpenSSL 1.1.1s and 3.0.13 and on PyPy. Matching UNEXPECTED_EOF never retried on LibreSSL, OpenSSL 1.1.1s or Python 3.9.25.
SOCKS5 isn't a way out. Clean closes and resets stayed at 1, but a corrupted TLS record after a complete POST went 4 times with Retry(total=3), through SOCKS5 and with no proxy, because it counts as other. A cut upload also reached the target more than once on some builds, as headers with no complete body. Delivery stays unknown through SOCKS5 too, and not every proxy offers it.
A retry that waits
On the fixture Retry(total=3) spent its 4 attempts in under 10 ms, all inside one outage. This version uses the helper above and waits:
import requests
from requests.adapters import HTTPAdapter
from retry_handshake_eof import HandshakeEOFRetry
proxy_url = "http://USERNAME:PASSWORD@proxy.example.net:8080"
session = requests.Session()
session.trust_env = False
session.proxies = {"http": proxy_url, "https": proxy_url}
retries = HandshakeEOFRetry(total=6, other=0, backoff_factor=1)
session.mount("https://", HTTPAdapter(max_retries=retries))
response = session.post("https://example.com/price-checks", json={"sku": "A-100"}, timeout=(10, 20))
print(response.status_code)trust_env = False stops an HTTPS_PROXY variable overriding session.proxies, and it also ignores REQUESTS_CA_BUNDLE. Against a 20 s outage on the fixture this attempted at about 0, 0, 2, 6, 14 and 30 s, and the last one got a 200. A GET can use plain Retry(total=6, backoff_factor=1).
From a shell, --retry alone did not retry exit 35. To retry it, wrap curl in a loop that waits and re-sends only on exit 35 with appconnect 0. Run one URL, put -q first so ~/.curlrc can't add flags, and use no redirect flag, --retry or --tls-earlydata: with any of those, exit 35 can follow a delivered POST.
How long to wait depends on the proxy's route out, so measure your own path or ask your provider.
Target, known-good, direct: which side to look at
Set KNOWN_GOOD_URL to a small, unrelated HTTPS page you may fetch and the proxy allows. The block sends the same HEAD to the target through the proxy, to that page through the proxy, and to the target from your own address: the second line shows whether every tunnel fails, the third whether direct clients fail too.
probe() {
curl --silent --show-error --head --output /dev/null --max-time 20 \
--write-out 'connect=%{http_connect} exit=%{exitcode} appconnect=%{time_appconnect} http=%{http_code}\n' "$@"
}
compare() {
probe --noproxy '' --proxy "${PROXY_URL:?}" "${TARGET_URL:?}"
probe --noproxy '' --proxy "${PROXY_URL:?}" "${KNOWN_GOOD_URL:?}"
probe --noproxy '*' "${TARGET_URL:?}"
}
compareThe third line connects from your own address, so the target sees that address; skip the line if it shouldn't. Each cell is exit/connect/http, so connect=200 exit=35 appconnect=0.000000 http=000 is 35/200/000. All are from the fixture on three curl builds.
| Target via proxy | Known-good via proxy | Target direct | Points at |
|---|---|---|---|
| 35/200/000 | 35/200/000 | 0/000/200 | The proxy or its exit IP, or your known-good page. Open that page directly and try a second one: a dead known-good through a proxy that answers 200 first prints this row too |
| 35/200/000 | 0/200/200 | 35/000/000 | The target closes your address and the proxy's exit IP, perhaps everyone |
| 35/200/000 | 0/200/200 | 7/000/000 | The target is down, or your own route to it is blocked, and this proxy answers 200 before dialling |
| 35/200/000 | 0/200/200 | 0/000/200 | Unresolved: the target refuses the proxy's exit IP, or the route from that IP to the target fails |
Row four's two causes print the same three lines. The third line counts only if this machine may connect directly. In a sandbox or behind a corporate proxy a blocked direct route fails whatever the target does, and exit 7 there looks like row three. If you can't connect directly, or you skip the line, read the first two columns alone: known-good failing is row one, known-good passing is row four. A local client in TUN mode sets no proxy variable, and --noproxy '*' still goes through it, so stop the client for the third line (we didn't run TUN mode). All three at exit 35 has no row here: look near you first, at a firewall or your client. Through SOCKS5 read 000 for 200, and a dead target prints exit 97. If connect on the first line isn't 200 through an HTTP proxy, the tunnel never opened, so read that line in the write-out table.
A first column of 52/200/000 or 56/200/000 means the handshake finished and then the connection ended. Read the same rows with that code in place of 35. On the fixture a drop after the handshake printed 52 on curl 8.7.1 and 56 on curl 8.18.0 and 8.23.0, in the patterns of rows one, two and four.
After row one or four, go to Next step by path. After row two the proxy is cleared, so use the no-proxy list above. After row three, try the target from another network, then wait for it.
Any HTTP status counts as a response here, except that a TLS-inspecting proxy can answer for a target it couldn't reach: mitmproxy answered connect=200 exit=0 http=502 with the target's handshake dead. If all three lines pass, curl's HEAD works now, and either the fault comes and goes or your job differs from this HEAD. If the job fails every time, suspect the difference: its TLS library, its proxy setting or its request. Run ssl_eof_probe.py with the interpreter your job uses: HTTPS_PROXY="$PROXY_URL" /path/to/job/python ssl_eof_probe.py "$TARGET_URL". If that fails where curl passed, read the error it prints: a certificate error is the trust store, and an EOF points at the TLS build, as in the middlebox row below. If it passes too, look at what your job does that this HEAD doesn't: its proxy URL, its body or a long response.
If the job fails only sometimes, repeat the first two lines on a slow timer while the job runs, since a connection limit may show only under its load:
while :; do
date -u +%FT%TZ
probe --noproxy '' --proxy "${PROXY_URL:?}" "${TARGET_URL:?}"
probe --noproxy '' --proxy "${PROXY_URL:?}" "${KNOWN_GOOD_URL:?}"
sleep 30
doneWhen a line fails, stop the loop, run the third line once and read that row. Send your provider the failing line and its timestamp. Until you catch one, keep logging on so the next failure shows its earlier attempts, and retry only what wasn't sent, with a wait.
A HEAD has no body in either direction, so it passes where only uploads or long responses die: on the fixture, through a proxy that cut uploads, it gave exit=0 http=200 while a 5 MB POST failed. If your failing request or its response is large, look for a size limit at the proxy or the target.
Next step by path
| Path | Next action | Tested here |
|---|---|---|
| Sandbox or corporate egress | Check the host allow-list, the proxy's connection limit and its log: a denied host can print SSLEOFError, and a 16-connection cap printed both strings on hosts that were allowed | No. Public reports only |
| Local proxy client with rules | Read which rule matched, then try the other route: one curl line appeared on a host a rule sent direct. Then check port, scheme and stale settings | mihomo 1.19.33 on loopback: a MATCH,REJECT rule or a dead upstream node printed the string with a healthy target (128 of 128). No other client's rules |
| TLS-inspecting proxy | This string doesn't point at the root CA: a missing root prints a certificate error (CERTIFICATE_VERIFY_FAILED, curl exit 60). Ask the network team | mitmproxy only: HTTP 502 or a timeout when the upstream closed first. The string only when mitmproxy was killed, or on curl when a transfer was cut. Not ssl-bump or corporate middleboxes |
| Middlebox and a large ClientHello | Add --curves X25519 to the first curl block on a curl built with OpenSSL 3.5 or later. If only that passes, OpenSSL's larger ClientHello is tripping a device, which OpenSSL warns can happen in rare cases. A developer calls this a device bug, so raise it with whoever runs it, or its vendor | Fixture only: a proxy that closed on a ClientHello over 1,000 bytes. One report suspects this behind SSLEOFError |
| Exit IP changing, for example a mobile proxy rotating | Live tunnels can die and new ones can fail until the path settles, as the fixture scripted it. Pause, then retry only what wasn't sent, with a wait | Fixture only. No timing here applies to a real network |
| Target refuses your exit IP | Stop, or ask the site. It isn't a fault to work around: one service did this to a client's IP until its staff lifted the block | Fixture only; that report had no proxy |
| Commercial proxy | On a rotating pool each line can leave from a different exit, so use a sticky session if your provider offers one, or repeat the comparison before you trust a row. If failures rise with concurrency, lower it and compare again. Then ask your provider, with a failing write-out line and its timestamp | Nothing here shows what a service does |
The wording depends on the build
| Build | Handshake EOF prints |
|---|---|
| Python on OpenSSL 3.0.13, 3.5.5, 3.5.9, 3.6.5 and 4.1.0 | [SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol, low number |
| Python on OpenSSL 1.1.1s and LibreSSL 2.8.3 | EOF occurred in violation of protocol, low number |
| Python 3.9.25 (OpenSSL 3.5.4) | SSLZeroReturnError(6, 'TLS/SSL connection has been closed (EOF) (_ssl.c:1147)') |
| curl 8.7.1 (LibreSSL 3.3.6) | curl: (35) LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to, then the host |
| pip 26.3 | There was a problem confirming the ssl certificate: [SSL: UNEXPECTED_EOF_WHILE_READING], then the EOF text |
An untrusted chain prints CERTIFICATE_VERIFY_FAILED on Linux and certificate is not trusted on macOS instead of pip's certificate line. aiohttp 3.14.5 prints Cannot connect to host with no EOF text, and sends a delivered GET, HEAD, OPTIONS, PUT or DELETE twice and a POST or PATCH once. Node 24.22.1's fetch, in one run, printed TypeError: fetch failed with cause ECONNRESET: see Node.js fetch failed behind a proxy. Playwright wasn't run: see Fix ERR_TUNNEL_CONNECTION_FAILED.
How this was tested
ipvolt sells dedicated mobile proxies, and its Terms say rotation can briefly interrupt traffic. The tables come from a loopback fixture and from named open-source proxy software on loopback, with default settings apart from the changes its README lists. None of it shows what a commercial service runs or does.
ipvolt ran the fixture on 4 and 5 November 2026 on Ubuntu 26.04 and macOS 15.7, and the named software on 4 November on Ubuntu 26.04 (11,816 client calls against an HTTP/1.1 target). These runs leave out Windows, HTTP/3, load, a real proxy's open tunnel going silent with no FIN or RST, and a real proxy that requires a login.
Fixture files: archive, README and recorded results. Named-software files: archive and the facts behind its counts.
Pricing lists ipvolt's mobile proxies by country and carrier.
Sources & further reading
Technical references used for this guide. Check the documentation for your installed version and your provider’s supported configuration.
- Requests 2.34.2 source: the max_retries docstring in adapters.py
- urllib3 2.8.0: Retry, including the other parameter and backoff_factor
- urllib3 2.8.0 source: where Retry checks the method, in retry.py
- urllib3 2.8.0 source: the ProxyError wrap in connectionpool.py
- urllib3 1.26.20 source: the older Cannot connect to proxy text
- urllib3 main at c5347bb, 2 November 2026
- curl man page: --write-out, --retry, --retry-all-errors, --tls-earlydata and exit codes
- HTTPX 0.28.1 source: where the retries setting goes, in default.py
- aiohttp 3.14.5 source: the retry on a closed connection, in client.py
- RFC 9110: idempotent methods and automatic retries, section 9.2.2
- CPython issue #110467: EOF on a write during a large request
- UbiOps: SSLError during a large upload
- Let's Encrypt community: a blocked client IP printing this string
- urllib3 issue #3100: SSLEOFError with no shared cipher
- Python issue 45487: SSLEOFError with stricter default ciphers
- OpenSSL: SSL_OP_IGNORE_UNEXPECTED_EOF and the missing close_notify
- Microsoft Q&A: this string through a firewall
- discuss.python.org: a pip user deleting proxy values from pip.ini and the environment
- hermes-agent issue #53702: a macOS system proxy picked up by httpx
- docker/sbx-releases issue #611: a denied host surfacing as SSLEOFError
- workmux issue #255: an egress proxy's connection cap printing both strings
- lens-sandbox-core issue #87: a proxy that answers 200 before dialling
- ios_rule_script issue #1749: a host a client rule sent direct, with the curl EOF line
- OpenSSL 3.5: the larger default ClientHello and the firewall warning
- OpenSSL discussion #28729: middleboxes and split ClientHellos
- tldr.fail: buggy middleboxes, and asking the vendor for a patch
- hermes-agent issue #106384: a middlebox suspected behind SSLEOFError on OpenSSL 3.5
- CPython _ssl.c: the handshake, read and write error lines
- Python docs: urllib.request.getproxies, environment then macOS or Windows settings
- urllib3 2.8.0 source: has_connected_to_proxy, in _base_connection.py
- urllib3 issue #1744: ProxyError hiding a connect failure from Retry