Server-side HTTP caching for ASGI applications, inspired by Django's cache framework
Project description
asgi-caches
asgi-caches
provides middleware and utilities for adding server-side HTTP caching to ASGI applications. It is powered by async-caches, and inspired by Django's cache framework.
Note: this project is in an "alpha" status. Several features still need to be implemented.
Features
- Compatible with any ASGI application (e.g. Starlette, FastAPI, Quart, etc.).
- Support for application-wide or per-endpoint caching.
- Ability to fine-tune the cache behavior (TTL, cache control) down to the endpoint level.
- Clean and explicit API enabled by a loose coupling with
async-caches
. - Fully type annotated.
- 100% test coverage.
Installation
pip install asgi-caches
Usage
We'll use this sample Starlette application equipped with an in-memory cache as a supporting example:
from caches import Cache
from starlette.applications import Starlette
app = Starlette()
cache = Cache("locmem://null", key_prefix="my-app", ttl=2 * 60)
app.add_event_handler("startup", cache.connect)
app.add_event_handler("shutdown", cache.disconnect)
Application-wide caching
To cache all endpoints, wrap the application around CacheMiddleware
:
from asgi_caches.middleware import CacheMiddleware
app.add_middleware(CacheMiddleware, cache=cache)
This middleware applies the Cache-Control
and Expires
headers based on the cache ttl
(see also Time to live). These headers tell the browser how and for how long it should cache responses.
If you have multiple middleware, read Order of middleware to know at which point in the stack CacheMiddleware
should be applied.
Per-endpoint caching
If your ASGI web framework supports a notion of endpoints (a.k.a. "routes"), you can specify the cache policy on a given endpoint using the @cached
decorator. This works regardless of whether CacheMiddleware
is present.
from starlette.endpoints import HTTPEndpoint
from asgi_caches.decorators import cached
@app.route("/users/{user_id:int}")
@cached(cache)
class UserDetail(HTTPEndpoint):
async def get(self, request):
...
Note that the decorated object should be an ASGI callable. This is why the code snippet above uses a Starlette endpoint (a.k.a. class-based view) instead of a function-based view. (Starlette endpoints implement the ASGI interface, while function-based views don't.)
Note that you can't apply @cached
to methods of a class either. This is probably fine though, as you shouldn't need to specify which methods support caching: asgi-caches
will only ever cache "safe" requests, i.e. GET and HEAD.
Disabling caching (TODO)
To disable caching altogether on a given endpoint, use the @never_cache
decorator:
from datetime import datetime
from asgi_caches.decorators import never_cache
@app.route("/datetime")
@never_cache
class DateTime(HTTPEndpoint):
async def get(self, request):
return JSONResponse({"time": datetime.now().utcformat()})
Time to live
Time to live (TTL) refers to how long (in seconds) a response can stay in the cache before it expires.
Components in asgi-caches
will use whichever TTL is set on the Cache
instance by default:
# Cache for 2 minutes by default.
cache = Cache("locmem://null", ttl=2 * 60)
(See also Default time to live in the async-caches
documentation.)
(TODO) You can override the TTL on a per-view basis using the ttl
parameter, e.g.:
import math
from starlette.responses import JSONResponse
from asgi_caches.decorators import cached
@app.route("/pi")
@cached(cache, ttl=None) # Cache forever
class Pi(HTTPEndpoint):
async def get(self, request):
return JSONResponse({"value": math.pi})
Cache control (TODO)
You can use the @cache_control()
decorator to add cache control directives to responses. This decorator will set the appropriate headers automatically (e.g. Cache-Control
).
One typical use case is cache privacy. If your view returns sensitive information to clients (e.g. a bank account number), you will probably want to mark its cache as private
. This is how to do it:
from asgi_caches.decorators import cache_control
@app.route("/accounts/{account_id}")
@cache_control(private=True)
class BankAccountDetail(HTTPEndpoint):
async def get(self, request):
...
Alternatively, you can explicitly mark a cache as public with public=True
.
(Note that the public
and private
directives are mutually exclusive. The decorator ensures that one is removed if the other is set, and vice versa.)
Besides, @cache_control()
accepts any valid Cache-Control
directives. For example, max-age
controls the amount of time clients should cache the response:
from asgi_caches.decorators import cache_control
@app.route("/weather_reports/today")
@cache_control(max_age=3600)
class DailyWeatherReport(HTTPEndpoint):
async def get(self, request):
...
Other example directives:
no_transform=True
must_revalidate=True
stale_while_revalidate=num_seconds
See RFC7234 (Caching) for more information, and the HTTP Cache Directive Registry for the list of valid cache directives (note not all apply to responses).
Order of middleware
The cache middleware uses the Vary
header present in responses to know by which request header it should vary the cache. For example, if a response contains Vary: Accept-Encoding
, a request containing Accept-Encoding: gzip
won't result in using the same cache entry than a request containing Accept-Encoding: identity
.
As a result of this mechanism, there are some rules relative to which point in the middleware stack cache middleware should be applied:
CacheMiddleware
should be applied after middleware that modifies theVary
header. For example:
from starlette.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware) # Adds 'Accept-Encoding'
app.add_middleware(CacheMiddleware, cache=cache)
- Similarly, it should be applied before middleware that may add something to the varying headers of the request. (As a contrived example, if you had a middleware that added
gzip
toAccept-Encoding
to later decompress the resulting response body, then you'd need to place this middleware beforeCacheMiddleware
.)
Debugging
If you'd like to see more of what asgi-caches
is doing, for example to investigate a bug, you can turn on debugging logs.
To do this, you can set the ASGI_CACHES_LOG_LEVEL
environment variable to one of the following values (case insensitive):
debug
: general-purpose output on cache hits, cache misses, and storage of responses in the cache.trace
: very detailed output on what operations are performed (e.g. calls to the remote cache system, computation of cache keys, reasons why responses are not cached, etc).
Note that if using Uvicorn or another logging-aware program, logs may be activated (perhaps with a different formatting) even if the environment variable is not set. (For example, Uvicorn will activate debug logs when run with --log-level=debug
.)
Example output when running with Uvicorn:
$ uvicorn debug.app:app --log-level=debug
INFO: Started server process [95022]
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Waiting for application startup.
INFO: Application startup complete.
DEBUG: cache_lookup MISS
DEBUG: store_in_cache max_age=120
INFO: 127.0.0.1:59895 - "GET / HTTP/1.1" 200 OK
DEBUG: cache_lookup HIT
INFO: 127.0.0.1:59897 - "GET / HTTP/1.1" 200 OK
Credits
Due credit goes to the Django developers and maintainers, as a lot of the API and implementation was directly inspired by the Django cache framework.
License
MIT
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
0.2.0 - 2019-11-12
Added
- Add
@cached()
decorator. (Pull #15)
0.1.1 - 2019-11-12
Added
- Add
DEBUG
andTRACE
logs. (Pull #14)
0.1.0 - 2019-11-12
Added
- Add
CacheMiddleware
. (Pull #8) - Prevent caching of responses that have cookies when the request has none. (Pull #9)
- Prevent caching of responses if the cache TTL is zero. (Pull #10)
Project details
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
File details
Details for the file asgi-caches-0.2.0.tar.gz
.
File metadata
- Download URL: asgi-caches-0.2.0.tar.gz
- Upload date:
- Size: 15.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/2.0.0 pkginfo/1.5.0.1 requests/2.22.0 setuptools/41.2.0 requests-toolbelt/0.9.1 tqdm/4.38.0 CPython/3.8.0
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | d7ebc23019232dc637bed66b902aa98c4ff81a17af78e17d4c78b724286e2656 |
|
MD5 | b0ac0a15362f215d4cb88a19df3deb23 |
|
BLAKE2b-256 | 96b14b6b13b57af2bd45e2e731831d7c7701d41f68d2e25e728f67791abd7b61 |
File details
Details for the file asgi_caches-0.2.0-py3-none-any.whl
.
File metadata
- Download URL: asgi_caches-0.2.0-py3-none-any.whl
- Upload date:
- Size: 13.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/2.0.0 pkginfo/1.5.0.1 requests/2.22.0 setuptools/41.2.0 requests-toolbelt/0.9.1 tqdm/4.38.0 CPython/3.8.0
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | bc295a5a56bdc48aaea5747d6e59295ac5d136ae04c7ebb19b67cb7dbe28e41c |
|
MD5 | 2281807740acfc11ef15ae7ed2d2210a |
|
BLAKE2b-256 | 2169f4055d2425c245d4182ff66204f948d1f3883fd209dba3e24f2e319feb8b |