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, deflate to Accept-Encoding: gzip, deflate, zstd or 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_response calls self.controller.update_cached_response(), changing the headers on disk. The Accept-Encoding in my case.
- [1] Returns the cached response.
- [2] Gets a response,
adapter.build_response calls self.controller.update_cached_response() which calls _load_from_cache which refuses to load because the headers has changed (Vary tells the answer varies on Accept-Encoding and the Accept-Encoding changed), update_cached_response returns the 304 response. build_response returns the 304 response. The user gets a 304, which is unexpected.
I spotted this while using
tox -vvv -p all, so viapip, sincepip==26.1.The issue with
tox -p allis 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:
Accept-Encoding: gzip, deflatetoAccept-Encoding: gzip, deflate, zstdor the other way around, by another process.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():Here
conditional_headersdoes a_load_from_cachewhich can succeed, let say it succeed.Then
super().send()will eventually calladapter.build_response()with a304 Not Modifiedsobuild_responsecallsself.controller.update_cached_responsewhich 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:So the 304 response is returned, up to the user. In case of
pipan exception is raised because the answer don't have aContent-Type. Butpipor not, the user just did agetwith noIf-None-Matchso he does not expect a304, either a cache hit (hopefully 200 OK) or a cache miss (hopefully 200 OK) but never a304.The chronology with two processes
It think it looks like this,
[1]and[2]denoting two distinct processes:requests.get("https://pypi.org/simple/setuptools/")requests.get("https://pypi.org/simple/setuptools/")adapter.build_responsecallsself.controller.update_cached_response(), changing the headers on disk. TheAccept-Encodingin my case.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.