I'd like to display PDFs in a Webapp served by Fastapi. The files are fetched in Javascript. Simplyfied code:
fetch('/files/', {method: "POST", body: JSON.stringify(myFilePathString),cache: "no-cache"})
.then(function(response) { return response.blob(); })
.then(function(blob) { myDomElement.src = URL.createObjectURL(blob); });
Problem: The browser always reloads the files and never uses cached files. With 'cache: "no-cache"' I would expect the browser to verify if the recently loaded file is up to date and take that one. The ETag in the header stays unchanged when reloading the file. Obviously, the created URL from the BLOB changes on every reload. On my understanding, this could be the reason. Is there a way to use the browser cache with this mechanism anyhow?
This mainly needs understanding some HTTP details.
At the client, you have to set the If-None-Match
header in your request to the current ETag value.
At the server, compare the ETag value passed in the If-None-Match
with the current ETag value of the file. If they are the same, return a 304 Not Modified status, without a body. This tells the browser to use the cached version. If they are different, this means the file is changed and you need to return the new file.
You can understand more about this mechanism from the MDN Web Docs
Here is a working example in FastAPI that implements the backend part:
import hashlib
import os
import stat
import anyio
from fastapi import Body, FastAPI, Header
from fastapi.responses import Response, FileResponse
from pydantic import FileUrl
app = FastAPI()
async def get_etag(file_path):
try:
stat_result = await anyio.to_thread.run_sync(os.stat, file_path)
except FileNotFoundError:
raise RuntimeError(f"File at path {file_path} does not exist.")
else:
mode = stat_result.st_mode
if not stat.S_ISREG(mode):
raise RuntimeError(f"File at path {file_path} is not a file.")
#calculate the etag based on file size and last modification time
etag_base = str(stat_result.st_mtime) + "-" + str(stat_result.st_size)
etag = hashlib.md5(etag_base.encode()).hexdigest()
return etag
@app.post("/file/")
async def get_file(file_url: FileUrl = Body(),
if_none_match: str | None = Header(default=None)):
print(file_url.path)
file_etag = await get_etag(file_url.path)
if if_none_match == file_etag:
return Response(status_code=304)
else:
return FileResponse(file_url.path)
The get_etag
function implementation is inspired by how FastAPI (actually Starlette) calculates it in their source code to return it in the FileResponse
Etag header. You can replace the get_etag
function with your method of calculating the ETag.
The example uses a POST request passing the file path in a JSON body because you did that in your Javascript example. However, I think it is better to use a GET request passing the file path as a path parameter. You can find out how to do this in FastAPI Tutorial
Also, I have used the FileUrl
Pydantic type to validate the passed file path. But you have to specify the URL schema as the file schema before the path. For example, pass the file path as "file:///path/to/file"
. The first 2 slashes are part of the schema. The third is the start of the path.