Disabling 304 Not Modified
in FastAPI's StaticFiles
by Christoph Schiessl on Python and FastAPI
I recently encountered a caching problem with FastAPI's StaticFiles
sub-application and decided to write a quick article because it turned out to be quite an interesting story after some digging.
Firstly, you need to know that StaticFiles
is trying to be quite smart by supporting two approaches to HTTP caching: the Etag
header and the Last-Modified
header. If you don't remember how these work in detail, you can check out my articles about them.
- HTTP Caching with
ETag
andIf-None-Match
Headers - HTTP Caching with
Last-Modified
andIf-Modified-Since
Headers
Secondly, you must know that either is sufficient to make FastAPI's StaticFiles
respond with 304 Not Modified
. In other words, if it either determines based on the If-None-Match
header or based on the If-Modified-Since
header that caching is possible, then this is enough for StaticFiles
to short-circuit the request and respond with 304 Not Modified
.
This is a big problem for certain use cases because the ETag
and the Last-Modified
response headers are computed from the requested file's modification time, but there's one key difference. The modification time in the Last-Modified
response header must be rounded to whole seconds so that it can be formatted according to RFC 5332. However, the ETag
response header is just an MD5 hash sum; hence, there's no need for rounding — fractional seconds are taken into consideration.
This can lead to false positives when files are rapidly modified and immediately requested after modification. This is a realistic use case if you have, for instance, a static site generator with a build script that watches for file changes and some sort of auto-reloading mechanism in your browser to immediately reload files as they are being changed.
We can easily demonstrate the problem with a FastAPI application like the following:
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path
app = FastAPI()
app.mount("/", StaticFiles(directory=Path("dist"), html=True))
if __name__ == '__main__':
uvicorn.run(app=app, port=8000)
$ tree --noreport dist
dist
└── index.html
If you run this program with python app.py
, you can send HTTP requests to trigger a false-positive 304 Not Modified
response. We still need, though, a small Python program to get a file modification time on disk and format it for use in an If-Modified-Since
request header.
import os
import sys
from email.utils import format_datetime
from datetime import datetime, UTC
mtime = datetime.fromtimestamp(os.stat(sys.argv[1]).st_mtime, UTC)
print(format_datetime(mtime, usegmt=True), end="")
This script takes the path to some file as an argument, reads the file's modification time from disk, and then proceeds to use the email.utils
module to format the modification time in accordance with RFC 5322.
$ touch dist/index.html && python modification_time.py dist/index.html && echo && \
touch dist/index.html && python modification_time.py dist/index.html && echo
Sun, 19 May 2024 18:30:15 GMT
Sun, 19 May 2024 18:30:15 GMT
This already demonstrates the core problem: The file dist/index.html
was touched twice, but its RFC 5322 formatted modification time is unchanged! That said, we can go one step further and also demonstrate the problem within the context of FastAPI's StaticFiles
:
$ touch dist/index.html && \
export if_modified_since_header="If-Modified-Since: $(python modification_time.py dist/index.html)" && \
http --verbose GET http://localhost:8000/index.html "$if_modified_since_header" && \
touch dist/index.html && \
http --verbose GET http://localhost:8000/index.html "$if_modified_since_header"
GET /index.html HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
If-Modified-Since: Thu, 30 May 2024 11:51:26 GMT
User-Agent: HTTPie/3.2.2
HTTP/1.1 304 Not Modified
date: Thu, 30 May 2024 11:51:25 GMT
etag: "7df03c46a66912df39b4e071635dfc6e"
server: uvicorn
GET /index.html HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
If-Modified-Since: Thu, 30 May 2024 11:51:26 GMT
User-Agent: HTTPie/3.2.2
HTTP/1.1 304 Not Modified
date: Thu, 30 May 2024 11:51:25 GMT
etag: "7b31b0a2b807845e4e9a147e423ea40f"
server: uvicorn
The second 304 Not Modified
response is incorrect because the file has been modified (touch dist/index.html
). The reason is that the Last-Modifeid
and If-Modified-Since
headers use timestamps that are rounded to whole seconds. Hence, the headers do not precisely reflect the latest modification time of the file, which in turn causes StaticFiles
to determine that the second 304 Not Modified
response is still warranted based on the rounded timestamp. The ETag
header isn't suffering from the same problem because it's taking fractional seconds into consideration.
Workaround with Monkey Patching
The easiest solution is to turn off caching entirely in StaticFiles
with a monkey patch. This has the effect of modifying the StaticFiles
class itself, which can be problematic in cases in which your application uses StaticFiles
in multiple places. In other words, you can't turn off caching for particular instances of StaticFiles
. It's an all-or-nothing decision — either you monkey patch the class and, thereby, all of its instances, or you don't.
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path
StaticFiles.is_not_modified = lambda self, *args, **kwargs: False
app = FastAPI()
# The sub-applications mounted on `/site1` and `/site2` never respond with 304 Not Modified ...
app.mount("/site1", StaticFiles(directory=Path("dist"), html=True))
app.mount("/site2", StaticFiles(directory=Path("dist"), html=True))
if __name__ == '__main__':
uvicorn.run(app=app, port=8000)
The StaticFiles
class has a method called is_not_modified()
that returns a boolean, determining whether a 304 Not Modified
response is possible. For my monkey patch, I'm overwriting this method always to return False
(i.e., 304 Not Modified
is never possible).
Workaround with Inheritance
A far better approach to solving the problem is inheritance. It's easy to create a subclass of StaticFiles
to provide the new behavior (disabled caching), but it doesn't modify the behavior of the StaticFiles
class itself. Therefore, you can have the best of both worlds:
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path
class StaticFilesWithoutCaching(StaticFiles):
def is_not_modified(self, *args, **kwargs) -> bool:
return super().is_not_modified(*args, **kwargs) and False
app = FastAPI()
# The sub-application mounted on `/site1` never responds with 304 Not Modified,
# but the sub-application mounted on `/site2` still has the default behavior.
app.mount("/site1", StaticFilesWithoutCaching(directory=Path("dist"), html=True))
app.mount("/site2", StaticFiles(directory=Path("dist"), html=True))
if __name__ == '__main__':
uvicorn.run(app=app, port=8000)
I'm still calling the original method, though, to play it safe, just in case this method happens to have side effects.
Thank you very much for reading, and see you soon!