
Django Recommender Search Backend Patterns
- 73 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
django-recommender-search-backend-patterns is a Claude Code skill in the Backend & APIs category.
Key points
- django-recommender-search-backend-patterns
- Backend & APIs
- AI-coding skill
Django Recommender Search Backend Patterns by the numbers
- 73 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #3,071 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill django-recommender-search-backend-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 73 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with backend & apis tasks during ai-assisted development?
Helps with backend & apis tasks during AI-assisted development.
Who is it for?
Best when you're working on backend & apis and need structured help with django-recommender-search-backend-patterns.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with backend & apis tasks during ai-assisted development, or when django-recommender-search-backend-patterns is a claude code skill in the backend & apis category.
What you get
Structured output aligned to django-recommender-search-backend-patterns: django-recommender-search-backend-patterns; Backend & APIs; AI-coding skill.
Files
Experimental Django Recommender + Search Backend Best Practices
Implementation patterns for a Django backend serving mixed-results recommendations (Personalize / Databricks / microservice fan-out) and OpenSearch-backed search/feeds. 48 rules across 8 categories, ordered by execution lifecycle impact — earlier categories cascade through everything downstream.
This is the backend peer of the react-fetch-cache-patterns skill. React handles client-side waterfalls and caching; this skill handles server-side fan-out, downstream protection, OpenSearch query design, and ML-blend orchestration.
When to Apply
- Building or reviewing Django views that fan out to AWS Personalize, Databricks Model Serving, internal microservices, or any ML inference downstream
- Designing OpenSearch query endpoints (search results, infinite feeds, faceted search)
- Implementing a recommendations endpoint that blends multiple ranker outputs
- Investigating "Django backend slow when downstream is degraded" or "Personalize quota exhausted"
- Adding caching, retry, circuit breakers, or rate limiting to outbound calls
- Choosing between sync and async Django views, configuring uvicorn vs gunicorn
- Designing DRF response shapes for paginated feeds, partial results, or degraded paths
Rule Categories by Priority
| # | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Fan-out Orchestration | CRITICAL | orch- | 8 |
| 2 | External Service Protection | CRITICAL | protect- | 7 |
| 3 | OpenSearch Query Patterns | CRITICAL | search- | 8 |
| 4 | Result Blending & Personalization | HIGH | blend- | 5 |
| 5 | Caching Strategy | HIGH | cache- | 5 |
| 6 | Resilience & Partial Results | HIGH | resilience- | 5 |
| 7 | Async & Concurrency | MEDIUM-HIGH | async- | 5 |
| 8 | API Response Design | MEDIUM | api- | 5 |
Quick Reference
1. Fan-out Orchestration (CRITICAL)
- `orch-parallel-fanout-asyncio-gather` — Use
asyncio.gatherfor independent downstream calls; never await sequentially - `orch-return-exceptions-on-fanout` —
return_exceptions=Trueso one failure doesn't poison the whole gather - `orch-propagate-request-deadline` — Pass a deadline through every downstream call to bound whole-request latency
- `orch-reuse-async-clients` — One
httpx.AsyncClientper downstream at module scope; never per-request - `orch-bounded-fanout-concurrency` — Cap per-request fan-out with
asyncio.Semaphoreto protect the pool - `orch-no-blocking-in-async-view` — Never block the event loop with sync ORM/IO in async views
- `orch-avoid-await-in-loop` —
for item in items: await ...is serial; useasyncio.gatherwith comprehension - `orch-batch-with-bulk-endpoint` — Bulk endpoint over N parallel calls; DataLoader pattern for batchers
2. External Service Protection (CRITICAL)
- `protect-per-downstream-timeout-budget` — Different timeouts per service matched to each downstream's p99
- `protect-circuit-breaker-per-downstream` — One breaker per downstream so failures stay isolated
- `protect-jittered-retry-backoff` — Full-jitter exponential backoff to prevent thundering-herd recovery
- `protect-no-retry-on-4xx` — Skip retry on 4xx and non-idempotent failures; distinguish connect vs read errors
- `protect-bulkhead-connection-pool` — One connection pool per downstream so one slow service doesn't starve others
- `protect-client-side-rate-limit` — Token bucket toward each downstream to stay under their quota
- `protect-honor-retry-after-header` — Parse
Retry-After(seconds or HTTP-date) on 429/503
3. OpenSearch Query Patterns (CRITICAL)
- `search-use-search-after-not-from` —
search_aftercursor instead offrom/sizefor any paginated endpoint - `search-filter-source-fields` — Restrict
_sourceto fields you render; usedocvalue_fieldsfor sortable - `search-bool-filter-vs-must` — Non-scoring clauses in
filter(cacheable), scoring clauses inmust - `search-function-score-for-blending` — Use
function_scoreto blend personalization signals in-engine - `search-stable-tiebreaker-sort` — Always end sort with
_id(or unique numeric field) for stable cursors - `search-alias-for-blue-green-reindex` — Query through aliases; never direct index names
- `search-enable-request-cache` —
request_cache=truefor hit-returning queries; canonicalize request body - `search-shard-aware-routing` — Use routing keys to limit per-query shard fan-out
4. Result Blending & Personalization (HIGH)
- `blend-normalize-scores-across-sources` — Min-max or RRF normalize before blending Personalize/Databricks/OpenSearch
- `blend-mmr-for-diversity` — Maximal Marginal Relevance to avoid monocultures in top-K
- `blend-dedup-across-sources` — Canonical item ID dedup; bonus for cross-source corroboration
- `blend-cold-start-fallback` — Popular/editorial fallback for new users; tiered personalization
- `blend-anonymous-vs-personalized-paths` — Cheap segment-keyed cache for anon traffic; ML only for logged-in
5. Caching Strategy (HIGH)
- `cache-redis-with-stampede-protection` —
SETNXlock + jittered TTL + probabilistic early refresh - `cache-version-on-model-deploy` — Bake model version into cache keys; no flush needed on retrain
- `cache-segment-keyed-isolation` — Include auth/role/locale/segment in keys to prevent cross-context leakage
- `cache-two-tier-process-and-redis` — Process LRU in front of Redis for the hottest keys
- `cache-negative-results` — Cache absences and empty results with shorter TTL
6. Resilience & Partial Results (HIGH)
- `resilience-partial-response-envelope` —
partial: true+sources_used+failed_sourcesin response - `resilience-serve-stale-from-redis` — Two TTLs (fresh + stale); serve stale on origin failure
- `resilience-default-ranking-fallback` — Precomputed default ranking when all ML sources are down
- `resilience-per-source-observability` — Tag every downstream call with structured source/outcome metadata
- `resilience-degrade-search-gracefully` — Tier 1 → tier 2 → tier 3 fallback for OpenSearch outages
7. Async & Concurrency (MEDIUM-HIGH)
- `async-sync-to-async-orm` — Use Django 4.1+ async ORM (
aget,afilter) orsync_to_asyncwiththread_sensitive=True - `async-worker-model-uvicorn-vs-gunicorn` — Run ASGI (uvicorn or gunicorn+UvicornWorker) for true async concurrency
- `async-fire-and-forget-with-create-task` —
create_taskfor analytics/audit; add error handler; hold task references - `async-context-vars-for-request-scope` —
contextvars.ContextVarfor per-request state; notthreading.local - `async-cancel-on-client-disconnect` — Check
await request.is_disconnected(); propagate cancellation
8. API Response Design (MEDIUM)
- `api-cursor-pagination-in-drf` — Cursor pagination over page-number; opaque base64 cursors
- `api-serializer-perf-select-related` —
select_related/prefetch_related/onlyto eliminate N+1 - `api-etag-and-cache-control-headers` —
ETag+Cache-Control+Varyfor CDN/client reuse - `api-compression-and-payload-shaping` — gzip/brotli; sparse fieldsets; msgpack for internal APIs
- `api-throttle-per-user-and-endpoint` — DRF throttle classes per user/anon and per expensive endpoint
How to Use
1. Open references/_sections.md for category definitions and impact rationale 2. Read individual rule files for incorrect-vs-correct code examples (each ~150-300 lines with Python code) 3. For ready-to-use scaffolds, see scaffolding templates 4. The AGENTS.md navigation document (auto-generated) provides a TOC for browsing
Scaffolding Templates
Five ready-to-adapt Python templates under assets/templates/:
| Template | Purpose |
|---|---|
fanout_recommender_service.py.template | Async fan-out client to Personalize/Databricks/microservice with per-downstream circuit breaker, bounded timeout, partial-result return |
opensearch_search_view.py.template | DRF view + OpenSearch search_after cursor + function_score blending + _source filtering |
result_blender.py.template | Score normalization + MMR diversity + canonical-ID dedup + cold-start fallback |
redis_cache_with_stampede.py.template | Stampede-safe cached function decorator with SETNX lock and jittered TTL |
degraded_response.py.template | Partial-results envelope with per-source status flags + tier-based fallback |
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions, ordering, impact rationale, tier definitions |
| assets/templates/_template.md | Template for authoring new rules |
| metadata.json | Version, references, abstract |
Related Skills
react-fetch-cache-patterns— Client-side peer covering React data fetching/caching (Suspense, query libraries, prefetch)io-bound-data-processing— Python async patterns for batch and pipeline workloadsinngest-nextjs-patterns— Workflow patterns for server-side step functions
Django Recommender + Search Backend
Version 0.1.0 Experimental May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Implementation patterns for a Django backend API that serves mixed-results recommendations (fan-out to AWS Personalize, Databricks Model Serving endpoints, and internal microservices) and OpenSearch-backed search/feed endpoints. 46 rules across 8 categories ordered by execution lifecycle impact: Fan-out Orchestration (asyncio.gather, return_exceptions, deadline propagation, async client reuse, bounded concurrency, no-blocking-in-async, no-await-in-loop, DataLoader batching), External Service Protection (per-downstream timeouts, per-downstream circuit breakers, full-jitter retry, 4xx/non-idempotent retry policy, bulkhead pools, client-side rate limits, Retry-After parsing), OpenSearch Query Patterns (search_after vs from/size, _source filtering, bool.filter vs must, function_score for blending, stable tiebreaker sort, blue-green index aliases, request_cache, shard-aware routing), Result Blending & Personalization (score normalization, MMR diversity, canonical-ID dedup, cold-start fallback, anonymous-vs-personalized paths), Caching Strategy (Redis stampede protection via SETNX, model-version-keyed cache, segment-keyed isolation, two-tier process+Redis, negative result caching), Resilience & Partial Results (partial-response envelope, stale-on-error from Redis, default ranking fallback, per-source observability tags, tiered search degradation), Async & Concurrency (async ORM and sync_to_async, uvicorn/UvicornWorker deployment, create_task fire-and-forget, contextvars for request scope, cancel on client disconnect), and API Response Design (cursor pagination in DRF, select_related/prefetch_related/only, ETag + Cache-Control + Vary, gzip/brotli compression and payload shaping, throttling per user and endpoint). Bundled with 5 Python scaffolding templates: fanout_recommender_service, opensearch_search_view, result_blender, redis_cache_with_stampede, degraded_response. Backend peer to react-fetch-cache-patterns.
---
Table of Contents
1. Fan-out Orchestration — CRITICAL
- 1.1 Avoid await Inside Independent Loops — CRITICAL (reduces N sequential awaits to 1 round-trip time)
- 1.2 Batch Fan-out via Bulk Endpoints — CRITICAL (reduces N parallel calls to 1 round-trip)
- 1.3 Bound Per-Request Fan-out with a Semaphore — CRITICAL (prevents one user's request from saturating the pool)
- 1.4 Fan Out to Recommenders with asyncio.gather — CRITICAL (reduces N sequential downstream calls to 1 round-trip time)
- 1.5 Never Block the Event Loop in Async Views — CRITICAL (prevents 1 slow request from blocking all other requests)
- 1.6 Propagate a Request Deadline Across All Downstreams — CRITICAL (prevents unbounded request latency on slow downstream)
- 1.7 Reuse Async HTTP Clients Across Requests — CRITICAL (prevents 50-200ms per-request TLS handshake overhead)
- 1.8 Use return_exceptions=True for Partial-Results Fan-out — CRITICAL (prevents one downstream failure from failing the whole request)
2. External Service Protection — CRITICAL
- 2.1 Honor Retry-After Headers from Downstreams — HIGH (prevents 429 escalation and downstream bans)
- 2.2 Isolate Connection Pools per Downstream — HIGH (prevents one slow downstream from starving fast ones)
- 2.3 Run One Circuit Breaker per Downstream — CRITICAL (prevents one degraded downstream from cascading)
- 2.4 Set Per-Downstream Timeout Budgets — CRITICAL (prevents one slow service from blowing the whole budget)
- 2.5 Skip Retry on 4xx and Non-Idempotent Failures — HIGH (prevents wasted retries on permanent errors)
- 2.6 Throttle Outbound Calls with a Token Bucket — HIGH (prevents downstream rate-limit bans)
- 2.7 Use Full-Jitter Backoff for Server-to-Server Retries — CRITICAL (prevents thundering-herd recovery storms)
3. OpenSearch Query Patterns — CRITICAL
- 3.1 Blend Personalization Signals with function_score — HIGH (eliminates client-side re-rank of large candidate sets)
- 3.2 Enable request_cache for Repeat Queries — HIGH (10-100× faster for hot identical queries)
- 3.3 Include a Unique Tiebreaker in Every Sort — HIGH (prevents duplicate/missing items on paginated queries)
- 3.4 Paginate OpenSearch with search_after, Not from/size — CRITICAL (O(N) deep pagination → O(1))
- 3.5 Query Through Aliases, Never Direct Index Names — HIGH (enables zero-downtime reindex and rollback)
- 3.6 Restrict _source to Fields You Actually Render — CRITICAL (reduces search response size 10-100×)
- 3.7 Use bool.filter for Non-Scoring Clauses — CRITICAL (2-10× faster queries via filter cache)
- 3.8 Use Routing to Limit Shards Per Query — HIGH (10× faster queries when partition key known)
4. Result Blending & Personalization — HIGH
- 4.1 Apply MMR Diversity to Avoid Recommendation Monocultures — HIGH (prevents top-K from showing 10 near-duplicate items)
- 4.2 Dedup by Canonical ID Across All Sources — HIGH (prevents duplicate items in the final ranking)
- 4.3 Fall Back to Popular/Editorial on Cold-Start — HIGH (prevents empty recommendations for new users)
- 4.4 Normalize Scores Before Blending Across Sources — HIGH (prevents one source from dominating the ranking)
- 4.5 Separate Anonymous and Personalized Code Paths — MEDIUM-HIGH (prevents 60-80% of traffic hitting expensive personalization)
5. Caching Strategy — HIGH
- 5.1 Cache Negative Results to Prevent Origin Hammering — MEDIUM-HIGH (prevents N% origin traffic from invalid IDs and empty results)
- 5.2 Isolate Cache Keys by Segment and Auth State — HIGH (prevents cross-segment data leakage in cached responses)
- 5.3 Layer a Process LRU in Front of Redis — HIGH (reduces Redis RTT 30-90× for the hottest keys)
- 5.4 Protect Cache Misses from Stampede with a Lock — HIGH (prevents N concurrent regenerations on cold miss)
- 5.5 Version Cache Keys by Model Deploy — HIGH (prevents serving stale recommendations after model retrain)
6. Resilience & Partial Results — HIGH
- 6.1 Define a Default Ranking for Total ML Outage — HIGH (prevents empty recommendations when all sources fail)
- 6.2 Degrade Gracefully When OpenSearch Is Slow or Down — MEDIUM-HIGH (prevents search outages cascading to API outages)
- 6.3 Serve Stale Redis Data When Fresh Fetch Fails — HIGH (prevents transient downstream outages from breaking the API)
- 6.4 Surface Partiality in the Response Envelope — HIGH (prevents callers caching degraded responses as complete)
- 6.5 Tag Every Downstream Call with Structured Source Metadata — HIGH (prevents silent degradation in production)
7. Async & Concurrency — MEDIUM-HIGH
- 7.1 Cancel In-Flight Work When the Client Disconnects — MEDIUM (prevents wasted compute on abandoned requests)
- 7.2 Run Async Views Under Uvicorn or Gunicorn+UvicornWorker — MEDIUM-HIGH (enables true async concurrency per worker)
- 7.3 Use Async ORM Methods in Async Views — MEDIUM-HIGH (prevents event-loop blocking on ORM calls)
- 7.4 Use contextvars for Request-Scoped State Across Async Calls — MEDIUM (prevents cross-request state leakage in async code)
- 7.5 Use create_task for Fire-and-Forget Background Work — MEDIUM (prevents user requests blocking on analytics/audit writes)
8. API Response Design — MEDIUM
- 8.1 Apply Throttling per User and per Expensive Endpoint — MEDIUM (prevents one user exhausting expensive downstream quota)
- 8.2 Compress Responses and Shape Payloads — MEDIUM (reduces 60-80% of API egress bandwidth)
- 8.3 Return Cursor-Based Pagination from DRF — MEDIUM (prevents page-skip bugs as data shifts)
- 8.4 Set ETag and Cache-Control for CDN/Client Reuse — MEDIUM (enables 304 Not Modified responses and CDN caching)
- 8.5 Use select_related, prefetch_related, and only in DRF Serializers — MEDIUM (reduces N+1 queries from serialization)
---
References
1. https://docs.djangoproject.com/en/5.0/topics/async/ 2. https://www.django-rest-framework.org/api-guide/pagination/ 3. https://opensearch.org/docs/latest/search-plugins/searching-data/paginate/ 4. https://opensearch.org/docs/latest/query-dsl/compound/function-score/ 5. https://docs.aws.amazon.com/personalize/latest/dg/getting-real-time-recommendations.html 6. https://docs.databricks.com/en/machine-learning/model-serving/index.html 7. https://www.python-httpx.org/async/ 8. https://docs.python.org/3/library/asyncio-task.html 9. https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ 10. https://redis.io/docs/manual/patterns/distributed-locks/ 11. https://datatracker.ietf.org/doc/html/rfc5861 12. https://github.com/danielfm/pybreaker 13. https://www.uvicorn.org/deployment/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title}
{1-3 sentences explaining WHY this matters. What goes wrong without this pattern in a Django backend serving recommendations + search? Frame the failure in concrete terms: extra downstream load, blown SLO budget, cascading worker exhaustion, ML quota burn, cache miss storm, OpenSearch shard pressure. The mechanism is what makes the rule generalize to novel scenarios.}
Incorrect ({problem label}):
{Production-realistic bad code — use names like fetch_recommendations, search_products,
PersonalizeClient, opensearch_client, not foo/bar.}
{Comments explain the *cost*: "# blocks event loop" or "# fires N requests".}
async def bad_example():
items = ... # 🚨 explanation of what's wrongCorrect ({solution label}):
{Good code — minimal diff from incorrect when possible.}
{Comments explain the *benefit*.}
async def good_example():
items = await with_circuit_breaker(
lambda: personalize_client.get(...),
) # ← the fix{Optional sections — include only when they add value:}
Alternative ({context}):
{Alternative valid approach}Implementation ({name of pattern}):
{Reusable utility worth shipping with the rule}With {framework/tool}:
{Tool-specific variant — e.g., boto3, httpx, opensearch-py, redis.asyncio}When NOT to use this pattern:
- {Specific exception with rationale}
- {Another specific exception}
Warning ({context}):
- {Gotcha that would burn a careful reader}
Pair with [[other-rule-slug]]: {how this rule combines with another}
Reference: [{Source Title}]({source URL — use authoritative sources only: official docs, AWS blog, RFC, primary maintainers})
"""
Degraded response envelope helpers.
Embedded patterns:
- Partial-results envelope with per-source status ([[resilience-partial-response-envelope]])
- Default-ranking fallback when all sources fail ([[resilience-default-ranking-fallback]])
- Per-source observability emission ([[resilience-per-source-observability]])
- Cache-Control headers reflecting partiality ([[api-etag-and-cache-control-headers]])
Usage from a view:
envelope = build_envelope(
raw_results={
"personalize": personalize_result,
"affinity": affinity_result,
"databricks": databricks_result,
},
blend_fn=blend,
fallback_fn=lambda: get_default_ranking("default"),
)
return JsonResponse(envelope.body, status=envelope.status,
headers=envelope.headers)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
logger = logging.getLogger(__name__)
@dataclass
class Envelope:
body: dict
status: int = 200
headers: dict[str, str] | None = None
async def build_envelope(
raw_results: dict[str, Any | BaseException],
*,
blend_fn: Callable[[dict[str, list[dict]]], list[dict]],
fallback_fn: Callable[[], Awaitable[list[dict]]],
model_version: str = "unknown",
metrics_emitter: Callable[[str, dict], None] | None = None,
) -> Envelope:
"""Build a partial-results envelope from a fan-out's gather output.
`raw_results` keys are source names; values are either lists of items or Exception instances
(from asyncio.gather(return_exceptions=True)).
"""
sources_used: list[str] = []
failed_sources: list[dict] = []
by_source: dict[str, list[dict]] = {}
for name, result in raw_results.items():
if isinstance(result, BaseException):
error_class = type(result).__name__
failed_sources.append({
"source": name,
"error_class": error_class,
"message": str(result)[:120],
})
logger.warning("source_failed", extra={"source": name, "error_class": error_class})
if metrics_emitter:
metrics_emitter("downstream.outcome",
{"source": name, "outcome": "error", "error_class": error_class})
else:
sources_used.append(name)
by_source[name] = result if isinstance(result, list) else []
if metrics_emitter:
metrics_emitter("downstream.outcome",
{"source": name, "outcome": "ok"})
partial = bool(failed_sources)
if not sources_used:
# Total ML failure — invoke the default-ranking fallback
try:
fallback_items = await fallback_fn()
logger.warning("fallback_applied", extra={"reason": "all_sources_failed"})
body = {
"items": fallback_items,
"sources_used": ["fallback"],
"failed_sources": failed_sources,
"partial": True,
"degraded": True,
"model_version": model_version,
}
return Envelope(
body=body,
status=200,
headers={"Cache-Control": "private, max-age=30, stale-while-revalidate=120",
"X-Recommender-Degraded": "true"},
)
except Exception as e:
logger.error("fallback_failed", extra={"err": str(e)[:200]})
return Envelope(
body={"items": [], "sources_used": [], "failed_sources": failed_sources,
"partial": True, "degraded": True,
"message": "Recommendations temporarily unavailable"},
status=200, # 200 not 503 — see [[resilience-partial-response-envelope]]
headers={"Cache-Control": "private, no-cache",
"X-Recommender-Degraded": "true"},
)
items = blend_fn(by_source)
body = {
"items": items,
"sources_used": sources_used,
"failed_sources": failed_sources,
"partial": partial,
"model_version": model_version,
}
headers = {
"Cache-Control": (
"private, max-age=30, stale-while-revalidate=60"
if partial else
"private, max-age=300, stale-while-revalidate=600"
),
"X-Recommender-Partial": "true" if partial else "false",
"X-Recommender-Sources": ",".join(sources_used),
}
return Envelope(body=body, status=200, headers=headers)
# ─────────────────────────────────────────────────────────────────────────────
# Convenience helper for emitting structured metrics
# ─────────────────────────────────────────────────────────────────────────────
def default_metrics_emitter(name: str, tags: dict) -> None:
"""Override this with your metrics backend (Datadog, Prometheus, etc.).
Example with Datadog:
from datadog import statsd
def default_metrics_emitter(name, tags):
statsd.increment(name, tags=[f"{k}:{v}" for k, v in tags.items()])
"""
logger.info("metric", extra={"name": name, **tags})
# Example wiring in a view — copy and adapt:
#
# from .clients import fetch_personalize, fetch_affinity, fetch_databricks
# from .result_blender import blend
# from .fallback import get_default_ranking
# from django.http import JsonResponse
#
# async def recommendations_view(request):
# raw = await asyncio.gather(
# fetch_personalize(request.user.id),
# fetch_affinity(request.user.id),
# fetch_databricks(request.user.id),
# return_exceptions=True,
# )
# envelope = await build_envelope(
# raw_results={
# "personalize": raw[0], "affinity": raw[1], "databricks": raw[2],
# },
# blend_fn=blend,
# fallback_fn=lambda: get_default_ranking(segment="default"),
# model_version=settings.RECOMMENDER_MODEL_VERSION,
# metrics_emitter=default_metrics_emitter,
# )
# response = JsonResponse(envelope.body, status=envelope.status)
# for k, v in (envelope.headers or {}).items():
# response[k] = v
# return response
"""
Async fan-out client to mixed recommender sources.
Embedded patterns:
- asyncio.gather with return_exceptions ([[orch-return-exceptions-on-fanout]])
- Deadline propagation across hops ([[orch-propagate-request-deadline]])
- Per-downstream circuit breakers ([[protect-circuit-breaker-per-downstream]])
- Per-downstream timeouts ([[protect-per-downstream-timeout-budget]])
- Full-jitter retry ([[protect-jittered-retry-backoff]])
- Reused module-level async clients ([[orch-reuse-async-clients]])
- Per-source observability tags ([[resilience-per-source-observability]])
- Partial-results envelope ([[resilience-partial-response-envelope]])
- Default-ranking fallback ([[resilience-default-ranking-fallback]])
Adapt the SourceConfig entries for your downstream URLs, timeouts, and retry budgets.
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Awaitable, Callable
import httpx
from django.conf import settings
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Circuit breaker (one per downstream)
# ─────────────────────────────────────────────────────────────────────────────
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half-open"
@dataclass
class CircuitBreaker:
name: str
failure_threshold: int = 5
cooldown_s: float = 30.0
state: CircuitState = CircuitState.CLOSED
failures: int = 0
opened_at: float = 0.0
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def call(self, fn: Callable[[], Awaitable]):
async with self._lock:
if self.state == CircuitState.OPEN:
if time.monotonic() - self.opened_at < self.cooldown_s:
raise CircuitOpenError(self.name)
self.state = CircuitState.HALF_OPEN
try:
result = await fn()
except Exception:
async with self._lock:
self.failures += 1
if self.state == CircuitState.HALF_OPEN or self.failures >= self.failure_threshold:
self.state = CircuitState.OPEN
self.opened_at = time.monotonic()
logger.warning("circuit_opened", extra={"downstream": self.name})
raise
async with self._lock:
self.failures = 0
self.state = CircuitState.CLOSED
return result
class CircuitOpenError(Exception):
def __init__(self, downstream: str):
self.downstream = downstream
super().__init__(f"circuit open for {downstream}")
# ─────────────────────────────────────────────────────────────────────────────
# Retry with full jitter
# ─────────────────────────────────────────────────────────────────────────────
async def with_retry(
fn: Callable[[], Awaitable],
*,
max_attempts: int = 2,
base_s: float = 0.2,
cap_s: float = 5.0,
idempotent: bool = True,
):
"""Retry with full jitter — see [[protect-jittered-retry-backoff]] and
[[protect-no-retry-on-4xx]] for the retriability matrix."""
for attempt in range(max_attempts):
try:
return await fn()
except BaseException as err:
if attempt == max_attempts - 1 or not _is_retriable(err, idempotent=idempotent):
raise
delay = random.uniform(0, min(cap_s, base_s * (2 ** attempt)))
await asyncio.sleep(delay)
def _is_retriable(err: BaseException, *, idempotent: bool) -> bool:
# 4xx = client error, never retriable (except 429 with Retry-After)
if isinstance(err, httpx.HTTPStatusError):
status = err.response.status_code
if status == 429:
return True
if 400 <= status < 500:
return False
if 500 <= status < 600:
return idempotent # 5xx may have side-effected — only retry idempotent
return False
# Connect-side errors — request never reached the server, safe to retry
if isinstance(err, (httpx.ConnectError, httpx.ConnectTimeout, httpx.WriteError)):
return True
# Read-side errors — server may have processed; only retry idempotent
if isinstance(err, (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError)):
return idempotent
if isinstance(err, asyncio.TimeoutError):
return idempotent
return False
# ─────────────────────────────────────────────────────────────────────────────
# Downstream client config — adapt per environment
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class SourceConfig:
name: str
timeout: httpx.Timeout
max_connections: int
breaker: CircuitBreaker
retry_attempts: int = 2
# Module-level shared clients (one per downstream) — [[orch-reuse-async-clients]]
_PERSONALIZE = SourceConfig(
name="personalize",
timeout=httpx.Timeout(connect=0.3, read=0.6, write=0.3, pool=0.2),
max_connections=50,
breaker=CircuitBreaker(name="personalize", failure_threshold=5, cooldown_s=30.0),
)
_AFFINITY = SourceConfig(
name="affinity",
timeout=httpx.Timeout(connect=0.2, read=0.4, write=0.2, pool=0.2),
max_connections=50,
breaker=CircuitBreaker(name="affinity", failure_threshold=10, cooldown_s=15.0),
)
_DATABRICKS = SourceConfig(
name="databricks",
timeout=httpx.Timeout(connect=0.5, read=3.0, write=0.5, pool=0.5),
max_connections=20,
breaker=CircuitBreaker(name="databricks", failure_threshold=3, cooldown_s=60.0),
)
def _build_client(cfg: SourceConfig, base_url: str) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=base_url,
timeout=cfg.timeout,
limits=httpx.Limits(
max_connections=cfg.max_connections,
max_keepalive_connections=cfg.max_connections // 2,
keepalive_expiry=30.0,
),
http2=True,
)
_clients = {
"personalize": _build_client(_PERSONALIZE, settings.PERSONALIZE_URL),
"affinity": _build_client(_AFFINITY, settings.AFFINITY_URL),
"databricks": _build_client(_DATABRICKS, settings.DATABRICKS_URL),
}
# ─────────────────────────────────────────────────────────────────────────────
# Per-source fetchers
# ─────────────────────────────────────────────────────────────────────────────
async def _call(cfg: SourceConfig, fn: Callable[[], Awaitable]) -> Any:
"""Wrap a downstream call with breaker + retry + observability."""
start = time.monotonic()
outcome = "ok"
try:
return await cfg.breaker.call(
lambda: with_retry(fn, max_attempts=cfg.retry_attempts, idempotent=True)
)
except CircuitOpenError:
outcome = "circuit_open"
raise
except asyncio.TimeoutError:
outcome = "timeout"
raise
except Exception:
outcome = "error"
raise
finally:
logger.info(
"downstream_call",
extra={
"source": cfg.name,
"outcome": outcome,
"duration_ms": int((time.monotonic() - start) * 1000),
},
)
async def fetch_personalize(user_id: str) -> list[dict]:
async def _do():
r = await _clients["personalize"].post("/recommend", json={"user_id": user_id})
r.raise_for_status()
return r.json()["items"]
return await _call(_PERSONALIZE, _do)
async def fetch_affinity(user_id: str) -> list[dict]:
async def _do():
r = await _clients["affinity"].get(f"/affinity/{user_id}")
r.raise_for_status()
return r.json()["items"]
return await _call(_AFFINITY, _do)
async def fetch_databricks(user_id: str) -> list[dict]:
async def _do():
r = await _clients["databricks"].post(
"/serving-endpoints/ranker/invocations",
json={"inputs": {"user_id": [user_id]}},
headers={"Authorization": f"Bearer {settings.DATABRICKS_TOKEN}"},
)
r.raise_for_status()
# Databricks response shape varies by model — adapt this
return r.json()["predictions"][0]["items"]
return await _call(_DATABRICKS, _do)
# ─────────────────────────────────────────────────────────────────────────────
# Fan-out + partial-response envelope
# ─────────────────────────────────────────────────────────────────────────────
REQUEST_BUDGET_S = 0.6 # whole-request SLO
async def get_blended_recommendations(user_id: str) -> dict:
"""Fan out to all three sources in parallel; return partial-results envelope."""
deadline = time.monotonic() + REQUEST_BUDGET_S
async def _bounded(coro):
remaining = max(0.01, deadline - time.monotonic())
try:
return await asyncio.wait_for(coro, timeout=remaining)
except asyncio.TimeoutError:
raise
raw = await asyncio.gather(
_bounded(fetch_personalize(user_id)),
_bounded(fetch_affinity(user_id)),
_bounded(fetch_databricks(user_id)),
return_exceptions=True,
)
sources_used = []
failed_sources = []
by_source: dict[str, list[dict]] = {}
for name, result in zip(["personalize", "affinity", "databricks"], raw):
if isinstance(result, BaseException):
failed_sources.append({
"source": name,
"error_class": type(result).__name__,
"message": str(result)[:120],
})
else:
sources_used.append(name)
by_source[name] = result
if not sources_used:
# All sources failed — see [[resilience-default-ranking-fallback]]
return {
"items": await get_default_ranking(user_id),
"sources_used": ["fallback"],
"failed_sources": failed_sources,
"partial": True,
"degraded": True,
}
return {
"items": blend(by_source), # TODO: import your blender
"sources_used": sources_used,
"failed_sources": failed_sources,
"partial": bool(failed_sources),
}
# Stubs — replace with your implementations
async def get_default_ranking(user_id: str) -> list[dict]:
raise NotImplementedError("import from a precomputed Redis key")
def blend(by_source: dict[str, list[dict]]) -> list[dict]:
raise NotImplementedError("import from result_blender.py.template")
"""
OpenSearch-backed DRF search view with cursor pagination, function_score blending,
and graceful degradation.
Embedded patterns:
- search_after cursor pagination ([[search-use-search-after-not-from]])
- _source filtering ([[search-filter-source-fields]])
- bool.filter for non-scoring clauses ([[search-bool-filter-vs-must]])
- function_score for personalization ([[search-function-score-for-blending]])
- Stable tiebreaker sort ([[search-stable-tiebreaker-sort]])
- Index aliases ([[search-alias-for-blue-green-reindex]])
- request_cache for hot queries ([[search-enable-request-cache]])
- Tiered degradation on OpenSearch failure ([[resilience-degrade-search-gracefully]])
- Cursor pagination response shape ([[api-cursor-pagination-in-drf]])
- ETag + Cache-Control headers ([[api-etag-and-cache-control-headers]])
Adapt the field names and personalization signals to your index schema.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
from typing import Any
from asgiref.sync import sync_to_async
from django.conf import settings
from django.http import HttpResponse
from opensearchpy import OpenSearch # synchronous client; wrap with sync_to_async
from rest_framework.response import Response
from rest_framework.views import APIView
logger = logging.getLogger(__name__)
# Module-level OpenSearch client — reused across requests
_opensearch = OpenSearch(
hosts=[settings.OPENSEARCH_HOST],
http_auth=(settings.OPENSEARCH_USER, settings.OPENSEARCH_PASS),
use_ssl=True,
timeout=2.0,
max_retries=0, # we do our own retry with jitter
)
INDEX_ALIAS = "products_live"
SEARCH_LIST_FIELDS = ["id", "title", "thumbnail_url", "price", "in_stock", "rating"]
# ─────────────────────────────────────────────────────────────────────────────
# Cursor encoding (opaque to clients)
# ─────────────────────────────────────────────────────────────────────────────
def encode_cursor(sort_values: list) -> str:
return base64.urlsafe_b64encode(
json.dumps(sort_values).encode()
).decode().rstrip("=")
def decode_cursor(cursor: str | None) -> list | None:
if not cursor:
return None
try:
padded = cursor + "=" * (-len(cursor) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
except (ValueError, json.JSONDecodeError):
return None # malformed → start over
# ─────────────────────────────────────────────────────────────────────────────
# Query builders — tiered for graceful degradation
# ─────────────────────────────────────────────────────────────────────────────
def build_full_query(
*, query: str, user_segment: str | None,
affinity_categories: list[str], cursor: list | None, size: int,
) -> dict:
"""Tier 1: full personalization blend with function_score."""
body: dict[str, Any] = {
"query": {
"function_score": {
"query": {
"bool": {
"must": [
{"multi_match": {
"query": query,
"fields": ["title^3", "description", "brand^2"],
}}
],
"filter": [
{"term": {"in_stock": True}},
# If user has segment access restrictions, scope:
*([{"term": {"segments": user_segment}}] if user_segment else []),
],
}
},
"functions": [
{
"filter": {"terms": {"category": affinity_categories}},
"weight": 1.5,
} if affinity_categories else {"weight": 1.0},
{"field_value_factor": {
"field": "popularity", "modifier": "log1p", "factor": 0.5, "missing": 0,
}},
{"gauss": {"created_at": {"origin": "now/d", "scale": "30d", "decay": 0.5}}},
],
"score_mode": "multiply",
"boost_mode": "multiply",
}
},
"size": size,
"sort": [
{"_score": "desc"},
{"_id": "asc"}, # stable tiebreaker
],
"_source": {"includes": SEARCH_LIST_FIELDS},
}
if cursor:
body["search_after"] = cursor
return body
def build_simple_query(*, query: str, cursor: list | None, size: int) -> dict:
"""Tier 2: simpler query without function_score (cheaper to compute)."""
body: dict[str, Any] = {
"query": {
"bool": {
"must": [{"match": {"title": query}}],
"filter": [{"term": {"in_stock": True}}],
}
},
"size": size,
"sort": [{"_score": "desc"}, {"_id": "asc"}],
"_source": {"includes": SEARCH_LIST_FIELDS},
}
if cursor:
body["search_after"] = cursor
return body
# ─────────────────────────────────────────────────────────────────────────────
# Async wrapper around the sync OpenSearch client
# ─────────────────────────────────────────────────────────────────────────────
async def _search(body: dict, *, timeout: float) -> dict:
return await asyncio.wait_for(
asyncio.to_thread(
_opensearch.search,
index=INDEX_ALIAS,
body=body,
request_cache=True,
timeout=f"{int(timeout * 1000)}ms", # per-shard timeout
),
timeout=timeout + 0.2, # outer guard
)
def _format_response(response: dict, *, degraded: bool, tier: str | None = None) -> dict:
hits = response["hits"]["hits"]
items = [h["_source"] for h in hits]
next_cursor = encode_cursor(hits[-1]["sort"]) if hits and len(hits) > 0 else None
return {
"items": items,
"next_cursor": next_cursor,
"degraded": degraded,
"tier": tier,
"total_approximate": response["hits"]["total"]["value"],
}
# ─────────────────────────────────────────────────────────────────────────────
# View — DRF APIView
# ─────────────────────────────────────────────────────────────────────────────
class SearchView(APIView):
permission_classes = [] # adapt for your auth
throttle_classes = [] # add per-endpoint throttle from settings
async def get_async(self, request):
query = request.query_params.get("q", "").strip()
if not query:
return Response({"items": [], "next_cursor": None})
cursor = decode_cursor(request.query_params.get("cursor"))
size = min(int(request.query_params.get("size", "20")), 100)
# Fetch personalization signals if user is authenticated
affinity_categories = []
user_segment = None
if request.user.is_authenticated:
affinity_categories = await _get_user_affinity_categories(request.user.id)
user_segment = request.user.segment
# Tier 1: full personalized query
try:
body = build_full_query(
query=query, user_segment=user_segment,
affinity_categories=affinity_categories,
cursor=cursor, size=size,
)
response = await _search(body, timeout=0.8)
return Response(_format_response(response, degraded=False))
except (asyncio.TimeoutError, Exception) as e:
logger.warning("search_tier1_failed", extra={"err": str(e)[:200]})
# Tier 2: simplified query
try:
body = build_simple_query(query=query, cursor=cursor, size=size)
response = await _search(body, timeout=1.5)
return Response(_format_response(response, degraded=True, tier="simple"))
except Exception as e:
logger.warning("search_tier2_failed", extra={"err": str(e)[:200]})
# Tier 3: graceful empty with message
return Response({
"items": [], "next_cursor": None, "degraded": True, "tier": "unavailable",
"message": "Search is temporarily unavailable. Please try again shortly.",
})
def get(self, request):
# Sync wrapper for environments that aren't fully async-routed
return asyncio.run(self.get_async(request))
async def _get_user_affinity_categories(user_id: str) -> list[str]:
"""Fetch the top categories the user has interacted with — from Redis or microservice."""
# Stub — replace with your implementation
return []
"""
Stampede-safe Redis cache utility.
Embedded patterns:
- SETNX lock around regeneration ([[cache-redis-with-stampede-protection]])
- Jittered TTL on writes
- Two-TTL fresh + stale model ([[resilience-serve-stale-from-redis]])
- Segment-keyed isolation via prefix templates ([[cache-segment-keyed-isolation]])
- Model-version-keyed ([[cache-version-on-model-deploy]])
- Negative-result caching ([[cache-negative-results]])
- Probabilistic early refresh
Usage:
@cached_with_stampede(
ttl_s=300, stale_ttl_s=86400,
key_template="recs:user:{user_id}:v{model_version}",
)
async def get_recommendations(user_id: str, model_version: str):
return await expensive_call(user_id)
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
from functools import wraps
from typing import Any, Awaitable, Callable, TypeVar
import redis.asyncio as aioredis
from django.conf import settings
logger = logging.getLogger(__name__)
# Module-level Redis client
_redis = aioredis.from_url(
settings.REDIS_URL,
decode_responses=False,
socket_timeout=0.5,
socket_connect_timeout=0.5,
health_check_interval=30,
max_connections=50,
)
NOT_FOUND_SENTINEL = b"__NOT_FOUND__"
T = TypeVar("T")
# ─────────────────────────────────────────────────────────────────────────────
# Core get-or-fetch with stampede protection
# ─────────────────────────────────────────────────────────────────────────────
async def get_with_stampede_protection(
key: str,
fetch_fn: Callable[[], Awaitable[Any]],
*,
ttl_s: int = 300,
stale_ttl_s: int = 86400,
lock_timeout_s: int = 10,
wait_for_lock_s: float = 1.0,
cache_negatives: bool = True,
) -> Any:
"""Stampede-safe get-or-fetch with stale fallback.
Layout in Redis:
<key> → JSON of {value, fresh_until} (TTL = stale_ttl_s)
lock:<key> → SETNX lock during regeneration (TTL = lock_timeout_s)
"""
payload = await _safe_redis_get(key)
now = time.time()
if payload is not None:
if payload == NOT_FOUND_SENTINEL and cache_negatives:
return None
try:
entry = json.loads(payload)
except (ValueError, json.JSONDecodeError):
entry = None
if entry and now < entry.get("fresh_until", 0):
return entry["value"]
# Stale — try to refresh; fall back to stale on failure
if entry:
try:
return await _refresh(key, fetch_fn, ttl_s, stale_ttl_s, cache_negatives, lock_timeout_s)
except Exception as e:
logger.warning(
"stale_fallback", extra={"key": key, "err": str(e)[:200]}
)
return entry["value"]
# No cache at all — regenerate (with stampede protection)
return await _refresh(key, fetch_fn, ttl_s, stale_ttl_s, cache_negatives, lock_timeout_s,
wait_for_lock_s=wait_for_lock_s)
async def _refresh(
key: str,
fetch_fn: Callable[[], Awaitable[Any]],
ttl_s: int,
stale_ttl_s: int,
cache_negatives: bool,
lock_timeout_s: int,
wait_for_lock_s: float = 0,
) -> Any:
lock_key = f"lock:{key}"
got_lock = await _safe_redis_set(lock_key, b"1", nx=True, ex=lock_timeout_s)
if got_lock:
try:
value = await fetch_fn()
await _store(key, value, ttl_s, stale_ttl_s, cache_negatives)
return value
finally:
await _safe_redis_delete(lock_key)
# Lock held by another worker — wait briefly, then read cache again
if wait_for_lock_s > 0:
deadline = time.monotonic() + wait_for_lock_s
while time.monotonic() < deadline:
await asyncio.sleep(0.05)
payload = await _safe_redis_get(key)
if payload == NOT_FOUND_SENTINEL:
return None
if payload is not None:
try:
return json.loads(payload)["value"]
except (ValueError, json.JSONDecodeError, KeyError):
pass
# Lock holder taking too long — compute ourselves (rare)
return await fetch_fn()
async def _store(key: str, value: Any, ttl_s: int, stale_ttl_s: int, cache_negatives: bool):
if value is None and cache_negatives:
# Negative-result caching — see [[cache-negative-results]]
await _safe_redis_set(key, NOT_FOUND_SENTINEL, ex=min(ttl_s, 300))
return
if value is None:
return
entry = {
"value": value,
"fresh_until": time.time() + ttl_s * (1 + random.uniform(-0.1, 0.1)), # jitter
}
await _safe_redis_set(key, json.dumps(entry).encode(), ex=stale_ttl_s)
# ─────────────────────────────────────────────────────────────────────────────
# Safe Redis wrappers — never propagate Redis errors to the caller
# ─────────────────────────────────────────────────────────────────────────────
async def _safe_redis_get(key: str):
try:
return await _redis.get(key)
except aioredis.RedisError as e:
logger.warning("redis_get_failed", extra={"key": key, "err": str(e)[:200]})
return None
async def _safe_redis_set(key: str, value: bytes, *, nx: bool = False, ex: int) -> bool:
try:
return bool(await _redis.set(key, value, nx=nx, ex=ex))
except aioredis.RedisError as e:
logger.warning("redis_set_failed", extra={"key": key, "err": str(e)[:200]})
return False
async def _safe_redis_delete(key: str) -> None:
try:
await _redis.delete(key)
except aioredis.RedisError:
pass
# ─────────────────────────────────────────────────────────────────────────────
# Decorator API
# ─────────────────────────────────────────────────────────────────────────────
def cached_with_stampede(
*,
ttl_s: int = 300,
stale_ttl_s: int = 86400,
key_template: str,
cache_negatives: bool = True,
):
"""Decorator to cache an async function with stampede protection.
`key_template` uses .format(**kwargs, *args) — include every variable the function
depends on, including any model_version that should invalidate this cache.
"""
def decorator(fn):
@wraps(fn)
async def wrapper(*args, **kwargs):
try:
key = key_template.format(*args, **kwargs)
except (IndexError, KeyError) as e:
raise ValueError(f"key_template {key_template!r} missing param: {e}")
return await get_with_stampede_protection(
key, lambda: fn(*args, **kwargs),
ttl_s=ttl_s, stale_ttl_s=stale_ttl_s,
cache_negatives=cache_negatives,
)
return wrapper
return decorator
# Usage example (don't import this — it's documentation):
#
# from .clients import personalize_client
#
# @cached_with_stampede(
# ttl_s=300,
# stale_ttl_s=86400,
# key_template="recs:user:{user_id}:v{model_version}",
# )
# async def get_user_recommendations(user_id: str, model_version: str):
# return await personalize_client.get(user_id)
#
# # In the view:
# items = await get_user_recommendations(
# user_id=request.user.id,
# model_version=settings.PERSONALIZE_VERSION,
# )
"""
Result blender for heterogeneous recommender sources.
Embedded patterns:
- Score normalization across sources ([[blend-normalize-scores-across-sources]])
- Canonical-ID dedup ([[blend-dedup-across-sources]])
- MMR diversity ([[blend-mmr-for-diversity]])
- Cold-start fallback ([[blend-cold-start-fallback]])
- Cross-source corroboration bonus
Adapt:
- SCORE_FIELDS: which field to read for each source's score
- CANONICAL_ID_FIELDS: which field is the item ID in each source's response
- DIVERSITY_FIELD: the field used for diversity (category, tag, brand)
- DEFAULT_WEIGHTS: weights per source — tune offline with eval data
"""
from __future__ import annotations
import logging
from typing import Any, Callable
logger = logging.getLogger(__name__)
# Configuration — adapt to your domain
SCORE_FIELDS = {
"personalize": "score",
"affinity": "affinity",
"databricks": "prediction",
"opensearch": "_score",
}
CANONICAL_ID_FIELDS = {
"personalize": "itemId",
"affinity": "item_id",
"databricks": "id",
"opensearch": "_id",
}
DEFAULT_WEIGHTS = {
"personalize": 0.4,
"affinity": 0.2,
"databricks": 0.3,
"opensearch": 0.1,
}
# ─────────────────────────────────────────────────────────────────────────────
# Canonicalization + normalization
# ─────────────────────────────────────────────────────────────────────────────
def canonical_id(item: dict, source: str) -> str:
"""Extract canonical item ID from a per-source response shape."""
field = CANONICAL_ID_FIELDS.get(source, "id")
raw = item.get(field)
if raw is None:
return ""
s = str(raw)
# Normalize known prefixes — adapt to your data
if s.startswith("item_"):
s = s[5:]
return s.strip()
def get_score(item: dict, source: str) -> float:
field = SCORE_FIELDS.get(source, "score")
return float(item.get(field, 0.0))
def min_max_normalize(items: list[dict], source: str) -> dict[str, float]:
"""Min-max normalize scores to [0, 1] within a source's results."""
scores = [get_score(item, source) for item in items]
if not scores:
return {}
lo, hi = min(scores), max(scores)
span = hi - lo
if span == 0:
return {canonical_id(item, source): 1.0 for item in items}
return {
canonical_id(item, source): (get_score(item, source) - lo) / span
for item in items
}
# ─────────────────────────────────────────────────────────────────────────────
# Blend
# ─────────────────────────────────────────────────────────────────────────────
def blend(
by_source: dict[str, list[dict]],
*,
weights: dict[str, float] | None = None,
corroboration_bonus_per_extra_source: float = 0.15,
) -> list[dict]:
"""Blend results from multiple sources into a single ranked list.
Returns items shaped as:
{"id": str, "score": float, "primary_source": str, "all_sources": list[str], ...}
"""
weights = weights or DEFAULT_WEIGHTS
normalized = {
source: min_max_normalize(items, source)
for source, items in by_source.items()
}
# Index original items by canonical id (keep the richest-detail copy)
item_details: dict[str, dict] = {}
for source, items in by_source.items():
for item in items:
cid = canonical_id(item, source)
if not cid:
continue
if cid not in item_details or len(item) > len(item_details[cid]):
item_details[cid] = item
# Compute blended scores
all_ids = set().union(*(d.keys() for d in normalized.values()))
blended = []
for cid in all_ids:
score = 0.0
contributions: dict[str, float] = {}
for source in normalized:
if cid in normalized[source]:
w = weights.get(source, 0.0)
contribution = normalized[source][cid] * w
score += contribution
contributions[source] = contribution
# Corroboration bonus
n_sources = len(contributions)
if n_sources > 1:
score *= 1.0 + corroboration_bonus_per_extra_source * (n_sources - 1)
item = dict(item_details.get(cid, {"id": cid}))
item.update({
"id": cid,
"score": score,
"sources": list(contributions.keys()),
"contributions": contributions,
})
blended.append(item)
blended.sort(key=lambda x: (-x["score"], x["id"])) # stable tiebreak
return blended
# ─────────────────────────────────────────────────────────────────────────────
# MMR diversity re-rank
# ─────────────────────────────────────────────────────────────────────────────
def mmr_rerank(
candidates: list[dict],
*,
k: int,
lambda_: float = 0.7,
diversity_field: str = "category",
similarity_fn: Callable[[Any, Any], float] | None = None,
) -> list[dict]:
"""Maximal Marginal Relevance — balance relevance with diversity.
lambda_ ∈ [0, 1]: 1 = pure relevance, 0 = pure diversity.
"""
if not candidates:
return []
sim = similarity_fn or _default_similarity
pool = list(candidates)
pool.sort(key=lambda c: -c.get("score", 0.0))
selected = [pool[0]]
pool.pop(0)
while len(selected) < k and pool:
best = None
best_mmr = -float("inf")
for cand in pool:
max_sim = max(
sim(cand.get(diversity_field), s.get(diversity_field))
for s in selected
)
mmr_score = lambda_ * cand.get("score", 0.0) - (1 - lambda_) * max_sim
if mmr_score > best_mmr:
best_mmr = mmr_score
best = cand
if best is None:
break # degenerate input — break to avoid infinite loop
selected.append(best)
pool.remove(best)
return selected
def _default_similarity(a, b) -> float:
if a is None or b is None:
return 0.0
if isinstance(a, str) and isinstance(b, str):
return 1.0 if a == b else 0.0
if isinstance(a, (list, set)) and isinstance(b, (list, set)):
sa, sb = set(a), set(b)
if not sa or not sb:
return 0.0
return len(sa & sb) / len(sa | sb)
return 0.0
# ─────────────────────────────────────────────────────────────────────────────
# Cold-start strategy
# ─────────────────────────────────────────────────────────────────────────────
def get_personalization_strategy(interaction_count: int) -> dict[str, float]:
"""Return source weights based on user maturity."""
if interaction_count < 3:
return {"popular": 1.0}
if interaction_count < 20:
return {"popular": 0.6, "personalize": 0.4}
if interaction_count < 100:
return {"personalize": 0.7, "affinity": 0.2, "popular": 0.1}
return {"personalize": 0.4, "affinity": 0.2, "databricks": 0.3, "opensearch": 0.1}
# ─────────────────────────────────────────────────────────────────────────────
# Reciprocal Rank Fusion — alternative blend when scores aren't comparable
# ─────────────────────────────────────────────────────────────────────────────
def rrf_blend(by_source: dict[str, list[dict]], *, k: int = 60) -> list[dict]:
"""Reciprocal Rank Fusion — robust to incompatible score scales."""
scores: dict[str, float] = {}
sources: dict[str, list[str]] = {}
item_details: dict[str, dict] = {}
for source, items in by_source.items():
for rank, item in enumerate(items, start=1):
cid = canonical_id(item, source)
if not cid:
continue
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
sources.setdefault(cid, []).append(source)
item_details.setdefault(cid, item)
blended = []
for cid, score in scores.items():
item = dict(item_details[cid])
item.update({"id": cid, "score": score, "sources": sources[cid]})
blended.append(item)
blended.sort(key=lambda x: (-x["score"], x["id"]))
return blended
{
"version": "0.1.1",
"organization": "Experimental",
"technology": "Django Recommender + Search Backend",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Implementation patterns for a Django backend API that serves mixed-results recommendations (fan-out to AWS Personalize, Databricks Model Serving endpoints, and internal microservices) and OpenSearch-backed search/feed endpoints. 48 rules across 8 categories ordered by execution lifecycle impact: Fan-out Orchestration (asyncio.gather, return_exceptions, deadline propagation, async client reuse, bounded concurrency, no-blocking-in-async, no-await-in-loop, DataLoader batching), External Service Protection (per-downstream timeouts, per-downstream circuit breakers, full-jitter retry, 4xx/non-idempotent retry policy, bulkhead pools, client-side rate limits, Retry-After parsing), OpenSearch Query Patterns (search_after vs from/size, _source filtering, bool.filter vs must, function_score for blending, stable tiebreaker sort, blue-green index aliases, request_cache, shard-aware routing), Result Blending & Personalization (score normalization, MMR diversity, canonical-ID dedup, cold-start fallback, anonymous-vs-personalized paths), Caching Strategy (Redis stampede protection via SETNX, model-version-keyed cache, segment-keyed isolation, two-tier process+Redis, negative result caching), Resilience & Partial Results (partial-response envelope, stale-on-error from Redis, default ranking fallback, per-source observability tags, tiered search degradation), Async & Concurrency (async ORM and sync_to_async, uvicorn/UvicornWorker deployment, create_task fire-and-forget, contextvars for request scope, cancel on client disconnect), and API Response Design (cursor pagination in DRF, select_related/prefetch_related/only, ETag + Cache-Control + Vary, gzip/brotli compression and payload shaping, throttling per user and endpoint). Bundled with 5 Python scaffolding templates: fanout_recommender_service, opensearch_search_view, result_blender, redis_cache_with_stampede, degraded_response. Backend peer to react-fetch-cache-patterns.",
"references": [
"https://docs.djangoproject.com/en/5.0/topics/async/",
"https://www.django-rest-framework.org/api-guide/pagination/",
"https://opensearch.org/docs/latest/search-plugins/searching-data/paginate/",
"https://opensearch.org/docs/latest/query-dsl/compound/function-score/",
"https://docs.aws.amazon.com/personalize/latest/dg/getting-real-time-recommendations.html",
"https://docs.databricks.com/en/machine-learning/model-serving/index.html",
"https://www.python-httpx.org/async/",
"https://docs.python.org/3/library/asyncio-task.html",
"https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/",
"https://redis.io/docs/manual/patterns/distributed-locks/",
"https://datatracker.ietf.org/doc/html/rfc5861",
"https://github.com/danielfm/pybreaker",
"https://www.uvicorn.org/deployment/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
Categories are ordered by lifecycle position and cascade effect. Problems at the top (fan-out orchestration, external service protection, search query design) multiply downstream — getting them wrong creates p99 spikes, cascading downstream failures, or wasted compute on every request. Problems at the bottom (response serialization, pagination shape) are localized.
Impact tier definitions
Used by rule frontmatter and category headings:
| Tier | Meaning | When to assign |
|---|---|---|
| CRITICAL | Cascades through every request; affects whole-API SLOs | Multiplicative failure modes (uncapped fan-out, missing timeouts, naive OpenSearch pagination at depth) |
| HIGH | Affects a major user path or compute budget; not multiplicative but compounding | Per-endpoint policy (blending strategy, cache stampede, partial-results envelope) |
| MEDIUM-HIGH | Important for a specific scenario (async views, large fan-out) but not universal | Stack-specific patterns (asgiref correctness, sync_to_async ORM pitfalls) |
| MEDIUM | Localized correctness or efficiency; high frequency, contained blast radius | Per-view patterns (DRF serializer perf, cursor pagination format, headers) |
| LOW-MEDIUM / LOW | Edge cases | Rarely used in this skill |
---
1. Fan-out Orchestration (orch)
Impact: CRITICAL Description: How concurrent calls to Personalize, internal microservices, and Databricks ML endpoints are coordinated — asyncio.gather with return_exceptions=True, deadline propagation across hops, partial-result aggregation, parallel-not-serial fan-out, async client reuse. Wrong here turns a 200ms p99 into a 2-second p99 (slowest-downstream bottleneck) or one downstream failure into a full request failure.
2. External Service Protection (protect)
Impact: CRITICAL Description: Per-downstream guardrails — circuit breakers tuned per service (Personalize fails differently than Databricks), per-endpoint timeout budgets, full-jitter exponential backoff, bulkhead pools, client-side rate-limiting toward downstreams. Without these, one slow service hangs every worker thread; one outage cascades into client-quota exhaustion.
3. OpenSearch Query Patterns (search)
Impact: CRITICAL Description: Index, query, and pagination design — search_after cursor instead of deep from/size, _source filtering to bound payload size, function_score for blending personalization signals, request cache enablement, index aliases for blue/green, tie-breaker sort for stable cursors. Bad query design turns a 50ms search into a 5-second search at production data volume.
4. Result Blending & Personalization (blend)
Impact: HIGH Description: Mixing heterogeneous recommender outputs — score normalization across sources with different score scales (Personalize 0..1, Databricks logits, OpenSearch BM25), MMR diversity to avoid recommendation monocultures, cross-source dedup by canonical item ID, cold-start fallback for new users, anonymous-vs-personalized response splits.
5. Caching Strategy (cache)
Impact: HIGH Description: Redis-tier patterns for an API serving expensive ML/search calls — key design with user-segment isolation, stampede protection via SETNX locks + jittered TTLs, cache versioning keyed on ML model deploy, two-tier caching (process LRU + Redis), negative caching for empty results. Wrong here means thundering-herd refetches on TTL expiry and stale recommendations after a model swap.
6. Resilience & Partial Results (resilience)
Impact: HIGH Description: How the API degrades when some downstreams fail — partial: true response envelopes flagging which sources contributed, stale-from-Redis fallback when fresh fetch fails, default ranking fallback when all recommenders are down, per-source observability tags so downstream failures are visible in metrics. Without these, one Databricks blip becomes a 500 for the entire recommendation page.
7. Async & Concurrency (async)
Impact: MEDIUM-HIGH Description: Django-specific async patterns — async views vs. sync, sync_to_async/async_to_sync correctness, ORM async pitfalls, gunicorn worker model vs. uvicorn for IO-bound workloads, connection pool sizing per downstream, httpx.AsyncClient reuse across requests. Wrong here either negates async benefits (blocking-in-async) or corrupts request-scoped state.
8. API Response Design (api)
Impact: MEDIUM Description: DRF-level response shape — cursor pagination instead of page-number, serializer performance (select_related/prefetch_related/only/values), ETag/Cache-Control headers for CDN/proxy reuse, streaming responses for large result sets, response compression. These don't change throughput dramatically but compound across endpoints.
Compress Responses and Shape Payloads
A 50KB JSON response compresses to ~6KB with gzip and ~5KB with brotli — that's bandwidth, CDN cost, and time-to-first-byte cut by 8-10×. For an API serving millions of requests/day, compression alone is one of the largest cost levers. Pair with payload shaping: drop optional fields, use shorter keys for hot paths, and consider binary formats (msgpack, protobuf) for high-volume internal traffic.
Compression is mostly free CPU-wise on modern servers (~1-5% overhead) and the bandwidth savings dwarf it. Make sure it's enabled at the right layer.
Incorrect (no compression — sending uncompressed JSON):
# settings.py — no compression middleware
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
...
]
# Response: 50KB sent on the wire for every requestCorrect (enable Django's GZip middleware):
# settings.py — put GZipMiddleware near the top, AFTER security
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.gzip.GZipMiddleware", # ✅ compress responses
"django.contrib.sessions.middleware.SessionMiddleware",
...
]
# Response: 6KB sent on the wire (~10× smaller)
# Django auto-detects Accept-Encoding and only compresses when client supports itBetter: compress at the reverse proxy (nginx) — frees Django CPU:
# nginx.conf
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types
application/json
application/javascript
text/css text/plain text/xml
application/xml application/xml+rss
image/svg+xml;
gzip_comp_level 5; # 1-9; higher = more CPU, more compression. 5-6 is the sweet spot.
# Or brotli (better compression, requires the brotli nginx module):
brotli on;
brotli_types application/json application/javascript text/css text/plain;
brotli_comp_level 5;
brotli_static on;When the reverse proxy compresses, Django doesn't need its own middleware. Pick one — not both.
Shape payloads — drop fields the client doesn't render:
# ❌ Returning the full Product model in every list response
class ProductSerializer(ModelSerializer):
class Meta:
model = Product
fields = "__all__" # 30 fields, even when the UI shows 5
# ✅ Shape per use case
class ProductListSerializer(serializers.Serializer):
id = serializers.IntegerField()
title = serializers.CharField()
price = serializers.DecimalField(max_digits=10, decimal_places=2)
thumbnail_url = serializers.URLField()
in_stock = serializers.BooleanField()
# 5 fields × N items, 6× smaller than __all__Conditional fields via `fields` query param (sparse fieldsets):
class FlexibleSerializer(serializers.ModelSerializer):
"""Client passes ?fields=id,title to limit serialization."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request")
if request is None:
return
fields_param = request.query_params.get("fields")
if fields_param:
allowed = set(fields_param.split(","))
existing = set(self.fields)
for field in existing - allowed:
self.fields.pop(field)
class ProductSerializer(FlexibleSerializer):
class Meta:
model = Product
fields = "__all__"
# Client requests:
# GET /products?fields=id,title,price
# Response: only id, title, price for each — ~50% smallerFor high-volume internal APIs, consider msgpack:
import msgpack
class MsgPackRenderer(renderers.BaseRenderer):
media_type = "application/msgpack"
format = "msgpack"
def render(self, data, accepted_media_type=None, renderer_context=None):
return msgpack.packb(data, use_bin_type=True)
class MyView(APIView):
renderer_classes = [JSONRenderer, MsgPackRenderer]
# Internal consumers request: Accept: application/msgpack
# Response is binary — typically 30-50% smaller than JSON before compressionDrop nulls — JSON nulls are visual noise and bytes:
class CompactSerializer(serializers.Serializer):
def to_representation(self, instance):
data = super().to_representation(instance)
return {k: v for k, v in data.items() if v is not None}
# {"id": 1, "title": "...", "discount": null} → {"id": 1, "title": "..."}Be careful with this — some clients expect explicit null to distinguish "missing from response" from "present but null."
Use shorter keys for high-volume responses:
# ❌ Verbose keys multiplied across thousands of items
{
"product_identifier": 42,
"product_display_title": "...",
"current_market_price": 19.99,
}
# ✅ For internal feeds where size matters more than readability
{
"id": 42,
"title": "...",
"price": 19.99,
}For public APIs, prefer readability. For internal high-volume APIs (mobile app payloads), every byte counts.
Pre-compress static-ish responses (e.g., catalog data):
# Compute and cache the gzipped response in Redis
import gzip
async def get_compressed_catalog(segment: str) -> bytes:
cached = await redis.get(f"catalog:gzip:{segment}")
if cached:
return cached
items = await get_catalog(segment)
raw = json.dumps(items).encode()
compressed = gzip.compress(raw, compresslevel=6)
await redis.setex(f"catalog:gzip:{segment}", 3600, compressed)
return compressed
# Return pre-compressed bytes directly
async def catalog_view(request):
body = await get_compressed_catalog(_segment(request))
response = HttpResponse(body, content_type="application/json")
response["Content-Encoding"] = "gzip"
response["Vary"] = "Accept-Encoding"
return responseVerify compression is working:
curl -i -H "Accept-Encoding: gzip, deflate, br" https://api.example.com/recommendations
# Look for: Content-Encoding: gzip (or br)
# Look at: Content-Length — should be much smaller than the uncompressed sizeDon't compress already-compressed content:
Images, videos, gzipped archives. Compressing them again costs CPU for ~0 benefit. Nginx's gzip_types directive restricts to text formats by default — good.
Symptom of missing compression:
- Egress bandwidth costs disproportionate to RPS
- Mobile users complain about data usage
- TTFB high on responses > 10KB
Reference: Django — GZipMiddleware | nginx — gzip module | msgpack
Return Cursor-Based Pagination from DRF
DRF's default PageNumberPagination (?page=2&size=20) returns the same items shifted around as data changes between requests — the user sees duplicates and missing items on infinite scroll. CursorPagination solves this by encoding the position with the last item's sort key, returning an opaque cursor the client passes back.
For recommendation feeds, search results, and any list that changes between page fetches (which is most lists), use cursor pagination. The response shape is stable, future-proof, and aligns with how the OpenSearch backend already paginates (search_after, see [[search-use-search-after-not-from]]).
Incorrect (page-number pagination with shifting data):
# views.py
from rest_framework.pagination import PageNumberPagination
class StandardPageNumberPagination(PageNumberPagination):
page_size = 20
max_page_size = 100
class RecommendationsList(ListAPIView):
pagination_class = StandardPageNumberPagination
# Response:
# {
# "count": 1037,
# "next": "https://api.example.com/recommendations?page=3",
# "previous": "...page=1",
# "results": [...]
# }
# Problem: new items inserted between page 1 and page 2 → user sees duplicatesCorrect (cursor pagination — stable across writes):
from rest_framework.pagination import CursorPagination
class RecommendationsCursorPagination(CursorPagination):
page_size = 20
max_page_size = 100
ordering = "-created_at" # tiebreaker — see [[search-stable-tiebreaker-sort]]
cursor_query_param = "cursor"
class RecommendationsList(ListAPIView):
pagination_class = RecommendationsCursorPagination
queryset = Recommendation.objects.all()
# Response:
# {
# "next": "https://api.example.com/recommendations?cursor=cD0yMDI2LTA1LTE5",
# "previous": null,
# "results": [...]
# }
# Opaque cursor; stable across inserts; no skipping/duplicatingFor OpenSearch-backed endpoints (search_after under the hood):
DRF's CursorPagination is for queryset-based endpoints. For OpenSearch-backed endpoints, build a custom paginator that wraps search_after:
import base64
import json
from rest_framework.pagination import BasePagination
from rest_framework.response import Response
class OpenSearchCursorPagination(BasePagination):
"""Opaque cursor pagination backed by OpenSearch search_after."""
page_size = 20
max_page_size = 100
def paginate(self, query_fn, request, view=None):
"""Call this from your view; query_fn accepts (cursor, size) → (items, next_cursor_sort_values)."""
size = self._get_size(request)
cursor = self._decode(request.query_params.get("cursor"))
items, next_sort = query_fn(cursor=cursor, size=size)
self._next_cursor = self._encode(next_sort) if next_sort else None
self._items = items
self._request = request
return items
def get_paginated_response(self, data):
next_url = self._build_next_url() if self._next_cursor else None
return Response({
"items": data,
"next": next_url,
"page_size": len(data),
})
def _get_size(self, request) -> int:
size = request.query_params.get("size")
try:
size = int(size) if size else self.page_size
except ValueError:
size = self.page_size
return min(max(1, size), self.max_page_size)
def _encode(self, sort_values) -> str:
return base64.urlsafe_b64encode(
json.dumps(sort_values).encode()
).decode().rstrip("=")
def _decode(self, cursor: str | None):
if not cursor:
return None
try:
padded = cursor + "=" * (-len(cursor) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
except (ValueError, json.JSONDecodeError):
return None # malformed cursor → start over
def _build_next_url(self) -> str:
from urllib.parse import urlencode, urlparse, urlunparse, parse_qs
parts = urlparse(self._request.build_absolute_uri())
qs = parse_qs(parts.query)
qs["cursor"] = [self._next_cursor]
return urlunparse(parts._replace(query=urlencode(qs, doseq=True)))Usage in the view:
class SearchView(APIView):
pagination_class = OpenSearchCursorPagination
def get(self, request):
paginator = self.pagination_class()
items = paginator.paginate(
query_fn=lambda cursor, size: search_opensearch(
query=request.query_params["q"], cursor=cursor, size=size,
),
request=request, view=self,
)
return paginator.get_paginated_response([self._serialize(i) for i in items])Response shape consistency across endpoints:
Use the same envelope across the API so clients don't branch:
# Standard response shape for all paginated endpoints
{
"items": [...],
"next": "...?cursor=abc", # or null on last page
"page_size": 20,
}
# For homepage feeds with multiple sections
{
"sections": [
{"name": "for_you", "items": [...], "next": "...?cursor=..."},
{"name": "trending", "items": [...], "next": "...?cursor=..."},
]
}Don't expose total counts on cursor-paginated endpoints:
Counting all matches is expensive (OpenSearch needs to count every shard; SQL needs a full count query). Cursor pagination doesn't need totals — the existence of next tells the client there's more. Drop the count field from response.
If users really need a total ("Showing X of Y results"), make it a separate endpoint that's cached aggressively or returns an approximate count (total_approximate: true).
Encode cursors opaquely:
# ❌ Exposing sort fields lets clients fabricate cursors and skip security checks
?cursor=created_at:2026-05-19,id:abc123
# ✅ Opaque base64-encoded JSON — clients can only echo it back
?cursor=eyJzb3J0IjpbIjIwMjYtMDUtMTkiLCJhYmMxMjMiXX0Sign cursors if they carry sensitive sort values:
import hmac, hashlib
def sign_cursor(sort_values, secret: bytes) -> str:
payload = json.dumps(sort_values).encode()
sig = hmac.new(secret, payload, hashlib.sha256).digest()[:8]
return base64.urlsafe_b64encode(payload + b"|" + sig).decode().rstrip("=")This prevents clients from constructing arbitrary cursors to scan data they shouldn't see.
Symptom of bad pagination:
- "User reports seeing the same item on consecutive pages" — page-number pagination + writes
- "Search page 100 returns 500" —
from + size > 10000 - "Total count query takes 5 seconds" — exposing
counton every page
Reference: DRF — Pagination | Use The Index, Luke — Pagination
Set ETag and Cache-Control for CDN/Client Reuse
A response without Cache-Control headers can't be cached by CDNs or browsers — every request hits Django. A response with ETag lets clients re-validate (sending If-None-Match) and get a 304 Not Modified (no body) instead of the full response. For an API serving largely-stable data (popular feeds, search-result snapshots), these headers cut Django load to <10% of the otherwise-needed capacity.
The trick is correctness: don't set long TTLs on per-user data, don't cache partial responses ([[resilience-partial-response-envelope]]) as long as fresh ones, and use the Vary header to prevent cross-segment serving.
Incorrect (no caching headers — every request hits Django):
def popular_view(request):
items = get_popular_items()
return JsonResponse({"items": items})
# CDN: can't cache, sends every request to origin
# Browser: must re-fetch every page loadCorrect (ETag + Cache-Control + Vary):
import hashlib
import json
def popular_view(request):
items = get_popular_items(segment=_segment(request))
body = {"items": items, "generated_at": ...}
body_bytes = json.dumps(body, sort_keys=True).encode()
etag = '"' + hashlib.blake2b(body_bytes, digest_size=8).hexdigest() + '"'
# Conditional GET — if the client sent If-None-Match, compare
if request.META.get("HTTP_IF_NONE_MATCH") == etag:
return HttpResponse(status=304) # no body needed; client uses its cache
response = HttpResponse(body_bytes, content_type="application/json", status=200)
response["ETag"] = etag
response["Cache-Control"] = "public, max-age=60, stale-while-revalidate=300"
response["Vary"] = "Accept-Language, Authorization"
return responseCache-Control directives (composition matters):
| Directive | Effect |
|---|---|
public | CDN can cache |
private | Only the user's browser caches; CDN can't |
max-age=N | Fresh for N seconds |
s-maxage=N | Fresh for N seconds for shared caches (CDN); overrides max-age for them |
stale-while-revalidate=N | Serve stale up to N seconds while revalidating in background |
stale-if-error=N | Serve stale up to N seconds if origin returns 5xx |
no-cache | Must revalidate before reuse (still cacheable!) |
no-store | Never cache anywhere — for sensitive data |
must-revalidate | Don't serve stale; revalidate when expired |
immutable | Content will never change at this URL (good for versioned assets) |
Common combinations:
| Endpoint type | Cache-Control |
|---|---|
| Anonymous popular feed | public, max-age=60, s-maxage=300, stale-while-revalidate=600 |
| Logged-in personalized | private, max-age=30, stale-while-revalidate=120 |
| Versioned static asset (image, JS) | public, max-age=31536000, immutable |
| Sensitive (account, billing) | private, no-cache, no-store, must-revalidate |
| Partial response | private, max-age=30, stale-while-revalidate=60 (short TTL) |
Vary header — prevent cross-context serving:
The Vary header tells CDNs which request headers affect the response. Without it, CDN may return the English response to an Italian user, or an authenticated response to an anonymous user:
response["Vary"] = "Accept-Language, Authorization, X-Tenant-Id"Common Vary values:
Accept-Language— localized responsesAuthorization— anonymous vs authenticatedCookie— when cookies affect the response (be careful: cache miss rate explodes)Accept-Encoding— for compressed responses (most servers add this automatically)X-*-Id— for tenant or segment headers
Don't include `Cookie` in Vary unless necessary:
Vary: Cookie means every distinct cookie value gets its own cache entry. Since cookies are typically per-user, this defeats CDN caching entirely. If only certain cookies affect the response, use a more specific header (e.g., a X-User-Segment header set by your edge logic).
Generate ETags from content hash, not random:
# Hash the response body (deterministic)
etag = '"' + hashlib.blake2b(body_bytes, digest_size=8).hexdigest() + '"'
# Or from a version + timestamp
etag = f'"v23-{int(updated_at.timestamp())}"'
# Or weak ETag (less strict comparison)
etag = f'W/"abc123"' # weak — semantic equivalence, not byte-for-byteCache the ETag itself for expensive computations:
async def search_view(request):
cache_key = f"search:{hash(request.GET['q'])}:etag"
cached_etag = await redis.get(cache_key)
if cached_etag and request.META.get("HTTP_IF_NONE_MATCH") == cached_etag.decode():
return HttpResponse(status=304)
items = await opensearch_search(request.GET["q"])
body = {"items": items}
body_bytes = json.dumps(body, sort_keys=True).encode()
etag = '"' + hashlib.blake2b(body_bytes, digest_size=8).hexdigest() + '"'
await redis.setex(cache_key, 300, etag)
response = HttpResponse(body_bytes, status=200)
response["ETag"] = etag
response["Cache-Control"] = "public, max-age=60, stale-while-revalidate=300"
return responseDon't cache responses with auth-dependent data without `Vary: Authorization`:
# ❌ CDN may serve User A's account data to User B
response["Cache-Control"] = "public, max-age=300"
# (no Vary)
# ✅ Either private OR vary by auth
response["Cache-Control"] = "private, max-age=300" # user-only cache
# or
response["Cache-Control"] = "public, max-age=300"
response["Vary"] = "Authorization" # CDN keys by auth headerUse 304 to save bandwidth, not 200 with same body:
# ❌ Sending the same response with same ETag every time wastes bandwidth
# ✅ Return 304 when If-None-Match matches
if request.META.get("HTTP_IF_NONE_MATCH") == etag:
return HttpResponse(status=304) # no body — client uses its cached copySymptom of missing cache headers:
- "Why is the API hit so much for popular endpoints?" — no CDN cacheability
- CDN hit ratio < 30% on cacheable endpoints
- Mobile users complain about data usage for repeated visits
Reference: MDN — Cache-Control | MDN — ETag | Django — HTTP shortcut decorators
Use select_related, prefetch_related, and only in DRF Serializers
A DRF ModelSerializer with a nested UserSerializer field will issue one query per row to fetch the user — the N+1 problem. For a 20-item recommendations response, that's 21 database queries instead of 1. DRF doesn't auto-detect related fields you serialize; you have to specify what to join via select_related/prefetch_related on the queryset.
only(...) further bounds the work: by default, every column is fetched. For a Product with 30 columns where the serializer renders 5, only("id", "title", "price", "thumbnail_url", "in_stock") cuts row size 6× and eliminates expensive column fetches (e.g., serialized JSON, large text fields).
Incorrect (N+1 serialization, full-row fetch):
# serializers.py
class RecommendationSerializer(serializers.ModelSerializer):
product = ProductSerializer() # nested
user = UserSerializer() # nested
class Meta:
model = Recommendation
fields = ["id", "user", "product", "score", "created_at"]
# views.py
class RecommendationsList(ListAPIView):
serializer_class = RecommendationSerializer
queryset = Recommendation.objects.all() # ❌ no joins, full columns
# For 20 results:
# 1 query: SELECT * FROM recommendations LIMIT 20
# 20 queries: SELECT * FROM products WHERE id = ? (per row!)
# 20 queries: SELECT * FROM users WHERE id = ?
# Total: 41 queriesCorrect (eager joins + column projection):
class RecommendationsList(ListAPIView):
serializer_class = RecommendationSerializer
def get_queryset(self):
return (
Recommendation.objects
.select_related("product", "user") # ✅ join in 1 query
.only(
"id", "score", "created_at",
"product__id", "product__title", "product__price", "product__thumbnail_url",
"user__id", "user__name", "user__avatar_url",
) # ✅ project only needed columns
)
# 1 query total: SELECT recommendations.*, products.*, users.* FROM ... JOIN ... JOIN ...
# 4× faster, smaller transfer, less DB loadselect_related vs prefetch_related:
| Method | Use for | Generates |
|---|---|---|
select_related | ForeignKey, OneToOne (single related row) | SQL JOIN |
prefetch_related | ManyToMany, reverse ForeignKey (many related rows) | Second query with WHERE id IN (...) |
# For many-to-one (one product belongs to one category):
qs = Product.objects.select_related("category")
# For many-to-many (one product has many tags):
qs = Product.objects.prefetch_related("tags")
# Combined:
qs = (
Product.objects
.select_related("category", "brand") # 1:1 / many:1 → joins
.prefetch_related("tags", "variants") # 1:many / m:m → in-clauses
.only("id", "title", "price", "category__name", "brand__name")
)For reverse relations with constraints, use Prefetch:
from django.db.models import Prefetch
# Fetch only active variants for each product
qs = Product.objects.prefetch_related(
Prefetch(
"variants",
queryset=Variant.objects.filter(is_active=True).only("id", "name", "stock"),
to_attr="active_variants",
)
)
# Each product now has product.active_variants
# Without the to_attr, it would shadow product.variants (the manager)Use `values_list` for trivial responses (skip serializer overhead):
For endpoints that just return a list of IDs or simple tuples:
# Slow: full ORM + serializer
ids = list(Product.objects.filter(...).values("id"))
# Fast: direct values_list (no model instantiation)
ids = list(Product.objects.filter(...).values_list("id", flat=True))
# Faster still on huge lists: queryset.iterator(chunk_size=1000)Detect N+1 in development:
# requirements-dev.txt
django-debug-toolbar
# settings.py (dev only)
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
# Or use nplusone for automated detection
INSTALLED_APPS += ["nplusone.ext.django"]
MIDDLEWARE += ["nplusone.ext.django.NPlusOneMiddleware"]
NPLUSONE_RAISE = True # fails tests on N+1nplusone raises an exception on every detected N+1, surfacing them in CI before they reach production.
Avoid `to_representation` overrides that re-query:
# ❌ Subtle N+1: to_representation calls .first() on a related queryset
class ProductSerializer(serializers.ModelSerializer):
primary_image = serializers.SerializerMethodField()
def get_primary_image(self, obj):
return obj.images.first().url # ← one query per product
# ✅ Use Prefetch + first item in Python
class ProductSerializer(serializers.ModelSerializer):
primary_image = serializers.SerializerMethodField()
def get_primary_image(self, obj):
# Relies on Prefetch having already loaded obj.images
images = list(obj.images.all()) # uses prefetched data
return images[0].url if images else NoneFor large nested responses, consider flat serialization:
When the nested object is heavy (lots of fields, deep nesting), flatten it:
# Heavy nested
{"user": {"id": ..., "name": ..., "email": ..., "preferences": {...}}}
# Flat with just the fields needed
{"user_id": ..., "user_name": ...}
# Implement with SerializerMethodField:
class RecommendationSerializer(serializers.Serializer):
user_id = serializers.IntegerField(source="user.id")
user_name = serializers.CharField(source="user.name")
# ... no nested UserSerializerSymptom of N+1 in serialization:
- Endpoint latency scales linearly with
page_size - Database query count > 5 per simple list endpoint
- "Single row fast, list slow" mismatch
Reference: Django — Database access optimization | DRF — Optimizing serialization | nplusone
Apply Throttling per User and per Expensive Endpoint
A logged-in user that issues 200 recommendation requests per second (intentional scraper, broken client, or test code left running) can exhaust your Personalize/Databricks quota for everyone else. Without throttling, one misbehaving caller becomes everyone's outage. DRF's Throttle classes apply per-user and per-endpoint limits at the Django layer — cheaper than letting requests flow through to expensive downstreams.
Throttling is the user-facing complement to [[protect-client-side-rate-limit]] (which throttles outbound to downstreams). Use both: API throttling bounds which users can call you frequently; outbound throttling bounds how often you call downstreams.
Incorrect (no throttling — one user can drain quota for everyone):
class RecommendationsView(APIView):
def get(self, request):
items = expensive_recommendation_call(request.user.id)
return Response({"items": items})
# A bot can hit this 1000 RPS per user; each call burns Personalize quotaCorrect (per-user throttle + per-endpoint anonymous throttle):
# settings.py
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.UserRateThrottle",
"rest_framework.throttling.AnonRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"user": "1000/hour", # default — broad cap
"anon": "100/hour", # anonymous traffic — tighter
"recommendations_user": "60/minute", # specific expensive endpoint
"recommendations_anon": "20/minute",
"search_user": "120/minute",
"search_anon": "30/minute",
},
}
# views.py
from rest_framework.throttling import UserRateThrottle, AnonRateThrottle
class RecommendationsUserThrottle(UserRateThrottle):
scope = "recommendations_user"
class RecommendationsAnonThrottle(AnonRateThrottle):
scope = "recommendations_anon"
class RecommendationsView(APIView):
throttle_classes = [RecommendationsUserThrottle, RecommendationsAnonThrottle]
def get(self, request):
items = expensive_recommendation_call(request.user.id)
return Response({"items": items})Throttle by tier — paying customers get higher limits:
class TieredUserThrottle(UserRateThrottle):
"""Higher rate limit for paying users."""
def get_rate(self):
request = self._request_context
if request and request.user.is_authenticated:
if request.user.is_premium:
return "300/minute"
if request.user.is_paid:
return "120/minute"
return "60/minute" # free tierThrottle by IP for anonymous (prevent single-IP abuse):
class IpThrottle(AnonRateThrottle):
"""Per-IP throttle for anonymous endpoints — different from per-anon-session."""
scope = "ip"
def get_ident(self, request):
# Use the trusted forwarded IP (depends on your proxy setup)
return request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() \
or request.META.get("REMOTE_ADDR")For burst-tolerant traffic, use a token bucket (not DRF's simple bucket):
DRF's throttle is a sliding window — once the bucket is full, the limit is hard. For traffic that has bursts but stays within average, a token bucket is more permissive:
# Custom DRF throttle backed by Redis token bucket
class TokenBucketThrottle:
rate = 60 # tokens per minute (sustained rate)
burst = 20 # extra tokens for short bursts
def allow_request(self, request, view):
user_id = request.user.id if request.user.is_authenticated else \
request.META.get("REMOTE_ADDR")
key = f"throttle:{view.__class__.__name__}:{user_id}"
return redis_token_bucket_acquire(key, self.rate, self.burst)Throttle by request cost, not just count:
Some endpoints are 10× more expensive than others (deep search, full personalization). Charge multiple "tokens" per expensive call:
class CostBasedThrottle(BaseThrottle):
def allow_request(self, request, view):
cost = getattr(view, "throttle_cost", 1)
return redis_token_bucket_charge(self._key(request), cost)
class DeepSearchView(APIView):
throttle_classes = [CostBasedThrottle]
throttle_cost = 5 # this view costs 5 tokens per call
class CheapEndpointView(APIView):
throttle_classes = [CostBasedThrottle]
throttle_cost = 1Return Retry-After on 429s — clients can honor it ([[protect-honor-retry-after-header]]):
# DRF does this automatically when throttle.wait() returns a value
class RecommendationsUserThrottle(UserRateThrottle):
scope = "recommendations_user"
# DRF automatically sets:
# 429 Too Many Requests
# Retry-After: <seconds>Bypass throttling for internal traffic (with care):
class ExternalOnlyThrottle(UserRateThrottle):
"""Skip throttle for requests from internal services (e.g., via a shared secret)."""
def allow_request(self, request, view):
if request.META.get("HTTP_X_INTERNAL_API_KEY") == settings.INTERNAL_API_KEY:
return True
return super().allow_request(request, view)Don't throttle on health-check / status endpoints:
class HealthView(APIView):
throttle_classes = [] # health checks must always work
permission_classes = [] # no auth either
def get(self, request):
return Response({"status": "ok"})Throttled health checks make load balancers mark instances unhealthy on rate-limit spikes — wrong signal.
Observability — track throttle hits:
class ObservableThrottle(UserRateThrottle):
def throttle_failure(self):
metrics.increment("throttle.exceeded", tags={
"scope": self.scope,
"view": getattr(self.view, "__class__", None).__name__,
})
return super().throttle_failure()A spike in throttle.exceeded for one scope often surfaces abuse or a broken client.
Don't throttle so aggressively that legitimate clients break:
Pick rates by measuring actual traffic from your highest-volume legitimate users (analytics service, mobile app, automated tests) and setting limits well above their natural rate. Throttling should catch outliers, not bound average traffic.
Symptom of missing throttle:
- One user/IP causes API-wide latency spikes
- Personalize/Databricks bills correlate with traffic from a single source
- Abuse complaints from competitors trying to scrape
Reference: DRF — Throttling | django-ratelimit | Stripe — Rate Limiters
Cancel In-Flight Work When the Client Disconnects
A user navigates away mid-request. Their browser closes the connection. The Django worker is still happily calling Personalize + Databricks + OpenSearch + blending — burning ML inference cost, database connections, and downstream rate-limit quota on a response no one will see. Worse, the worker remains busy until everything completes, so the next request waits.
Under ASGI, Django can detect the disconnect and cancel the request. The pattern: check await request.is_disconnected() periodically, or rely on asyncio.CancelledError propagation. For downstream calls, propagate cancellation so AWS Personalize/Databricks stop processing too (where supported).
Incorrect (no disconnect detection — work continues after client gone):
async def recommendations_view(request):
# User closes browser after 100ms. The view doesn't know.
user_id = request.user.id
personalize, affinity, databricks = await asyncio.gather(
personalize_client.get(user_id), # still running
affinity_client.get(user_id), # still running
databricks_client.invoke(user_id), # still running
)
items = blend_results([personalize, affinity, databricks])
return JsonResponse(items)
# Worker holds resources for the full duration. Wasted.Correct (check disconnect; propagate cancellation):
async def recommendations_view(request):
user_id = request.user.id
# Wrap the fan-out so we can race it against disconnect detection
work_task = asyncio.create_task(_fanout_and_blend(user_id))
disconnect_task = asyncio.create_task(_wait_for_disconnect(request))
done, pending = await asyncio.wait(
[work_task, disconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
if disconnect_task in done:
# Client gone — cancel the work
work_task.cancel()
try:
await work_task
except (asyncio.CancelledError, BaseException):
pass
# No response needed; the connection is closed
return HttpResponse(status=499) # 499 = client closed request (nginx convention)
# Work completed first — cancel the disconnect watcher
disconnect_task.cancel()
items = await work_task
return JsonResponse({"items": items})
async def _wait_for_disconnect(request):
while True:
if await request.is_disconnected():
return
await asyncio.sleep(0.1) # poll every 100ms
async def _fanout_and_blend(user_id: str):
results = await asyncio.gather(
personalize_client.get(user_id),
affinity_client.get(user_id),
databricks_client.invoke(user_id),
return_exceptions=True,
)
return blend_results(results)Propagate cancellation to HTTP clients:
httpx.AsyncClient honors asyncio.CancelledError — when the parent task is cancelled, in-flight requests are aborted. The downstream server may or may not stop work on disconnect (depends on its own implementation), but at least your worker frees up immediately:
async def _fanout_and_blend(user_id: str):
# When this task is cancelled, httpx aborts the in-flight requests
return await asyncio.gather(
personalize_client.get(user_id),
databricks_client.invoke(user_id),
return_exceptions=True,
)For boto3 (sync, no async cancellation):
# boto3 doesn't honor cancellation. asyncio.to_thread propagates cancellation to the
# Python task but the boto3 call continues until completion.
# Best effort: use a short timeout in the boto3 client config so it can't run forever.
import boto3
from botocore.config import Config
_personalize = boto3.client("personalize-runtime", config=Config(
connect_timeout=1.0,
read_timeout=2.0, # caps the worst case
))Use ASGI lifespan for graceful shutdown:
# settings/asgi.py
import asyncio
from django.core.asgi import get_asgi_application
from django.conf import settings
django_application = get_asgi_application()
async def application(scope, receive, send):
if scope["type"] == "lifespan":
while True:
message = await receive()
if message["type"] == "lifespan.startup":
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
# Cancel background tasks here
await _shutdown_background_tasks()
await send({"type": "lifespan.shutdown.complete"})
return
else:
await django_application(scope, receive, send)Don't over-engineer for low-stakes endpoints:
For most recommendation endpoints, the full work is <1s. The user's window for disconnecting is small. Implementing disconnect detection is worth it only when:
- The work is expensive enough to matter (ML inference calls, large query fan-outs)
- p95 latency is high enough that abandonment is common (>500ms)
- You're hitting rate limits or quota constraints
For sub-100ms endpoints, skip the complexity.
Tradeoff with caching:
Cancelling a downstream call means the cache won't be populated for the next user. Sometimes you want the call to complete even after the original user disconnected, because the next user is about to ask the same thing. For shared-cache endpoints (popular feed, common search), let the request finish:
async def search_view(request):
if _is_shared_cache_endpoint(request):
# Don't cancel — let the work warm the cache for others
return await _search(request)
# User-specific endpoint — cancel on disconnect
return await _search_cancellable(request)Don't cancel partial database writes (data integrity):
If your view writes to the DB then returns, cancellation between write and response means the data is committed but the user thinks the request failed. They retry, you double-write. Either avoid cancellation for write paths, or use transactions + idempotency keys.
Symptom of missing cancellation:
- Worker pool exhaustion after a traffic burst with high abandon rate
- "Personalize bill is higher than expected" — wasted calls on abandoned requests
- p99 latency dragged up by old requests still processing
Reference: Django — Async views and request handling | Python — asyncio cancellation
Use contextvars for Request-Scoped State Across Async Calls
In sync Django, request-scoped state (the current user, request ID, locale) often lives in thread-local storage (threading.local()). This works because each request gets a dedicated thread. In async views, one thread handles many concurrent requests — thread-local storage spills across requests, and a value set by request A is visible to request B running on the same thread between awaits.
contextvars.ContextVar is the asyncio-aware equivalent: variables are bound to the current task / async context, so each request has its own logical "thread-local" storage even when threads are shared. Use this for request IDs, current user, deadlines, locale — anything that needs to travel through nested async calls without explicit parameter threading.
Incorrect (thread-local in async — cross-request contamination):
# auth.py
import threading
_thread_local = threading.local()
def set_current_user(user):
_thread_local.user = user # ❌ shared across async requests
def get_current_user():
return getattr(_thread_local, "user", None)
# middleware.py
async def auth_middleware(get_response, request):
set_current_user(request.user)
response = await get_response(request) # ← awaits here; other requests run on same thread
return response
# Inside another concurrent request, get_current_user() returns wrong userCorrect (contextvars — isolated per async task):
# auth.py
import contextvars
_current_user: contextvars.ContextVar = contextvars.ContextVar("current_user", default=None)
def set_current_user(user):
return _current_user.set(user) # returns a Token for restoring
def get_current_user():
return _current_user.get()
def reset_current_user(token):
_current_user.reset(token)
# middleware.py
async def auth_middleware(get_response, request):
token = set_current_user(request.user)
try:
response = await get_response(request)
finally:
reset_current_user(token)
return response
# Each request has its own value; no leakage across concurrent requestsCommon request-scoped contextvars:
# context.py
import contextvars
import time
request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("request_id", default=None)
current_user: contextvars.ContextVar = contextvars.ContextVar("current_user", default=None)
request_deadline: contextvars.ContextVar[float | None] = contextvars.ContextVar(
"request_deadline", default=None
)
locale: contextvars.ContextVar[str] = contextvars.ContextVar("locale", default="en-US")
# Set them in middleware
async def context_middleware(get_response, request):
tokens = [
request_id.set(request.headers.get("X-Request-ID", str(uuid.uuid4()))),
current_user.set(request.user),
request_deadline.set(time.monotonic() + 0.5),
locale.set(request.META.get("HTTP_ACCEPT_LANGUAGE", "en-US").split(",")[0]),
]
try:
return await get_response(request)
finally:
for t in tokens:
try:
t.var.reset(t)
except LookupError:
passUse them in deeply nested code (no parameter threading):
# clients/personalize.py — needs the request deadline but isn't passed it explicitly
async def get_recommendations(user_id: str):
deadline = request_deadline.get()
timeout = max(0.01, deadline - time.monotonic()) if deadline else 2.0
return await client.post(url, json={...}, timeout=timeout)Pass context to fire-and-forget tasks (or it's lost):
# By default, asyncio.create_task inherits the current context
async def search_view(request):
# Context is set by middleware
asyncio.create_task(track_event("search")) # ✅ inherits context
return JsonResponse({...})
async def track_event(event):
user = current_user.get() # works — inherited from request context
req_id = request_id.get()
await analytics.send(event, user_id=user.id, request_id=req_id)Don't pass context to long-lived background workers (different lifecycle):
# Celery worker doesn't share the request's contextvars
@shared_task
def track_event_celery(event, user_id, request_id):
# Pass values explicitly — they were captured at task submission time
analytics.send(event, user_id=user_id, request_id=request_id)
# When scheduling, capture context explicitly:
track_event_celery.delay(
event="search",
user_id=current_user.get().id,
request_id=request_id.get(),
)For OpenTelemetry-style propagation:
Tracing libraries (OpenTelemetry, Datadog APM) already use contextvars under the hood for trace IDs. The pattern above is the same — just don't reinvent it for trace propagation; use the tracing library's API.
Don't store mutable state in contextvars:
# ❌ Storing a dict and mutating it — surprises on concurrent updates
request_state: contextvars.ContextVar[dict] = contextvars.ContextVar("state", default={})
request_state.get()["count"] += 1 # mutates the shared default dict across requests!
# ✅ Set a new dict each time
state = dict(request_state.get())
state["count"] = state.get("count", 0) + 1
request_state.set(state)The default value is shared across all reads until .set() is called.
Thread-aware context bridging (when calling sync code from async):
# sync_to_async with thread_sensitive=True respects contextvars correctly
# (provided you've set them via contextvars, not threading.local)
result = await sync_to_async(sync_function, thread_sensitive=True)(args)
# sync_function can call current_user.get() and see the right valueSymptom of missing contextvars (using thread-locals in async):
- Random "wrong user" or "wrong request ID" in logs
- Sporadic "cross-request data leakage" reported in security review
- Tests pass locally (one request at a time) but fail under concurrent load
Reference: Python — contextvars | PEP 567 — Context Variables
Related skills
FAQ
What does django-recommender-search-backend-patterns do?
django-recommender-search-backend-patterns is a Claude Code skill in the Backend & APIs category.
When should I use django-recommender-search-backend-patterns?
When you need to helps with backend & apis tasks during ai-assisted development, or when django-recommender-search-backend-patterns is a claude code skill in the backend & apis category.
What are the main capabilities?
django-recommender-search-backend-patterns; Backend & APIs; AI-coding skill.