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
ETagandIf-None-MatchHeaders - HTTP Caching with
Last-ModifiedandIf-Modified-SinceHeaders
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!