Repository navigation
Race condition in send() #424
Description
I spotted this while using tox -vvv -p all, so via pip, since pip==26.1.
The issue with tox -p all is that it can run multiple versions of Python (in my config from 3.10 to 3.14), in parallel, I bet some with zstd some without.
TL;DR:
- pip wants to query /simple/setuptools/
- Cache is checked, an etag is found in the cache.
- The HTTP request with an If-None-Match is sent.
- In the meantime the cache gets modified, from
Accept-Encoding: gzip, deflatetoAccept-Encoding: gzip, deflate, zstdor the other way around, by another process. - The HTTP response arrives, it's a
304 Not Modified, the cache has to be updated, but this time it does no longer loads, and the 304 itself is returned.
In details:
It all starts in those two lines of adapter.send():
request.headers.update(self.controller.conditional_headers(request))
resp = super().send(request, stream, timeout, verify, cert, proxies)
Here conditional_headers does a _load_from_cache which can succeed, let say it succeed.
Then super().send() will eventually call adapter.build_response() with a 304 Not Modified so build_response calls self.controller.update_cached_response which runs another _load_from_cache, which can fail if the cache was modified by another process. Let's say it fails it runs this code:
if not cached_response:
# we didn't have a cached response
return response
So the 304 response is returned, up to the user. In case of pip an exception is raised because the answer don't have a Content-Type. But pip or not, the user just did a get with no If-None-Match so he does not expect a 304, either a cache hit (hopefully 200 OK) or a cache miss (hopefully 200 OK) but never a 304.
The chronology with two processes
It think it looks like this, [1] and [2] denoting two distinct processes:
- [1] runs a
requests.get("https://pypi.org/simple/setuptools/") - [2] runs a
requests.get("https://pypi.org/simple/setuptools/") - [1] Read the cache, extract an ETag from the cache
- [2] Read the cache, extract an ETag from the cache
- [1] Sends the HTTP request with If-None-Match
- [2] Sends the HTTP request with If-None-Match
- [1] Gets a response,
adapter.build_responsecallsself.controller.update_cached_response(), changing the headers on disk. TheAccept-Encodingin my case. - [1] Returns the cached response.
- [2] Gets a response,
adapter.build_responsecallsself.controller.update_cached_response()which calls_load_from_cachewhich refuses to load because the headers has changed (Varytells the answer varies onAccept-Encodingand theAccept-Encodingchanged),update_cached_responsereturns the 304 response.build_responsereturns the 304 response. The user gets a304, which is unexpected.
Activity
JulienPalard commentedon May 19, 2026
I created a reproducer
It does not always work but sometime it does:
Output from the reproducer
To help with the reproduction I added the following patch:
the patch
To log a bit and to disable the 10 minute
cache-control: max-age=600, publicprovided by pypi.org, else we have to wait 10 minutes between retries.The first column in the output is the PID, filtering the interesting lines from the output, and adding comments gives: