Skip to content

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:

pip install "velocipy[redis]"

What gets cached

The cache is conservative. A response is stored only when all of these are true:

  • The request method is GET, HEAD, or QUERY.
  • The handler completed without raising.
  • The status code is 2xx (or inside cacheable_statuses if you override it).
  • The response body is bytes or str.
  • The response is not a FileResponse or StreamingResponse.
  • 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.

key = await cache.build_key(request, policy)
await cache.delete(key)
await cache.close()

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, or Vary response 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 or invalidate_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.