http.server Silently Drops a Valid Request on a Transient None (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, and already confirmed to collapse to b'' through io.BufferedReader.readline(). We measured that BaseHTTPRequestHandler.handle_one_request() reads the request line with exactly one call -- self.raw_requestline = self.rfile.readline(65537) -- and then checks if not self.raw_requestline: self.close_connection = True; return. That check exists to detect a client that closed the connection before sending anything. A collapsed transient None looks identical: the server marks the connection closed and returns, with no exception, no error response, and no log line -- silently dropping a request that was valid and complete one read call away.

1. A real, valid HTTP request behind one transient None

import http.server, io

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 request
        return 0                   # real EOF after that

class DummyHandler(http.server.BaseHTTPRequestHandler):
    def setup(self): pass
    def finish(self): pass
    def do_GET(self):
        self.handled = True
        self.send_response(200)
        self.end_headers()

payload = b"GET /hello HTTP/1.1\r\nHost: example.com\r\n\r\n"
raw = FlakyRaw(payload)
br = io.BufferedReader(raw)

h = DummyHandler.__new__(DummyHandler)
h.rfile = br
h.wfile = io.BytesIO()
h.close_connection = False
h.handled = False
h.server = None
h.client_address = ('127.0.0.1', 12345)
h.request_version = 'HTTP/1.1'
h.handle_one_request()

print("close_connection:", h.close_connection)
print("handled a real request:", h.handled)
print("response bytes written:", h.wfile.getvalue())
print("raw calls made:", raw.calls)
close_connection: True
handled a real request: False
response bytes written: b''
raw calls made: 1

Measured: the request is valid and complete, one read call away, and only one raw read happened. handle_one_request() treated the collapsed empty readline() result exactly like a client that disconnected before sending anything -- it set close_connection = True and returned, with no exception raised anywhere and nothing written back to the client. There is nothing here for a try/except around request handling to catch, because nothing failed from the server's point of view.

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

def readline_retrying_on_none(buffered_reader, size=65537):
    while True:
        line = buffered_reader.readline(size)
        if line == b'' and buffered_reader.peek(1) != b'':
            continue   # collapsed None, not real EOF -- retry
        return line

raw2 = FlakyRaw(payload)
br2 = io.BufferedReader(raw2)
h2 = DummyHandler.__new__(DummyHandler)
h2.rfile = br2
h2.wfile = io.BytesIO()
h2.close_connection = False
h2.handled = False
h2.server = None
h2.client_address = ('127.0.0.1', 12345)
h2.request_version = 'HTTP/1.1'
h2.raw_requestline = readline_retrying_on_none(h2.rfile)
if h2.parse_request():
    h2.do_GET()
print("handled a real request:", h2.handled)
print("response bytes written:", h2.wfile.getvalue())
handled a real request: True
response bytes written: b'HTTP/1.0 200 OK\r\nServer: BaseHTTP/0.6 Python/3.13.5\r\nDate: ...\r\n\r\n'

Measured: distinguishing a collapsed transient None from a genuine client disconnect -- by checking whether more data is actually available with peek() -- before giving up lets the server process the same request correctly. If a server built on BaseHTTPRequestHandler occasionally drops requests with no error and no log entry under load, and the socket involved is non-blocking, this silent collapse is a plausible cause worth ruling out.

Frequently asked questions

Is this documented behavior?

http.server's documentation describes handle_one_request() as reading and dispatching a single HTTP request; it says nothing about None or non-blocking sockets. We measured that the existing not self.raw_requestline check -- meant only to detect a genuine client disconnect -- can't distinguish that from a collapsed transient None, since both look like an empty readline() result.

Does the standard socketserver.TCPServer hit this in practice?

Not with its default blocking sockets -- a blocking socket's readinto() simply waits for data rather than returning None. This applies specifically to servers built on BaseHTTPRequestHandler over a socket that has been put in non-blocking mode, or wrapped in a custom non-blocking RawIOBase, which is a real but deliberate setup choice, not the out-of-the-box default.

Is this worse than the client-side findings in this family?

In one sense, yes: the already-confirmed http.client/smtplib/poplib findings at least raise an exception a caller can catch and log. Here, nothing raises -- the server just quietly closes the connection and moves on, which means there is no exception, traceback, or log line marking the dropped request at all.

Is this related to the other None/EOF findings?

Yes -- it's a direct, more severe consequence of the already-confirmed io.BufferedReader.readline() None-to-b'' collapse (the same mechanism behind the http.client/smtplib/poplib trio), but applied on the server side of a connection instead of the client side, where the existing disconnect-detection logic has no way to tell a transient retry signal from a real goodbye.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in http.server.BaseHTTPRequestHandler.handle_one_request(), feeding it a genuinely valid, complete HTTP/1.1 GET request through an io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once before returning the real request bytes. PlainHeaders is our extension; Python's http.server and io modules are built in.