smtplib.getreply() Mistakes None for SMTPServerDisconnected (tested)

A non-blocking raw stream signals "no data ready yet" by returning None from readinto() -- distinct from the empty read that means real end-of-stream. We measured that smtplib.SMTP.getreply() reads the server's response line with self.file.readline(), and io.BufferedReader.readline() itself collapses that transient None into b'' -- indistinguishable from a socket that closed before sending anything. getreply() sees the empty line and raises smtplib.SMTPServerDisconnected: Connection unexpectedly closed, even though a complete, valid 250 OK reply is sitting one read call away and the connection was never actually closed.

1. A real, complete SMTP reply behind one transient None

import io, smtplib

class FlakyRaw(io.RawIOBase):
    def __init__(self, payload):
        self.calls = 0
        self.sent = False
        self.payload = payload
    def readable(self):
        return True
    def readinto(self, b):
        self.calls += 1
        if self.calls == 1:
            return None            # transient: no data ready yet
        if not self.sent:
            self.sent = True
            n = len(self.payload)
            b[:n] = self.payload
            return n               # the real, complete reply
        return 0                   # real EOF after that

class FakeSocket:
    def __init__(self, payload):
        self.raw = FlakyRaw(payload)
        self.br = io.BufferedReader(self.raw)
    def makefile(self, mode, *a, **kw):
        return self.br
    def close(self):
        pass

payload = b"250 OK\r\n"
sock = FakeSocket(payload)
smtp = smtplib.SMTP.__new__(smtplib.SMTP)
smtp.sock = sock
smtp.file = None
smtp.debuglevel = 0
try:
    code, resp = smtp.getreply()
    print("code:", code, "resp:", resp)
except Exception as e:
    print("CRASHED:", type(e).__name__, e)
print("raw calls made:", sock.raw.calls)
CRASHED: SMTPServerDisconnected Connection unexpectedly closed
raw calls made: 1

Measured: the reply is valid and complete, one read call away. getreply() made exactly one read call, got back an empty line, and concluded the server closed the connection -- a conclusion that looks like a real network failure but is actually just an unhandled non-blocking retry signal.

2. The fix: drain into a real buffer first, retrying on None

def drain_retrying_on_none(raw_stream, chunk_size=65536):
    out = io.BytesIO()
    br = io.BufferedReader(raw_stream)
    while True:
        chunk = br.read(chunk_size)
        if chunk is None:
            continue              # retry: not ready yet, not EOF
        if not chunk:
            break                  # real EOF
        out.write(chunk)
    out.seek(0)
    return out

class FakeSocket2:
    def __init__(self, fileobj):
        self.fileobj = fileobj
    def makefile(self, mode, *a, **kw):
        return self.fileobj
    def close(self):
        pass

raw2 = FlakyRaw(payload)
safe_buf = drain_retrying_on_none(raw2)
sock2 = FakeSocket2(safe_buf)
smtp2 = smtplib.SMTP.__new__(smtplib.SMTP)
smtp2.sock = sock2
smtp2.file = None
smtp2.debuglevel = 0
code2, resp2 = smtp2.getreply()
print("code:", code2, "resp:", resp2)
code: 250 resp: b'OK'

Measured: resolving the None/EOF distinction before the bytes reach getreply() parses the reply code and text correctly, with no spurious disconnection.

Frequently asked questions

Is this documented behavior?

The smtplib documentation for getreply() says it raises SMTPServerDisconnected when end-of-file is reached; it does not mention non-blocking sockets or a None read result. We measured that a None-returning source triggers the identical exception with no actual disconnection involved.

Why does this look like a real network problem?

Because SMTPServerDisconnected is also the genuine exception for an actually-closed connection or a dropped network link, there is no way to distinguish the two causes from the exception alone. If you see this on a socket you know is still open (e.g. a non-blocking socket you're polling), suspect a transient None read instead of a real disconnection.

Is this the same root cause as the http.client finding?

Yes -- we traced it to the identical mechanism: io.BufferedReader.readline() collapses a transient None into b'', and both http.client.HTTPResponse.begin() and smtplib.SMTP.getreply() treat an empty readline() result as proof the connection closed. Any stdlib network client built the same way around sock.makefile('rb').readline() is a plausible candidate for the same bug.

Is this related to the other None/EOF findings?

Yes -- same family already confirmed in shutil.copyfileobj, gzip/bz2/lzma, tarfile/zipfile, TextIOWrapper.readline(), ElementTree.parse(), tomllib.load(), email.message_from_binary_file(), plistlib.load(), wave.open(), and http.client.HTTPResponse: a correctly-returned non-blocking None gets mishandled one layer above io itself.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in smtplib.SMTP.getreply(), reading a genuinely complete, valid SMTP reply through a fake socket whose makefile() returns an io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once before returning the real reply bytes. PlainHeaders is our extension; Python's smtplib and io modules are built in.