Published
Updated
Here's something I learned today when adding Redis backed caching to a FastAPI application.
In my day job, I currently work on a full-stack application with a React frontend and a FastAPI backend. I recently added Redis backed caching to some of the slower endpoints in our backend to give some relief to our database.
My first implementation was a decorator inspired by Django's per-view cache. A great approach if you ask me:
from django.views.decorators.cache import cache_page
@cache_page(60 * 15)
def my_view(request):
...
Since I wanted to set the Cache-Control
header to allow clients
and reverse proxies to store the cached response, I needed access
to the response: Response
argument in the decorator. I also
wanted to bypass the cache if the request's Cache-Control
header is no-cache
, so I needed access to the
request: Request
argument. In code, this is how such a
decorator can be implemented with a lot of handwaving:
from fastapi import Request, Response
from functools import wraps
def cache_view():
def decorator(view_func):
@wraps(view_func)
def decorated_view(*args, **kwargs):
request: Request = kwargs["request"]
response: Response = kwargs["response"]
# read cache-control header in request
# write cache-control header in response
cached_response = cache.get()
return cached_response
return decorated_view
return decorator
And this is how you would then add caching to a view:
@cache_view()
def my_view(request: Request, response: Response):
return []
This works fine, but it bothered me that I had to include
request
and response
in every view I wanted to add caching to
even though I'm not using those parameters in the view itself. I
asked about this in the
FastAPI discussion board,
but I did not receive a satisfying answer.
A few months have gone by, and today, when touching this part of
the app again, I ran into a project that basically implements the
same decorator I came up with in December 2022:
fastapi-cache
.
While going through the source code of fastapi-cache
, I finally
figured out how to have access to the request
and response
arguments in every view even when they are not explicitly added
as parameters to the view function.
In Python it's possible to inspect and replace a function's
signature dynamically at runtime using the
inspect
module
from the standard library. This is the technique we can leverage
here. You can also see it in action in
the source code of fastapi-cache
.
Even though we can replace a function's signature, this does not mean we can actually call the function with different arguments. You can read more about it in PEP 362
import inspect
from fastapi import Request
@cache_view()
def view_func():
return []
# first, we get the signature of the original function
signature = inspect.signature(view_func)
# second, we store parameters and keyword only
# parameters separately so we can reconstruct
# the signature at the end
parameters = []
extra_params = []
for p in signature.parameters.values():
if p.kind <= inspect.Parameter.KEYWORD_ONLY:
parameters.append(p)
else:
extra_params.append(p)
# third, we check if the request parameter is in the
# function signature. For brevity I didn't include the
# response parameter here, but the idea is the same
request_param = None
for param in signature.parameters.values():
if param.annotation is Request:
request_param = param
break
# fourth, if the request parameter is not in the function
# signature, we add it to the parameters list
if not request_param:
parameters.append(
inspect.Parameter(
name="request",
annotation=Request,
kind=inspect.Parameter.KEYWORD_ONLY,
),
)
# last we reconstruct the function signature and we
# override the original signature with the new one
parameters.extend(extra_params)
view_func.__signature__ = signature.replace(parameters=parameters)
At this point, whenever the view function is called, we have
access to request
and response
in the cache decorator.
As mentioned above, however, remember that you cannot actually
call view_func
with request
or response
arguments.
Replacing a function's signature only affects the output of
inspect.signature
. In code:
def function(arg):
return arg
# pretend this function is replacing the original
# signature to include `request` and `response`
replace_signature(arg)
# this will work
function(0)
# this will raise a TypeError
function(0, request=request, response=response)