A simple and robust caching solution for FastAPI endpoints, fueled by the unfathomable power of Redis.
Project description
fastapi-redis-cache
Features
- Cache response data for async and non-async path operation functions.
- Lifetime of cached data is configured separately for each API endpoint.
- Requests with
Cache-Control
header containingno-cache
orno-store
are handled correctly (all caching behavior is disabled). - Requests with
If-None-Match
header will receive a response with status304 NOT MODIFIED
ifETag
for requested resource matches header value.
Installation
pip install fastapi-redis-cache
Usage
Initialize Redis
Create a FastApiRedisCache
instance when your application starts by defining an event handler for the "startup"
event as shown below:
import os
from fastapi import FastAPI, Request, Response
from fastapi_redis_cache import FastApiRedisCache, cache
from sqlalchemy.orm import Session
LOCAL_REDIS_URL = "redis://127.0.0.1:6379"
app = FastAPI(title="FastAPI Redis Cache Example")
@app.on_event("startup")
def startup():
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 the init
method. 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 aRequest
orResponse
object), 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 the
sqlalchemy.orm.Session
type, if your project uses SQLAlchemy as a dependency (as demonstrated in the FastAPI docs), you should includeSession
inignore_arg_types
in order for cache keys to be created correctly (More info).
- The example shown here includes the
@cache
Decorator
Decorating a path function with @cache
enables caching for the endpoint. Response data is only cached for GET
operations, decorating path functions for other HTTP method types will have no effect. If no arguments are provided, responses will be set to expire after 1 year, which, historically, is the correct way to mark data that "never expires".
# WILL NOT be cached
@app.get("/data_no_cache")
def get_data():
return {"success": True, "message": "this is the data you requested"}
# Will be cached
@app.get("/immutable_data")
@cache()
async def get_immutable_data():
return {"success": True, "message": "this data can be cached indefinitely"}
Response data for the API endpoint at /immutable_data
will 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 OK
INFO: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 OK
The log messages show two successful (200 OK
) responses to the same request (GET /immutable_data
). The first request executed the get_immutable_data
function and stored the result in Redis under key api.get_immutable_data()
. The second request did not execute the get_immutable_data
function, instead the cached result was retrieved and sent as the response.
If data for an API endpoint needs to expire, you can specify the number of seconds before it is deleted by Redis using the expire_after_seconds
parameter:
@app.get("/dynamic_data")
@cache(expire_after_seconds=30)
def get_dynamic_data(request: Request, response: Response):
return {"success": True, "message": "this data should only be cached temporarily"}
Response Headers
Below is the HTTP response for the /dynamic_data
endpoint. The cache-control
, etag
, expires
, and x-fastapi-cache
headers are added because of the @cache
decorator:
$ http "http://127.0.0.1:8000/dynamic_data"
HTTP/1.1 200 OK
cache-control: max-age=29
content-length: 72
content-type: application/json
date: Wed, 21 Apr 2021 07:54:33 GMT
etag: W/-5480454928453453778
expires: Wed, 21 Apr 2021 07:55:03 GMT
server: uvicorn
x-fastapi-cache: Hit
{
"message": "this data should only be cached temporarily",
"success": true
}
- The
x-fastapi-cache
header field indicates that this response was found in the Redis cache (a.k.a. aHit
). - The
expires
field andmax-age
value in thecache-control
field indicate that this response will be considered fresh for 29 seconds. This is expected sinceexpire_after_seconds=30
was specified in the@cache
decorator. - The
etag
field is an identifier that is computed by converting the response data to a string and applying a hash function. If a request containing theif-none-match
header is received, theetag
value will be used to determine if the requested resource has been modified.
If this request was made from a web browser, and a request for the same resource was sent before the cached response expires, the browser would automatically serve the cached version and the request would never even be sent to the FastAPI server.
Similarly, if a request is sent with the cache-control
header containing no-cache
or no-store
, all caching behavior will be disabled and the response will be generated and sent as if endpoint had not been decorated with @cache
.
Cache Keys
Consider the /get_user
API route defined below. This is the first path function we have seen where the response depends on the value of an argument (user_id: int
). This is a typical CRUD operation where user_id
is used to retrieve a User
record from a SQLAlchemy database.
@app.get("/get_user", response_model=schemas.User)
@cache(expire_after_seconds=3600)
def get_item(user_id: int, db: Session = Depends(get_db)):
return db.query(models.User).filter(models.User.id == user_id).first()
The API route also includes a dependency that injects a Session object (db
) into the function, per the instructions from the FastAPI docs. This is used to query the database for the User
corresponding to the user_id
value.
In the Initialize Redis section of this document, the FastApiRedisCache.init
method was called with ignore_arg_types=[Request, Response, Session]
. Why is it necessary to include Session
in this list?
Before we can answer that question, we must understand how a cache key is created. In order to create a unique identifier for the data sent in response to an API request, the following values are combined:
- The optional
prefix
value provided as an argument to theFastApiRedisCache.init
method ("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 function EXCEPT for arguments with a type that exists in
ignore_arg_types
("user_id=?"
).
Therefore, all response data for the /get_user
endpoint will have a cache key equal to "myapi-cache:api.get_user(user_id=?)"
(e.g., for user_id=1
, the cache key will be "myapi-cache:api.get_user(user_id=1)"
).
Even though db
is an argument to the path function, it is not included in the cache key because it is a Session
type. If Session
had not been included in the ignore_arg_types
list, 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(user_id=1,db=<sqlalchemy.orm.session.Session object at 0x11b9fe550>)
INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK
INFO:fastapi_redis_cache.client: 04/23/2021 07:04:15 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7f73a0>)
INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK
INFO:fastapi_redis_cache.client: 04/23/2021 07:04:17 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7e35e0>)
INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK
The log messages indicate that three requests were received for the same endpoint, with the same arguments (GET /get_user?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(user_id=1,db=<sqlalchemy.orm.session.Session object at 0x11b9fe550>
KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7f73a0>
KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db=<sqlalchemy.orm.session.Session object at 0x11c7e35e0>
The value of each argument is added to the cache key by calling str(arg)
. The db
object 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 (with Session
included in ignore_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(user_id=1)
INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK
INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(user_id=1)
INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK
INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(user_id=1)
INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK
Questions/Contributions
If 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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Hashes for fastapi-redis-cache-0.1.3.tar.gz
Algorithm | Hash digest | |
---|---|---|
SHA256 | 24273181c26126173d5e89515110c07179b645a262ceb80d89070210262fe214 |
|
MD5 | cde73bfc64f9a6e13692a1e45d15e1ac |
|
BLAKE2b-256 | 9153eb030ff3f0cc7c809ef962fbe4db8e21f4b4960122a85fd6415ff33cc380 |
Hashes for fastapi_redis_cache-0.1.3-py3-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | 4a4a7e13f10c04629d113d6e080af33cbf94432b646c44d2365ca0dedbe72ae2 |
|
MD5 | f48b230718fe06aad60c265b6d53d7da |
|
BLAKE2b-256 | 7921e37bde938bd59ec09e5a10d127a84c897070a1f2d47d868c702f95e1cf50 |