Skip to main content

Framework-Agnostic HTTP Caching

FastAPI users get @fastapi_cache with automatic HTTP wiring. But the core HTTP cache primitives live in inhouse.http_cache and work with any framework. Flask, Django, Starlette, even raw WSGI - same ETag, Cache-Control, and 304 logic.

Core HTTP primitives

These functions have zero framework dependencies:

  • make_weak_etag(value) - stable W/"<sha256>" from any JSON-serializable value
  • etag_matches(if_none_match, etag) - exact token match in a comma-separated If-None-Match header
  • cache_control_header(remaining_ttl, *, visibility=) - builds Cache-Control string
  • http_cache_headers(...) - builds a header dict from TTL/etag flags
  • http_cache_outcome(...) - decides 200 vs 304, returns HttpCacheOutcome

Flask example

A Flask endpoint that caches product data in-process and emits HTTP cache headers.

from flask import Flask, request, jsonify, make_response
from inhouse import MemoryStore, inhouse_cache, http_cache_outcome

store = MemoryStore(max_size=1024, default_ttl=120)
app = Flask(__name__)

@inhouse_cache(store=store, etag=True)
def load_product(product_id: int) -> dict:
...

@app.route("/api/products/<int:product_id>")
def get_product(product_id: int):
body = load_product(product_id)

cache_key = f"load_product:{product_id}" # simplified for illustration
stored_etag = store.get_etag(cache_key)

if_none_match = request.headers.get("If-None-Match")

outcome = http_cache_outcome(
body,
if_none_match=if_none_match,
remaining_ttl=store.remaining_ttl(cache_key),
stored_etag=stored_etag,
http_cache=True,
cache_visibility="public",
use_etag=True,
)

if outcome.status_code == 304:
return "", 304, dict(outcome.headers)

resp = make_response(jsonify(outcome.body), 200)
for key, value in dict(outcome.headers).items():
resp.headers[key] = value
return resp

The same http_cache_outcome that @fastapi_cache uses internally, wired to Flask's request and response objects. You get ETag/304 support and Cache-Control headers without FastAPI.

Django example

A Django view that uses the same primitives.

import json
from django.http import HttpResponse, HttpResponseNotModified
from django.views import View
from inhouse import MemoryStore, inhouse_cache, http_cache_outcome

store = MemoryStore(max_size=1024, default_ttl=120)

@inhouse_cache(store=store, etag=True)
def load_article(slug: str) -> dict:
...

class ArticleView(View):
def get(self, request, slug):
body = load_article(slug)

cache_key = f"load_article:{slug}"
stored_etag = store.get_etag(cache_key)
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")

outcome = http_cache_outcome(
body,
if_none_match=if_none_match,
remaining_ttl=store.remaining_ttl(cache_key),
stored_etag=stored_etag,
http_cache=True,
cache_visibility="public",
use_etag=True,
)

if outcome.status_code == 304:
return HttpResponseNotModified(headers=dict(outcome.headers))

response = HttpResponse(
json.dumps(outcome.body),
content_type="application/json",
status=200,
)
for key, value in dict(outcome.headers).items():
response[key] = value
return response

Same core, different framework. The HTTP semantics are identical - only the response object construction differs.

Raw ASGI example

For maximal control, wire it directly into an ASGI application.

async def cached_product_app(scope, receive, send):
if scope["type"] != "http":
return

product_id = extract_product_id(scope["path"])
body = await load_product(product_id)

cache_key = f"load_product:{product_id}"
if_none_match = extract_header(scope, "if-none-match")

outcome = http_cache_outcome(
body,
if_none_match=if_none_match,
remaining_ttl=store.remaining_ttl(cache_key),
stored_etag=store.get_etag(cache_key),
http_cache=True,
cache_visibility="public",
use_etag=True,
)

await send({
"type": "http.response.start",
"status": outcome.status_code,
"headers": [(k.encode(), v.encode()) for k, v in dict(outcome.headers).items()],
})
if outcome.body is not None:
await send({
"type": "http.response.body",
"body": json.dumps(outcome.body).encode(),
})

Framework-agnostic etag-only (no response objects)

If you only need ETag metadata without building HTTP responses, use @inhouse_cache(etag=True) directly.

@inhouse_cache(store=store, etag=True)
def load_config(key: str) -> str:
...

value = load_config("theme")
etag = store.get_etag(f"__main__.load_config:{hash_args}")
remaining = store.remaining_ttl(f"__main__.load_config:{hash_args}")

Combine these with your framework's native response helpers. The ETag is stored as a weak W/"<sha256>" string, compatible with HTTP conditional request handling.

Which approach to use

ApproachWhen to use
@fastapi_cacheYou use FastAPI/Starlette. Full automatic HTTP wiring.
@inhouse_cache(etag=True) + manual http_cache_outcomeFlask, Django, or any non-Starlette framework. One extra step.
@inhouse_cache(etag=True) onlyYou only need in-process caching with ETag metadata. Handle HTTP yourself.
Raw primitives (make_weak_etag, etag_matches, etc.)Custom protocols, non-HTTP caching, or building your own cache layer.

The core primitives are framework-agnostic by design. http_cache_outcome returns a data class with status code, headers, and body - you decide how to turn that into a response.