Package: fastapi-redis-cache-hakiergrzonzo
fastapi-redis-cacheFeaturesInstallationUsageInitialize Redis@cacheDecoratorResponse HeadersPre-defined LifetimesCache KeysCache Keys Pt 2.Questions/ContributionsFeaturesCache response data for async and non-async path operation functions.Lifetime of cached data is configured separately for each API endpoint.Requests withCache-Controlheader containingno-cacheorno-storeare handled correctly (all caching behavior is disabled).Requests withIf-None-Matchheader will receive a response with status304 NOT MODIFIEDifETagfor requested resource matches header value.Installationpip install fastapi-redis-cacheUsageInitialize RedisCreate aFastApiRedisCacheinstance when your application starts bydefining an event handler for the"startup"eventas shown below:importosfromfastapiimportFastAPI,Request,Responsefromfastapi_redis_cacheimportFastApiRedisCache,cachefromsqlalchemy.ormimportSessionLOCAL_REDIS_URL="redis://127.0.0.1:6379"app=FastAPI(title="FastAPI Redis Cache Example")@app.on_event("startup")defstartup():redis_cache=FastApiRedisCache()redis_cache.init(host_url=os.environ.get("REDIS_URL",LOCAL_REDIS_URL),prefix="myapi-cache",response_header="X-MyAPI-Cache",ignore_arg_types=[Request,Response,Session])After creating the instance, you must call theinitmethod. The only required argument for this method is the URL for the Redis database (host_url). All other arguments are optional:host_url(str) — Redis database URL. (Required)prefix(str) — Prefix to add to every cache key stored in the Redis database. (Optional, defaults toNone)response_header(str) — Name of the custom header field used to identify cache hits/misses. (Optional, defaults toX-FastAPI-Cache)ignore_arg_types(List[Type[object]]) — Cache keys are created (in part) by combining the name and value of each argument used to invoke a path operation function. If any of the arguments have no effect on the response (such as aRequestorResponseobject), including their type in this list will ignore those arguments when the key is created. (Optional, defaults to[Request, Response])The example shown here includes thesqlalchemy.orm.Sessiontype, if your project uses SQLAlchemy as a dependency (as demonstrated in the FastAPI docs), you should includeSessioninignore_arg_typesin order for cache keys to be created correctly (More info).@cacheDecoratorDecorating a path function with@cacheenables caching for the endpoint.Response data is only cached forGEToperations, decorating path functions for other HTTP method types will have no effect. If no arguments are provided, responses will be set to expire after one year, which, historically, is the correct way to mark data that "never expires".# WILL NOT be cached@app.get("/data_no_cache")defget_data():return{"success":True,"message":"this data is not cacheable, for... you know, reasons"}# Will be cached for one year@app.get("/immutable_data")@cache()asyncdefget_immutable_data():return{"success":True,"message":"this data can be cached indefinitely"}Response data for the API endpoint at/immutable_datawill be cached by the Redis server. Log messages are written to standard output whenever a response is added to or retrieved from the cache:INFO:fastapi_redis_cache:| 04/21/2021 12:26:26 AM | CONNECT_BEGIN: Attempting to connect to Redis server...INFO:fastapi_redis_cache:| 04/21/2021 12:26:26 AM | CONNECT_SUCCESS: Redis client is connected to server.INFO:fastapi_redis_cache:| 04/21/2021 12:26:34 AM | KEY_ADDED_TO_CACHE: key=api.get_immutable_data()INFO:     127.0.0.1:61779 - "GET /immutable_data HTTP/1.1" 200 OKINFO:fastapi_redis_cache:| 04/21/2021 12:26:45 AM | KEY_FOUND_IN_CACHE: key=api.get_immutable_data()INFO:     127.0.0.1:61779 - "GET /immutable_data HTTP/1.1" 200 OKThe log messages show two successful (200 OK) responses to the same request (GET /immutable_data). The first request executed theget_immutable_datafunction and stored the result in Redis under keyapi.get_immutable_data(). The second requestdid notexecute theget_immutable_datafunction, instead the cached result was retrieved and sent as the response.In most situations, response data must expire in a much shorter period of time than one year. Using theexpireparameter, You can specify the number of seconds before data is deleted:# Will be cached for thirty seconds@app.get("/dynamic_data")@cache(expire=30)defget_dynamic_data(request:Request,response:Response):return{"success":True,"message":"this data should only be cached temporarily"}NOTE!expirecan be either anintvalue ortimedeltaobject. When the TTL is very short (like the example above) this results in a decorator that is expressive and requires minimal effort to parse visually. For durations an hour or longer (e.g.,@cache(expire=86400)), IMHO, using atimedeltaobject is much easier to grok (@cache(expire=timedelta(days=1))).Response HeadersA response from the/dynamic_dataendpoint showing all header values is given below:$http"http://127.0.0.1:8000/dynamic_data"HTTP/1.1 200 OKcache-control: max-age=29content-length: 72content-type: application/jsondate: Wed, 21 Apr 2021 07:54:33 GMTetag: W/-5480454928453453778expires: Wed, 21 Apr 2021 07:55:03 GMTserver: uvicornx-fastapi-cache: Hit{"message": "this data should only be cached temporarily","success": true}Thex-fastapi-cacheheader field indicates that this response was found in the Redis cache (a.k.a. aHit). The only other possible value for this field isMiss.Theexpiresfield andmax-agevalue in thecache-controlfield indicate that this response will be considered fresh for 29 seconds. This is expected sinceexpire=30was specified in the@cachedecorator.Theetagfield is an identifier that is created by converting the response data to a string and applying a hash function. If a request containing theif-none-matchheader is received, anyetagvalue(s) included in the request will be used to determine if the data requested is the same as the data stored in the cache. If they are the same, a304 NOT MODIFIEDresponse will be sent. If they are not the same, the cached data will be sent with a200 OKresponse.These header fields are used by your web browser's cache to avoid sending unnecessary requests. After receiving the response shown above, if a user requested the same resource before theexpirestime, the browser wouldn't send a request to the FastAPI server. Instead, the cached response would be served directly from disk.Of course, this assumes that the browser is configured to perform caching. If the browser sends a request with thecache-controlheader containingno-cacheorno-store, thecache-control,etag,expires, andx-fastapi-cacheresponse header fields will not be included and the response data will not be stored in Redis.Pre-defined LifetimesThe decorators listed below define several common durations and can be used in place of the@cachedecorator:@cache_one_minute@cache_one_hour@cache_one_day@cache_one_week@cache_one_month@cache_one_yearFor example, instead of@cache(expire=timedelta(days=1)), you could use:fromfastapi_redis_cacheimportcache_one_day@app.get("/cache_one_day")@cache_one_day()defpartial_cache_one_day(response:Response):return{"success":True,"message":"this data should be cached for 24 hours"}If a duration that you would like to use throughout your project is missing from the list, you can easily create your own:fromfunctoolsimportpartial,update_wrapperfromfastapi_redis_cacheimportcacheONE_HOUR_IN_SECONDS=3600cache_two_hours=partial(cache,expire=ONE_HOUR_IN_SECONDS*2)update_wrapper(cache_two_hours,cache)Then, simply importcache_two_hoursand use it to decorate your API endpoint path functions:@app.get("/cache_two_hours")@cache_two_hours()defpartial_cache_two_hours(response:Response):return{"success":True,"message":"this data should be cached for two hours"}Cache KeysConsider the/get_userAPI route defined below. This is the first path function we have seen where the response depends on the value of an argument (id: int). This is a typical CRUD operation whereidis used to retrieve aUserrecord from a database. The API route also includes a dependency that injects aSessionobject (db) into the function,per the instructions from the FastAPI docs:@app.get("/get_user",response_model=schemas.User)@cache(expire=3600)defget_user(id:int,db:Session=Depends(get_db)):returndb.query(models.User).filter(models.User.id==id).first()In theInitialize Redissection of this document, theFastApiRedisCache.initmethod was called withignore_arg_types=[Request, Response, Session]. Why is it necessary to includeSessionin this list?Before we can answer that question, we must understand how a cache key is created. If the following request was received:GET /get_user?id=1, the cache key generated would bemyapi-cache:api.get_user(id=1).The source of each value used to construct this cache key is given below:The optionalprefixvalue provided as an argument to theFastApiRedisCache.initmethod =>"myapi-cache".The module containing the path function =>"api".The name of the path function =>"get_user".The name and value of all arguments to the path functionEXCEPT for arguments with a type that exists inignore_arg_types=>"id=1".SinceSessionis included inignore_arg_types, thedbargument was not included in the cache key whenStep 4was performed.IfSessionhad not been included inignore_arg_types, caching would be completely broken. To understand why this is the case, see if you can figure out what is happening in the log messages below:INFO:uvicorn.error:Application startup complete.INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=<sqlalchemy.orm.session.Session object at 0x11b9fe550>)INFO:     127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OKINFO:fastapi_redis_cache.client: 04/23/2021 07:04:15 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7f73a0>)INFO:     127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OKINFO:fastapi_redis_cache.client: 04/23/2021 07:04:17 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7e35e0>)INFO:     127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OKThe log messages indicate that three requests were received for the same endpoint, with the same arguments (GET /get_user?id=1). However, the cache key that is created is different for each request:KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=<sqlalchemy.orm.session.Session object at 0x11b9fe550>KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7f73a0>KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7e35e0>The value of each argument is added to the cache key by callingstr(arg). Thedbobject includes the memory location when converted to a string, causing the same response data to be cached under three different keys! This is obviously not what we want.The correct behavior (withSessionincluded inignore_arg_types) is shown below:INFO:uvicorn.error:Application startup complete.INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1)INFO:     127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OKINFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(id=1)INFO:     127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OKINFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(id=1)INFO:     127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OKNow, every request for the sameidgenerates the same key value (myapi-cache:api.get_user(id=1)). As expected, the first request adds the key/value pair to the cache, and each subsequent request retrieves the value from the cache based on the key.Cache Keys Pt 2.What about this situation? You create a custom dependency for your API that performs input validation, but you can't ignore it becauseit doeshave an effect on the response data. There's a simple solution for that, too.Here is an endpoint from one of my projects:@router.get("/scoreboard",response_model=ScoreboardSchema)@cache()defget_scoreboard_for_date(game_date:MLBGameDate=Depends(),db:Session=Depends(get_db)):returnget_scoreboard_data_for_date(db,game_date.date)Thegame_dateargument is aMLBGameDatetype. This is a custom type that parses the value from the querystring to a date, and determines if the parsed date is valid by checking if it is within a certain range. The implementation forMLBGameDateis given below:classMLBGameDate:def__init__(self,game_date:str=Query(...,description="Date as a string in YYYYMMDD format"),db:Session=Depends(get_db),):try:parsed_date=parse_date(game_date)exceptValueErrorasex:raiseHTTPException(status_code=400,detail=ex.message)result=Season.is_date_in_season(db,parsed_date)ifresult.failure:raiseHTTPException(status_code=400,detail=result.error)self.date=parsed_dateself.season=convert_season_to_dict(result.value)def__str__(self):returnself.date.strftime("%Y-%m-%d")Please note the__str__method that overrides the default behavior. This way, instead of<MLBGameDate object at 0x11c7e35e0>, the value will be formatted as, for example,2019-05-09. You can use this strategy whenever you have an argument that has en effect on the response data but converting that argument to a string results in a value containing the object's memory location.Questions/ContributionsIf you have any questions, please open an issue. Any suggestions and contributions are absolutely welcome. This is still a very small and young project, I plan on adding a feature roadmap and further documentation in the near future.