xmlrpc.client ExpatError "no element found" on a Valid Response (tested)

A non-blocking raw stream signals "no data ready yet" by returning None -- distinct from the empty read that means real end-of-stream. We measured that xmlrpc.client.Transport.parse_response() reads the body with while data := stream.read(1024):, and the underlying http.client.HTTPResponse.read(1024) returns that None (and closes the connection). None is falsy, so the loop ends before feeding a single byte to the XML parser, and p.close() raises ExpatError: no element found: line 1, column 0. The error blames the server's XML, but the server sent a complete, valid reply. Nothing in it mentions sockets, reads, or retries.

1. A valid XML-RPC reply behind one transient None

import http.client, io, xmlrpc.client

class FlakyRaw(io.RawIOBase):
    """Sends headers cleanly, then one transient None mid-body, then the body."""
    def __init__(self, header_bytes, body_bytes):
        self.header_bytes, self.body_bytes, self.stage = header_bytes, body_bytes, 0
    def readable(self):
        return True
    def readinto(self, b):
        if self.stage == 0:
            n = len(self.header_bytes)
            b[:n] = self.header_bytes
            self.stage = 1
            return n
        if self.stage == 1:
            self.stage = 2
            return None            # transient: no data ready yet
        if self.stage == 2:
            n = len(self.body_bytes)
            b[:n] = self.body_bytes
            self.stage = 3
            return n               # the real, complete XML body
        return 0                   # real EOF after that

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

body = xmlrpc.client.dumps((42,), methodresponse=True).encode()
headers = b"HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: %d\r\n\r\n" % len(body)

class FlakyTransport(xmlrpc.client.Transport):
    def make_connection(self, host):
        c = http.client.HTTPConnection(host)
        c.sock = FakeSocket(FlakyRaw(headers, body))
        self._connection = host, c
        return c

proxy = xmlrpc.client.ServerProxy("http://example.com/RPC2", transport=FlakyTransport())
try:
    print(proxy.add(40, 2))
except Exception as e:
    print(type(e).__module__, type(e).__name__, e)
xml.parsers.expat ExpatError no element found: line 1, column 0

Measured: the reply is a valid methodResponse carrying 42, with a correct Content-Length, one read away. ServerProxy raised ExpatError: no element found: line 1, column 0 -- the message you would get if the server had sent an empty body. Line 1, column 0 means the parser was never fed anything.

2. Why: a truthy while loop over a read that can return None

The relevant part of the printed source:

import inspect, xmlrpc.client
print(inspect.getsource(xmlrpc.client.Transport.parse_response))
        p, u = self.getparser()

        while data := stream.read(1024):
            if self.verbose:
                print("body:", repr(data))
            p.feed(data)

        if stream is not response:
            stream.close()
        p.close()

        return u.close()

stream is the http.client.HTTPResponse. Its read(1024) returns None on the transient read and closes the connection as a side effect (see the linked HTTPResponse.read() guide). while data := ... treats None exactly like end-of-body, so the parser is closed with zero bytes fed. The real body is gone by then, so catching ExpatError and retrying the parse cannot recover it -- only a fresh request can.

3. The fix: stop the None below http.client

class RetryOnNone(io.RawIOBase):
    """Wraps a non-blocking raw stream; turns None into 'wait and retry'."""
    def __init__(self, raw):
        self.raw = raw
    def readable(self):
        return True
    def readinto(self, b):
        while True:
            n = self.raw.readinto(b)
            if n is not None:
                return n
            # real code: wait for readability here (e.g. selectors) instead of spinning

class FixedSocket(FakeSocket):
    def makefile(self, mode, *a, **kw):
        return io.BufferedReader(RetryOnNone(self.raw))

class FixedTransport(xmlrpc.client.Transport):
    def make_connection(self, host):
        c = http.client.HTTPConnection(host)
        c.sock = FixedSocket(FlakyRaw(headers, body))
        self._connection = host, c
        return c

proxy = xmlrpc.client.ServerProxy("http://example.com/RPC2", transport=FixedTransport())
print("fixed:", proxy.add(40, 2))
fixed: 42

Measured: same flaky stream, same reply, and the call returns 42. The retry has to live in the raw stream that makefile() hands to http.client, because once a None reaches HTTPResponse.read() the connection is already closed. Simpler still: if you control the socket, keep it in blocking mode (or use settimeout()) for XML-RPC, since a blocking socket never returns None.

Related: http.client.HTTPResponse.read() permanently closes the connection on None.

Frequently asked questions

Does a normal ServerProxy over a real TCP socket hit this?

Not with the default setup: Transport opens ordinary blocking sockets, and a blocking socket never returns None. You hit it when the socket or the stream that makefile() returns is non-blocking -- for example a custom Transport, an event-loop integration, or a wrapper socket that reports 'no data yet' as None.

Is the server sending bad XML?

No. In this repro the server reply is a complete, valid methodResponse with a correct Content-Length. 'no element found: line 1, column 0' means the parser received zero bytes, which here comes from the client loop stopping early, not from the server.

Can I catch ExpatError and retry the parse?

No. By the time ExpatError is raised, HTTPResponse.read() has already closed the connection and the body is lost. You can only retry the whole request, or fix the stream so it never hands None to http.client.

Does gzip-encoded XML-RPC behave differently?

With Content-Encoding: gzip, parse_response wraps the response in GzipDecodedResponse before the same while loop; we only measured the uncompressed path shown here, so test the gzip path yourself before relying on it.

Is this related to the other None/EOF findings?

Yes -- same family as json.load(), configparser.read_file(), http.server and others: a correctly-returned non-blocking None is treated as end-of-data one layer above io. This one surfaces as an XML parse error, which points debugging at the server instead of the client's socket.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in xmlrpc.client.ServerProxy and Transport, calling a method whose server reply is a complete, valid XML-RPC methodResponse with a correct Content-Length, through a custom non-blocking socket whose underlying stream returns None once, mid-body, before the real bytes. PlainHeaders is our extension; Python's xmlrpc.client, http.client and io modules are built in.