First, turns out it's normal for TCP connections to stick around for a while in TIME_WAIT after closing. I was only hitting the limit because I had set the singleShot timer to 0ms for testing.
I rewrote the example in C++. deleteLater worked as expected, and I could reproduce both the memory growth and slow quit by omitting it. (Since the memory is managed by Qt, all the reply objects had to be deleted by the QNetworkAccessManager destructor).
Interestingly, on closer examination, the slow quit does not happen in Python when deleteLater is used, but the memory growth does. So I guess the C++ object is getting deleted but there are still resources being used somewhere. This is still mysterious to me.
The fix, though, is to call setParent(None)
on the QNetworkReply. This can be done at any time, even when it is first returned from the QNetworkAccessManager. It is initially parented to the manager; changing the parent to null means Qt isn't responsible for memory management, and it will be properly handled by Python, even without using deleteLater.
(I found this hint somewhere online; unfortunately I can't find it now, or I would link it.)
Edit: I thought this worked in my testing, but my app is still leaking memory.
Edit 2: I have no leak with PyQt4 on Python 2, but I do with PySide, and with both on Python 3.