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
response: Response argument in the decorator. I also
wanted to bypass the cache if the request's
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
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:
While going through the source code of
fastapi-cache, I finally
figured out how to have access to the
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
from the standard library. This is the technique we can leverage
here. You can also see it in action in
the source code of
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
response in the cache decorator.
As mentioned above, however, remember that you cannot actually
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)