error:0A000126 unexpected eof while reading: fix the origin
error:0A000126 unexpected eof while reading: fix the origin
error:0A000126:SSL routines::unexpected eof while reading means the TLS peer sent a FIN and closed the TCP connection without the close_notify alert that ends every well-behaved TLS session. The bytes curl already received are fine — it is the missing ten-byte alert at the end, not the response, that raises the error, which is why the failure looks random: the same origin fails one client, passes another, and reports 200 in its own access log.
That asymmetry is the whole difficulty. In the lab built for this article one deliberately broken origin was probed with five clients: curl 8.14.1 exited 56 with error:0A000126, Python 3.13 returned a clean b'' from the same read, Node 26 closed with hadError=false, openssl s_client printed DONE, and Python's own HTTP layer raised http.client.IncompleteRead. Five clients, three different verdicts, one bug. If your error tracker only shows the curl jobs, you are not looking at a flaky network — you are looking at the one client that checks.
Everything below was executed on one host: arm64 Linux 6.17.0-1019-oracle, OpenSSL 3.5.6 (7 Apr 2026), CPython 3.13.5, curl 8.14.1 (libcurl/8.14.1 OpenSSL/3.5.6), Node v26.5.1. Each transcript is the unedited output of the block above it, and the origin is a 60-line Python TLS server that can close a connection four different ways, so every claim here can be re-run byte-for-byte on your own machine. No part of this article is quoted from a vendor forum.
TL;DR — what the error is, and where to look first
- The alert is missing, not the data. TLS has no length field, so a peer that disappears mid-stream is indistinguishable from a peer that finished unless it sends
close_notify(RFC 8446 §6.1, and the same rule in TLS 1.2's RFC 5246 §7.2.1). OpenSSL 3.x makes that ambiguity loud:error:0A000126. - It is a closing error, not a handshake error. The connection is established, the certificate verifies, and the HTTP status line usually arrives. curl reports the status it already parsed and then fails the body read: measured
http_code=200withexit=56. - Only a FIN triggers this string. A reset produces a completely different error:
curl: (56) Recv failure: Connection reset by peer,ConnectionResetError: [Errno 104]in Python,ERR ECONNRESETin Node. Same exit code 56, different fault, different fix. - A complete response hides the bug entirely. When the origin sends a full
Content-Lengthbody and then closes withoutclose_notify, all five clients here were satisfied: curlhttp_code=200 exit=0, NodehadError=false. Closing a dead keep-alive connection is common and harmless; this is why the error only appears on some requests. - Python's raw TLS layer told me nothing. Against the broken origin, the second
recv()returnedb''— a clean EOF, no exception — withssl.OP_IGNORE_UNEXPECTED_EOFunset in the context options. The truncation only surfaced one layer up, ashttp.client.IncompleteRead: IncompleteRead(2 bytes read, 98 more expected). - Fix it at the origin, and fix it by flushing the session, not the socket. In Python that is
sock.unwrap()beforesock.close(); in Node it issock.end()instead ofsock.destroy(). Measured: after the alert is sent,error:0A000126disappears and only the honest HTTP truncation error remains (curl: (18) end of response with 98 bytes missing).
One dead origin, five clients, three verdicts
The origin in this lab is a real TLS server: it completes the handshake, reads the request, writes a response that claims Content-Length: 100 and delivers two bytes of it, then drops the TCP connection without close_notify. That single defect produced these results, all on the same host, minutes apart:
$ curl -sS -v --cacert cert.pem https://127.0.0.1:18450/
< HTTP/1.1 200 OK
< Content-Length: 100
<
* TLSv1.3 (OUT), TLS alert, decode error (562):
* OpenSSL SSL_read: OpenSSL/3.5.6: error:0A000126:SSL routines::unexpected eof while reading, errno 0
* closing connection #0
curl: (56) OpenSSL SSL_read: OpenSSL/3.5.6: error:0A000126:SSL routines::unexpected eof while reading, errno 0
# the same run with -w "http_code=%{http_code} curl_exit=%{exitcode}" prints:
http_code=200 curl_exit=56
Read the first line of that transcript carefully, because it reorders the usual diagnosis. The HTTP status line was received and parsed — 200 — before TLS had anything to complain about. Then OpenSSL read the end of the stream, found no close_notify, and raised the error. The TLS alert, decode error (562) line one step earlier is curl's own trace of an alert being sent outward as the read fails — it is shown here only so that you do not mistake it for the error's cause.
Now the same origin, different clients:
$ python3 pclient2.py 18450 default
[default] OP_IGNORE_UNEXPECTED_EOF set after construction: False
[default] negotiated: TLSv1.3
[default] recv#1: b'HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nok' (len=42)
[default] recv#2: b'' (len=0)
$ python3 pclient3.py 18450 urllib
status: 200
[urllib] EXC chain: http.client.IncompleteRead: IncompleteRead(2 bytes read, 98 more expected)
$ node nclient.js 18450
negotiated: TLSv1.3 TLS_AES_256_GCM_SHA384
data: "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nok"
close hadError= false
$ printf 'GET / HTTP/1.0\r\n\r\n' | openssl s_client -connect 127.0.0.1:18450 \
-CAfile cert.pem -quiet -no_ign_eof
Connecting to 127.0.0.1
Can't use SSL_get_servername
depth=0 CN=lab.local
verify return:1
DONE
Four clients, four different stories about one broken origin. The raw Python socket, Node's TLS socket, and s_client all treated the missing alert as a normal end of stream. Only curl — and, one layer further up, Python's HTTP client — noticed that something was truncated. That is the practical consequence: a Go, Node or Python worker reading the same endpoint as a shell script may report success while the script fails, and the short body is the only clue.
The truncation itself is not a TLS concept: Content-Length: 100 promised 100 bytes, two arrived, the peer closed. Any client that enforces the length sees the real damage, and the TLS error is only its symptom.
Reproduce it in sixty seconds
You do not need a broken upstream or a corporate proxy to see this string. A TLS server that detaches before closing reproduces it deterministically. The essential part is how the socket is closed: the Python SSLSocket.detach() method drops the SSL layer and hands back a bare file descriptor, so close() on it sends FIN with no alert. The drain() step matters too, and is explained below it.
#!/usr/bin/env python3
# server.py (close mode "eof"): truncate the body, close TCP without close_notify
import socket, ssl, time
def drain(sock):
"""Read pending bytes first: close() on a socket with unread data sends RST,
not FIN, and RST produces a different error (ECONNRESET)."""
sock.setblocking(False)
try:
while True:
if not sock.recv(65536):
break
except (BlockingIOError, ssl.SSLWantReadError, OSError):
pass
finally:
sock.setblocking(True)
def handle(tls):
tls.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nok') # 2 of 100 bytes
time.sleep(0.2) # let the records leave the kernel
fd = tls.detach() # drops SSL: no close_notify is queued
sock = socket.socket(fileno=fd) # detach() hands back an fd, not a socket
drain(sock)
sock.close() # FIN, no close_notify
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("cert.pem", "key.pem")
srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 18450)); srv.listen(8)
while True:
raw, _ = srv.accept()
handle(ctx.wrap_socket(raw, server_side=True))
Two details in that snippet are load-bearing, and both cost me a wrong measurement before they were right. First, detach() returns an integer file descriptor, not a socket object — wrapping it in socket.socket(fileno=fd) is what makes the close send FIN rather than leave the connection open until the client times out. Second, drain() is what separates branch 1 from branch 2 below: a socket closed while unread request bytes are still queued sends a reset, and that changes the error message entirely. Generate the certificate with a SAN for localhost and 127.0.0.1 or curl will refuse the connection before TLS starts:
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3 \
-subj "/CN=lab.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
python3 server.py eof 18450 &
curl -sS -v --cacert cert.pem https://127.0.0.1:18450/
Branch 1 — a FIN without the close_notify alert
This is the branch the error names. The peer's TCP stack sends FIN because the process closed the socket, called exit() without flushing the TLS session, or was killed. The TLS layer never got the chance to enqueue its close_notify record, so the client sees end-of-stream in the middle of a record sequence.
In Python the distinction is one method call, and the effect is measurable from the other side. unwrap() performs the TLS shutdown handshake and sends close_notify; close() on a detached socket does not. Compare the two origins using the same Node client binary and the same curl invocation:
# origin A: tlsSocket.destroy() -> FIN, no close_notify (stdout first, then stderr)
$ node nodeserver.js destroy 18472 &
node origin mode=destroy port=18472 node=v26.5.1
$ curl -sS --cacert cert.pem https://127.0.0.1:18472/ -o /dev/null -w "http_code=%{http_code} exit=%{exitcode}"
http_code=200 exit=56
curl: (56) OpenSSL SSL_read: OpenSSL/3.5.6: error:0A000126:SSL routines::unexpected eof while reading, errno 0
# origin B: tlsSocket.end() -> close_notify is flushed first
$ node nodeserver.js end 18471 &
node origin mode=end port=18471 node=v26.5.1
$ curl -sS --cacert cert.pem https://127.0.0.1:18471/ -o /dev/null -w "http_code=%{http_code} exit=%{exitcode}"
http_code=200 exit=18
curl: (18) end of response with 98 bytes missing
Origin B is still broken — it still promised 100 bytes and delivered two — but the TLS complaint is gone. curl: (18) is a clean HTTP-level statement about a truncated body, and it points straight at the real defect instead of at the protocol. That is the whole reason to fix this at the origin: error:0A000126 is noise generated by a missing courtesy alert, and it buries the truncation underneath it.
Where the FIN comes from matters less than you would think. The process owning the TLS session need not be the one that wrote the response: a recycled worker on a keep-alive connection, a stopped container, an OOM kill, or an intermediate TCP hop reaping an idle connection all produce the identical client-side string. Cover only the app and it will appear.
Branch 2 — RST, not FIN: the Connection reset twin
Before you touch the origin's shutdown code, confirm which of the two teardowns you have. Both make curl exit 56, and the similarity ends there.
# same truncated response, but the socket is closed with SO_LINGER (0,0) -> RST
$ curl -sS --cacert cert.pem https://127.0.0.1:18445/ -o /dev/null -w "http_code=%{http_code} curl_exit=%{exitcode}"
http_code=200 curl_exit=56
curl: (56) Recv failure: Connection reset by peer
$ python3 pclient.py 18445
recv#1: b'HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nok'
EXC recv#2 builtins.ConnectionResetError: [Errno 104] Connection reset by peer
EXC unwrap BrokenPipeError: [Errno 32] Broken pipe
$ node nclient.js 18445
ERR ECONNRESET: read ECONNRESET
close hadError= true
Three signatures separate the branches, and none of them needs packet capture. First, the message itself: unexpected eof while reading versus Connection reset by peer. Second, the Python exception class: a reset raises ConnectionResetError from the raw socket, while a clean FIN raises nothing at all on this build. Third, Node's callback argument: hadError= true with ERR ECONNRESET versus hadError= false.
The commonest way to turn a harmless close into a reset is the one drain() exists to prevent: a socket that is closed while inbound bytes are still queued can answer with RST instead of FIN, which is why branch 2 was measured with an explicit hard close (SO_LINGER (0,0)) rather than assumed from a clean shutdown. An origin that stops reading a request body halfway — a client uploading through a 413 rejection, a request cancelled by the browser — resets rather than closes, and the error message changes class entirely. If you are chasing error:0A000126 and your captures show RST, you are on the wrong branch and no amount of close_notify work will remove it.
Branch 3 — the response was complete, so nobody complains
The most common production shape of a missing close_notify is invisible. A keep-alive connection sits idle, the origin decides to reap it, and it closes the socket abruptly because from the application's point of view the exchange finished long ago. There is nothing truncated, so there is nothing to report. Measured, with a complete two-byte body and a FIN carrying no alert:
# complete response (Content-Length: 2, both bytes sent), then FIN without close_notify
$ curl -sS --cacert cert.pem https://127.0.0.1:18446/ -o /dev/null -w "http_code=%{http_code} curl_exit=%{exitcode}\n"
http_code=200 curl_exit=0
$ node nclient.js 18446
data: "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"
close hadError= false
$ python3 pclient2.py 18446 default
[default] OP_IGNORE_UNEXPECTED_EOF set after construction: False
[default] negotiated: TLSv1.3
[default] recv#1: b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok' (len=40)
[default] recv#2: b'' (len=0)
Every client is happy at the HTTP layer, and the only trace is what Python prints when you try to shut the session down yourself afterwards:
$ python3 pclient.py 18446
client openssl: OpenSSL 3.5.6 7 Apr 2026
negotiated: TLSv1.3 TLS_AES_256_GCM_SHA384
recv#1: b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok'
recv#2: b''
recv#2: clean EOF (peer sent FIN)
EXC unwrap SSLError: [SSL: SHUTDOWN_WHILE_IN_INIT] shutdown while in init (_ssl.c:2777)
That SSLError: [SSL: SHUTDOWN_WHILE_IN_INIT] from unwrap() is the tidiest client-side fingerprint of this branch: the application tried to perform a TLS shutdown on a session the peer had already dropped. It is not fatal, it is not the reported error, and it will never reach a bug tracker — but if you are trying to prove that your origin leaks close_notify on idle reaping, writing it to a counter is a two-line change and the only instrument that will tell you.
Why is this branch harmless? Because HTTP/1.1 already carries its own framing. When Content-Length is known, or the body is chunked and the terminating 0\r\n\r\n arrived, the message is complete; TLS's job is done and the alert is a formality. RFC 8446 makes close_notify mandatory for a clean shutdown, and on the build measured here OpenSSL 3.5.6 reported nothing at all when no body bytes were pending — which is precisely why the same defect produces an error on one request and silence on the next fifty.
Why only curl reports it
The client-silence matrix is not academic — it decides which parts of your fleet you find out about. On the origin that truncates a body (branch 1), here is what each client did with the identical bytes:
client raw TLS read what the app sees
------------------------------ ----------------------------- ------------------------------------------
curl 8.14.1 (OpenSSL 3.5.6) error:0A000126 exit 56, body truncated after 2 bytes
python 3.13.5, raw ssl b'' (clean EOF, no error) nothing, unless the caller checks the length
python 3.13.5, http.client b'' http.client.IncompleteRead
python 3.13.5, urllib b'' http.client.IncompleteRead
openssl s_client (3.5.6) no error printed DONE
node v26.5.1 clean 'close', hadError=false nothing, unless the caller checks the length
------------------------------ ----------------------------- ------------------------------------------
measured against the same origin; cert verified in each case
Two consequences follow. The first is that the error is a client property as much as an origin property: the same bytes are a fatal error to one library and a routine EOF to another. The second is worse — the clients that stay silent are the ones that keep the truncated data. Python's recv() returned the 42 bytes that arrived and then b''; a service that concatenates chunks and trusts the Content-Length header will hand a 2-byte body to its caller and log a success.
Python exposes a switch for this class of event: ssl.OP_IGNORE_UNEXPECTED_EOF, documented as the way to stop treating a missing close_notify as an error. Do not reach for it to explain the silence here. On this build the flag was reported unset in a create_default_context() client, in a bare SSLContext(PROTOCOL_TLS_CLIENT), and after explicitly clearing it — and the read returned b'' in all three cases. Whatever silences the raw layer on OpenSSL 3.5.6, the honest defence is not a library flag: it is checking that the body you received matches the length you were promised, which works on every runtime regardless of which way the TLS layer votes.
Fix the origin: flush the session, then the socket
The repair is small and belongs on the side that closes first. From the two origins measured above, the difference between error:0A000126 and a clean HTTP truncation was a single method call.
# origin, Python: let TLS say goodbye before the socket goes away
body = b'...'
sock.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n' % len(body) + body)
try:
sock.unwrap() # sends close_notify, waits for the peer's
except (ssl.SSLError, OSError):
pass # the peer may already be gone; the alert is still queued
sock.close()
// origin, Node: end() flushes close_notify, destroy() drops the socket
sock.end(); // ssl alert queued behind the response
// sock.destroy(); // this is what raises error:0A000126 at the client
For WSGI and ASGI servers the alert is the framework's job, and the practical test is the one used above rather than a code review: put curl on the endpoint that fails and read the exit code. curl: (56) ... unexpected eof while reading means the alert is missing; curl: (18) end of response with N bytes missing means the session closed properly and the body itself is short, which is a bug in your handler and not in your TLS teardown.
Where you cannot change the origin — an appliance, a vendored binary, a TCP hop you do not own — the fix moves to whoever does control the close. A connection pool that reuses a socket after the peer has half-closed will keep producing this error on some fraction of requests; the mitigation is to detect the dead socket on the reuse path rather than to mask the alert. And a client-side flag that ignores missing close_notify does not repair the truncation, it just hides the only warning you had.
Make the truncation visible in your own clients
Since curl is the only client here that reports the fault by default, the second half of the fix is teaching the quiet ones to speak. The rule that works everywhere is short: believe Content-Length, and treat a peer close before the promised byte count as an error, regardless of what the TLS layer said.
def read_exact(sock, n):
"""Read exactly n bytes or fail loudly. Never returns a short buffer."""
buf = b''
while len(buf) < n:
chunk = sock.recv(min(65536, n - len(buf)))
if not chunk: # FIN, close_notify, or otherwise
raise EOFError('peer closed after %d of %d body bytes' % (len(buf), n))
buf += chunk
return buf
# for a TLS socket, this shows up as a plain EOFError instead of a silent b''
# because the raw layer returns b'' on this build and the length check does the work
# strict HTTP on the command line: any truncation is a non-zero exit
curl --fail-with-body -sS -o /dev/null -w '%{http_code} %{size_download}\n' https://host/path
# exit 56 = TLS-level truncation (missing close_notify), exit 18 = short HTTP body
# if you only ever run `curl -s`, both are invisible: -s hides -S's error text
The last comment is worth its own sentence, because it is the most common way this error stays hidden for weeks: curl -s suppresses the error text that -S would print, and a shell script that never inspects $? turns a fatal truncation into an empty string. Measured in this lab, the same invocation that printed curl: (56) ... unexpected eof while reading exits 56; the script that piped its output onward reported nothing at all.
Verifying the fix with the same three probes
Keep the three probes because they cover the three branches, and a green run on all three is what "fixed" means here: a curl fetch of a complete response (session close), a curl fetch of a deliberately truncated response (must be exit=18, not 56), and a Python socket read of a truncated response (must raise, not return a short buffer).
#!/usr/bin/env bash
# close-notify check: run against your real origin, not the lab
set -u
URL=${1:?usage: check-close-notify.sh https://host/path}
http_code=$(curl -sS --fail-with-body -o /dev/null \
-w '%{http_code} %{size_download} %{exitcode}' "$URL") && rc=0 || rc=$?
echo "$URL -> $http_code (curl exit $rc)"
case "$rc" in
0) echo "session closed cleanly" ;;
18) echo "SHORT BODY: close_notify ok, response truncated upstream" ;;
56) echo "NO CLOSE_NOTIFY: origin or an intermediate hop dropped the session" ;;
*) echo "other failure, investigate separately" ;;
esac
Run it against the lab first so you can see all three verdicts on demand: python3 server.py clean 18443 should be exit 0, eof 18450 exit 56, and a Node origin calling sock.end() on a truncated body exit 18. When all three agree with the table above on your machine, the instrument is trustworthy and any later deviation is a real change in your origin.
Field notes
- Exit code 56 is not a diagnosis.
unexpected eof while readingandConnection reset by peershare it. The message text, not the code, picks the branch — and branch 2 is not fixed by sendingclose_notify. - Check the HTTP layer before the TLS layer.
Content-Lengthversus bytes actually received tells you whether there was real damage. In branch 3 there is none, and the error is the only artefact. - Do not set
ssl.OP_IGNORE_UNEXPECTED_EOFto make a dashboard green. It suppresses the warning about a possibly truncated stream while leaving the truncation in place; the length check above is what actually protects the data. - Reproduce before you blame. A detached socket and a two-second TLS server settle in a minute what a packet-capture argument settles in an afternoon — and the reproduction is the regression test.
- Both directions matter. A near-identical string appears on servers whose clients vanish without
close_notify, and on this build the Python server's own read simply returnedb''. Count it as an operational fact rather than alerting on it. - Pin the versions you quote. Whether a missing alert is a fatal error or a silent EOF is a property of the client library's version as much as of the origin — OpenSSL 3.5.6, CPython 3.13.5, curl 8.14.1 and Node v26.5.1 are the four measured here, and the table above is only valid for the ones you actually deploy.