Conditional View Processing¶
HTTP clients can send headers telling the server about cached copies of resources they already have. When the resource has not changed, the server can respond with 304 Not Modified instead of sending the full response body, saving bandwidth and improving perceived performance.
HyperDjango supports conditional processing through two mechanisms:
- CacheableMixin on REST ViewSets -- automatic ETag and Cache-Control for API responses
- Response-level headers -- manual ETag and Last-Modified on any response
How Conditional Requests Work¶
The flow for a conditional GET request:
- Client requests
/api/posts/for the first time. - Server responds with the full body, plus an
ETagheader (e.g.,W/"a1b2c3d4..."). - Client caches the response and the ETag.
- On next request, client sends
If-None-Match: W/"a1b2c3d4...". - Server computes the ETag for the current content. If it matches, it returns
304 Not Modifiedwith no body. - If content has changed, the server returns the full new response with a new ETag.
The same mechanism works with Last-Modified / If-Modified-Since for time-based validation.
CacheableMixin (REST Framework)¶
The simplest way to add conditional processing to API endpoints is the CacheableMixin on a ViewSet. It automatically computes ETags from response content and handles If-None-Match validation.
from hyperdjango.rest import CacheableMixin, ModelViewSet, ModelSerializer
class PostSerializer(ModelSerializer):
class Meta:
model = Post
fields = ["id", "title", "body", "updated_at"]
class PostViewSet(CacheableMixin, ModelViewSet):
serializer_class = PostSerializer
queryset = Post
# Cache-Control configuration
cache_max_age = 60 # Cache-Control: max-age=60
cache_private = True # Cache-Control: private (default)
cache_no_cache = False # If True, Cache-Control: no-cache
How CacheableMixin Works¶
CacheableMixin is applied automatically on list() and retrieve() actions:
- The response body is serialized as normal.
- A weak ETag is computed from the SHA-256 hash of the response bytes:
W/"<first 32 hex chars>". - The
Cache-Controlheader is built fromcache_max_age,cache_private, andcache_no_cache. - If the request includes
If-None-Matchand the ETag matches, a304 Not Modifiedresponse is returned with no body.
Cache-Control Configuration¶
The three class attributes control the Cache-Control header:
| Attribute | Default | Description |
|---|---|---|
cache_max_age |
0 |
Seconds the response can be cached. 0 means no max-age directive. |
cache_private |
True |
True = private (only browser cache). False = public (CDN/proxy cache). |
cache_no_cache |
False |
True = no-cache (forces revalidation on every request). |
Examples of generated headers:
# cache_max_age=0, cache_private=True, cache_no_cache=False
Cache-Control: private
# cache_max_age=300, cache_private=False
Cache-Control: public, max-age=300
# cache_no_cache=True
Cache-Control: no-cache, private
304 Not Modified Responses¶
When CacheableMixin detects that the client's cached version is still valid, it returns a minimal 304 response:
# Client sends:
# GET /api/posts/ HTTP/1.1
# If-None-Match: W/"a1b2c3d4e5f6..."
# Server checks: current ETag matches? Yes.
# Response: 304 Not Modified (no body)
This works for both GET and HEAD requests. For POST, PUT, and DELETE, ETag validation is skipped -- the full response is always returned.
Manual ETag and Cache Headers¶
For non-ViewSet routes, set headers directly on the Response:
import hashlib
from hyperdjango import HyperApp
from hyperdjango.response import Response
app = HyperApp("myapp")
@app.route("GET", "/articles/{id}")
async def get_article(request, id: int):
article = await Article.objects.get(id=id)
body = article.to_json()
# Compute ETag from content
etag = f'W/"{hashlib.sha256(body).hexdigest()[:32]}"'
# Check If-None-Match
if_none_match = request.headers.get("if-none-match", "")
if if_none_match and etag in if_none_match:
return Response(body=b"", status=304, headers={"ETag": etag})
return Response(
body=body,
status=200,
content_type="application/json",
headers={
"ETag": etag,
"Cache-Control": "private, max-age=120",
},
)
Last-Modified Headers¶
For resources with a clear modification timestamp, use Last-Modified instead of (or alongside) ETags:
from email.utils import formatdate
from calendar import timegm
@app.route("GET", "/articles/{id}")
async def get_article(request, id: int):
article = await Article.objects.get(id=id)
# Format Last-Modified as HTTP date
last_modified = formatdate(timegm(article.updated_at.timetuple()), usegmt=True)
# Check If-Modified-Since
ims = request.headers.get("if-modified-since", "")
if ims == last_modified:
return Response(body=b"", status=304, headers={
"Last-Modified": last_modified,
})
return Response.json(
article.to_dict(),
headers={
"Last-Modified": last_modified,
"Cache-Control": "public, max-age=60",
},
)
Combining ETag and Last-Modified¶
For maximum compatibility, provide both headers. Different clients and proxies may prefer one over the other:
class ArticleViewSet(CacheableMixin, ModelViewSet):
serializer_class = ArticleSerializer
queryset = Article
cache_max_age = 300
cache_private = False # Allow CDN caching
The CacheableMixin handles ETag automatically. To also set Last-Modified, override the retrieve method:
class ArticleViewSet(CacheableMixin, ModelViewSet):
serializer_class = ArticleSerializer
queryset = Article
cache_max_age = 300
async def retrieve(self, request, **kwargs):
response = await super().retrieve(request, **kwargs)
instance = await self.get_object()
last_mod = formatdate(timegm(instance.updated_at.timetuple()), usegmt=True)
response.headers["Last-Modified"] = last_mod
return response
Precondition Failed (412)¶
For write operations (PUT, DELETE), clients can send If-Match to ensure they are modifying the version they expect. If the ETag does not match, the server should return 412 Precondition Failed:
@app.route("PUT", "/articles/{id}")
async def update_article(request, id: int):
article = await Article.objects.get(id=id)
current_etag = compute_etag(article)
if_match = request.headers.get("if-match", "")
if if_match and current_etag not in if_match:
return Response(body=b'{"detail":"Precondition Failed"}', status=412)
# Proceed with update...
data = await request.json()
article.title = data["title"]
await article.save()
return Response.json(article.to_dict())
When to Use Conditional Processing¶
| Scenario | Approach |
|---|---|
| REST API list/detail endpoints | CacheableMixin on ViewSet |
| Static or rarely-changing content | Cache-Control: public, max-age=3600 |
| Frequently updated data | ETag with no-cache (always revalidate) |
| CDN-cached public pages | Cache-Control: public + ETag |
| User-specific data | Cache-Control: private + ETag |
| Write operations (PUT/DELETE) | If-Match with 412 responses |
| File downloads with known mtime | Last-Modified header |
Performance Impact¶
Conditional processing reduces bandwidth and server load:
- 304 responses skip serialization, rendering, and body transfer.
- ETag computation is fast -- SHA-256 of already-serialized bytes (nanosecond-scale for typical API responses).
- CDN offloading with
publicCache-Control can eliminate server requests entirely for cacheable content.
For API endpoints that serve large collections, combining CacheableMixin with pagination ensures that only the pages that changed need to be re-transferred.
See Also¶
- REST Framework -- full ViewSet and serializer documentation
- Performance -- query tracking and optimization
- Static Files -- static file caching with ETag and immutable headers
- Middleware -- request/response middleware pipeline