Caching¶
A velociraptor does not chase the same prey twice if it can help it. Once the kill is made, the pack remembers where the meat is. VelociPy's route response cache does the same: store the result of a safe route and serve it again without re-running the handler.
The cache is server-side. It lives in a shared Storage backend and is bound
to a route through the same cache= decorator argument used for response_model,
rate_limit, and OpenAPI metadata. GET, HEAD, and QUERY responses are cached
by default, and only when the response is safe to replay.
At a glance¶
| Gear | What it does |
|---|---|
| Backend | Shared Storage abstraction used by both cache and rate limiter. |
| Local backend | MemoryStorage for development and single-process deploys. |
| Distributed backend | RedisStorage for multi-worker deploys. |
| Policy | Cache.policy(ttl=...) returns a CachePolicy bound to the cache. |
| Key | Method + normalized path + sorted query + vary headers/cookies; for QUERY, the request body is also hashed into the key. |
| Cached methods | GET, HEAD, and QUERY by default; mutating verbs skip the cache. |
| Skipped responses | Non-2xx, streaming/file responses, background tasks, no-store. |
The simplest cache¶
Start with in-memory storage. This is perfect for development and tests.
from velocipy import VelociPy
from velocipy.cache import Cache
from velocipy.storage import MemoryStorage
app = VelociPy()
cache = Cache(storage=MemoryStorage())
@app.get("/items", cache=cache.policy(ttl=60))
async def list_items():
return {"items": ["claw", "feather"]}
The first request runs the handler. The next request for the same path, method, and vary values returns the cached response for up to 60 seconds.
Sharing storage with the rate limiter¶
Cache and Limiter both accept any Storage and apply their own key
prefixes, so a single backend instance can serve both features safely.
from velocipy.cache import Cache
from velocipy.limiter import Limiter
from velocipy.storage import MemoryStorage
storage = MemoryStorage()
cache = Cache(storage=storage)
limiter = Limiter(storage=storage)
The cache uses keys like vcache:<hash>, while the limiter uses keys like
vlimit:rl:<rule>:<identifier>. They never collide.
Redis for distributed packs¶
When multiple workers run the same app, use RedisStorage so every worker sees
the same cached responses.
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from velocipy import VelociPy
from velocipy.cache import Cache
from velocipy.storage import RedisStorage
@asynccontextmanager
async def lifespan(app: VelociPy) -> AsyncIterator[None]:
yield
await cache.close()
storage = RedisStorage(url="redis://localhost")
cache = Cache(storage=storage)
app = VelociPy(lifespan=lifespan)
@app.get("/items", cache=cache.policy(ttl=60))
async def list_items():
return {"items": []}
Close the storage
Always close the storage when the app shuts down. The example above uses a lifespan context manager to do exactly that. Without it, Redis connections may leak.
Install the Redis backend with:
What gets cached¶
The cache is conservative. A response is stored only when all of these are true:
- The request method is
GET,HEAD, orQUERY. - The handler completed without raising.
- The status code is 2xx (or inside
cacheable_statusesif you override it). - The response body is
bytesorstr. - The response is not a
FileResponseorStreamingResponse. - The response has no pending
background_tasks. - The response does not set
Cache-Control: no-store.
HEAD requests share the same cache key as GET for the same path. The stored
body is stripped before the response is returned.
Varying the cache key¶
Responses that depend on a header or cookie need separate cache entries. Use
vary and vary_cookies to include those values in the key.
@app.get("/profile", cache=cache.policy(ttl=60, vary=("x-user-id",)))
async def profile():
return {"profile": "raptor"}
Different x-user-id values now get different cached responses. The same works
for cookies:
@app.get("/dashboard", cache=cache.policy(ttl=60, vary_cookies=("session",)))
async def dashboard():
return {"dashboard": "data"}
Query-string parameters are always part of the key, sorted alphabetically.
Bypassing the cache¶
The cache is automatically disabled while dependency_overrides is active. This
keeps tests from seeing stale production cache entries when dependencies are
swapped out.
You can also clear a specific entry if you know the request, or close the entire cache to wipe in-memory state.
What is not provided¶
VelociPy's cache intentionally stays small. Two common HTTP-cache features are not included:
- Automatic cache headers. VelociPy does not emit
Cache-Control,ETag,Last-Modified, orVaryresponse headers automatically. The cache is server-side acceleration, not a client-cache negotiation layer. Set headers manually if your use case needs them. - Tag-based invalidation. Entries expire by TTL or per-key deletion. There is
no
tags=argument orinvalidate_tags(...)method.
Summary¶
| Concept | API |
|---|---|
| Cache | Cache(storage=...) |
| Policy | cache.policy(ttl=60, vary=(...), vary_cookies=(...)) |
| Storage | MemoryStorage() or RedisStorage(url=...) |
| Cached methods | GET, HEAD, and QUERY by default |
| Key parts | Method, path, sorted query, vary headers/cookies; for QUERY, the request body is also hashed into the key |
| Skip conditions | Non-2xx, streaming/file responses, background tasks, no-store, dependency overrides |
| Cleanup | cache.delete(key) or TTL expiry |
Full working example¶
See examples/caching.py for a runnable app that
caches a slow lookup, varies a route by header, and shows how dependency_overrides
bypass the cache during tests.
Caching in VelociPy stays light: declare the policy, pick the storage, and let the route handler decide what is safe to replay. No middleware, no global state, no accidental caching of mutations.