Remove CDN Cache-Control headers; fix cross-language sort bug
Mizan's protocol layers (origin Redis cache, Edge Worker) handle caching
autonomously. The origin emits Cache-Control: no-store on ALL responses —
browsers and non-Mizan intermediaries must not cache. The Edge Worker
controls CDN caching via cf object, independent of origin headers.
Also fixes:
- TS localeCompare → byte-order sort (localeCompare is locale-sensitive,
would produce different HMAC keys for non-ASCII params vs Python)
- Python cache_purge: empty {} params no longer treated as falsy
(was inconsistent with JS where {} is truthy)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
- **Shapes** — Pydantic + django-readers for typed query projections
|
- **Shapes** — Pydantic + django-readers for typed query projections
|
||||||
- **WebSocket channels** — real-time bidirectional communication
|
- **WebSocket channels** — real-time bidirectional communication
|
||||||
- **Codegen** — generates typed React providers, hooks, mutations from schema
|
- **Codegen** — generates typed React providers, hooks, mutations from schema
|
||||||
- **CDN-ready headers** — `Cache-Control`, deterministic JSON on context GETs, `no-store` on mutations
|
- **Protocol-managed caching** — `no-store` on all origin responses, deterministic JSON on context GETs
|
||||||
|
|
||||||
### Next: X-Mizan-Invalidate Header
|
### Next: X-Mizan-Invalidate Header
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ The protocol is the product. Two invalidation transports. Every endpoint CDN-rea
|
|||||||
GET /api/mizan/ctx/<name>/?param=value
|
GET /api/mizan/ctx/<name>/?param=value
|
||||||
|
|
||||||
200 OK
|
200 OK
|
||||||
Cache-Control: public, max-age=0, s-maxage=31536000
|
Cache-Control: no-store
|
||||||
|
|
||||||
{
|
{
|
||||||
"function_a": { ... },
|
"function_a": { ... },
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ def cache_purge(
|
|||||||
Broad purge (no params): scans by key prefix "ctx:{context}:*".
|
Broad purge (no params): scans by key prefix "ctx:{context}:*".
|
||||||
This is a rare operation (Tier 3 fallback in invalidation).
|
This is a rare operation (Tier 3 fallback in invalidation).
|
||||||
"""
|
"""
|
||||||
if params and secret:
|
if params is not None and len(params) > 0 and secret:
|
||||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||||
return 1 if backend.delete(key) else 0
|
return 1 if backend.delete(key) else 0
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -874,10 +874,7 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
)
|
)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
response = HttpResponse(cached, content_type="application/json")
|
response = HttpResponse(cached, content_type="application/json")
|
||||||
if isinstance(effective_cache, int):
|
response["Cache-Control"] = "no-store"
|
||||||
response["Cache-Control"] = f"public, max-age=0, s-maxage={effective_cache}"
|
|
||||||
else:
|
|
||||||
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
|
|
||||||
response["X-Mizan-Cache"] = "HIT"
|
response["X-Mizan-Cache"] = "HIT"
|
||||||
return response
|
return response
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -902,13 +899,9 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
|||||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
# Deterministic JSON (sorted keys) for consistent cache keys
|
||||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||||
|
|
||||||
# CDN-ready headers based on effective cache policy
|
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
|
||||||
if effective_cache is False:
|
# The browser and non-Mizan intermediaries must not cache.
|
||||||
response["Cache-Control"] = "no-store"
|
response["Cache-Control"] = "no-store"
|
||||||
elif isinstance(effective_cache, int):
|
|
||||||
response["Cache-Control"] = f"public, max-age=0, s-maxage={effective_cache}"
|
|
||||||
else:
|
|
||||||
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
|
|
||||||
|
|
||||||
# Store in origin-side cache (skip if cache=False)
|
# Store in origin-side cache (skip if cache=False)
|
||||||
if use_cache:
|
if use_cache:
|
||||||
|
|||||||
@@ -1019,9 +1019,8 @@ class ServerDrivenInvalidationTests(TestCase):
|
|||||||
self.assertIn("team_info", data)
|
self.assertIn("team_info", data)
|
||||||
self.assertEqual(data["team_info"]["name"], "team_3")
|
self.assertEqual(data["team_info"]["name"], "team_3")
|
||||||
|
|
||||||
# CDN-ready headers
|
# Mizan handles caching via its protocol; origin emits no-store
|
||||||
self.assertIn("public", response["Cache-Control"])
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
self.assertIn("s-maxage", response["Cache-Control"])
|
|
||||||
|
|
||||||
def test_context_error_not_cached(self):
|
def test_context_error_not_cached(self):
|
||||||
"""Context fetch errors must not be cached."""
|
"""Context fetch errors must not be cached."""
|
||||||
@@ -1785,9 +1784,8 @@ class HTTPIntegrationTests(TestCase):
|
|||||||
self.assertEqual(data["user_profile"]["name"], "user_5")
|
self.assertEqual(data["user_profile"]["name"], "user_5")
|
||||||
self.assertEqual(data["user_orders"]["count"], 50)
|
self.assertEqual(data["user_orders"]["count"], 50)
|
||||||
|
|
||||||
# CDN headers
|
# Mizan handles caching; origin emits no-store
|
||||||
self.assertIn("public", response["Cache-Control"])
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
self.assertIn("s-maxage", response["Cache-Control"])
|
|
||||||
|
|
||||||
def test_context_fetch_string_to_int_coercion(self):
|
def test_context_fetch_string_to_int_coercion(self):
|
||||||
"""Query params arrive as strings. Pydantic must coerce to int."""
|
"""Query params arrive as strings. Pydantic must coerce to int."""
|
||||||
@@ -2142,16 +2140,10 @@ class EdgeCompatibilityTests(TestCase):
|
|||||||
|
|
||||||
# ── Cache-Control correctness ───────────────────────────────────────────
|
# ── Cache-Control correctness ───────────────────────────────────────────
|
||||||
|
|
||||||
def test_context_get_is_cacheable(self):
|
def test_context_get_no_store(self):
|
||||||
"""Context GET has Cache-Control that allows CDN caching."""
|
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
|
||||||
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||||
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
cc = response["Cache-Control"]
|
|
||||||
self.assertIn("public", cc)
|
|
||||||
self.assertIn("s-maxage", cc)
|
|
||||||
# Must NOT have no-store or private
|
|
||||||
self.assertNotIn("no-store", cc)
|
|
||||||
self.assertNotIn("private", cc)
|
|
||||||
|
|
||||||
def test_mutation_post_not_cacheable(self):
|
def test_mutation_post_not_cacheable(self):
|
||||||
"""Mutation POST has no-store. CDN must never cache mutations."""
|
"""Mutation POST has no-store. CDN must never cache mutations."""
|
||||||
@@ -3196,8 +3188,8 @@ class CachePolicyIntegrationTests(TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
clear_registry()
|
clear_registry()
|
||||||
|
|
||||||
def test_cache_int_sets_smaxage(self):
|
def test_cache_int_still_no_store_header(self):
|
||||||
"""Context with cache=60 emits s-maxage=60."""
|
"""cache=60 affects origin Redis TTL, but HTTP header is always no-store."""
|
||||||
Ctx = ReactContext("trending")
|
Ctx = ReactContext("trending")
|
||||||
|
|
||||||
@client(context=Ctx, cache=60)
|
@client(context=Ctx, cache=60)
|
||||||
@@ -3206,10 +3198,9 @@ class CachePolicyIntegrationTests(TestCase):
|
|||||||
|
|
||||||
register(trending, "trending")
|
register(trending, "trending")
|
||||||
response = self.client.get("/api/mizan/ctx/trending/")
|
response = self.client.get("/api/mizan/ctx/trending/")
|
||||||
self.assertIn("s-maxage=60", response["Cache-Control"])
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
self.assertNotIn("31536000", response["Cache-Control"])
|
|
||||||
|
|
||||||
def test_cache_false_sets_nostore(self):
|
def test_cache_false_no_store(self):
|
||||||
"""Context with cache=False emits no-store."""
|
"""Context with cache=False emits no-store."""
|
||||||
Ctx = ReactContext("random")
|
Ctx = ReactContext("random")
|
||||||
|
|
||||||
@@ -3221,23 +3212,6 @@ class CachePolicyIntegrationTests(TestCase):
|
|||||||
response = self.client.get("/api/mizan/ctx/random/")
|
response = self.client.get("/api/mizan/ctx/random/")
|
||||||
self.assertEqual(response["Cache-Control"], "no-store")
|
self.assertEqual(response["Cache-Control"], "no-store")
|
||||||
|
|
||||||
def test_effective_cache_is_minimum(self):
|
|
||||||
"""Context with mixed TTLs uses the minimum."""
|
|
||||||
Ctx = ReactContext("mixed")
|
|
||||||
|
|
||||||
@client(context=Ctx, cache=300)
|
|
||||||
def slow_fn(request: HttpRequest) -> dict:
|
|
||||||
return {"slow": True}
|
|
||||||
|
|
||||||
@client(context=Ctx, cache=60)
|
|
||||||
def fast_fn(request: HttpRequest) -> dict:
|
|
||||||
return {"fast": True}
|
|
||||||
|
|
||||||
register(slow_fn, "slow_fn")
|
|
||||||
register(fast_fn, "fast_fn")
|
|
||||||
response = self.client.get("/api/mizan/ctx/mixed/")
|
|
||||||
self.assertIn("s-maxage=60", response["Cache-Control"])
|
|
||||||
|
|
||||||
def test_effective_rev_is_maximum(self):
|
def test_effective_rev_is_maximum(self):
|
||||||
"""Context with mixed revs uses the maximum for cache key."""
|
"""Context with mixed revs uses the maximum for cache key."""
|
||||||
from mizan.cache.keys import derive_cache_key
|
from mizan.cache.keys import derive_cache_key
|
||||||
|
|||||||
2
packages/mizan-ts/src/cache/keys.ts
vendored
2
packages/mizan-ts/src/cache/keys.ts
vendored
@@ -40,7 +40,7 @@ export function deriveCacheKey(
|
|||||||
rev: number = 0,
|
rev: number = 0,
|
||||||
): string {
|
): string {
|
||||||
const sortedParams: Record<string, string> = {}
|
const sortedParams: Record<string, string> = {}
|
||||||
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a.localeCompare(b))) {
|
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
||||||
sortedParams[k] = String(v)
|
sortedParams[k] = String(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,18 +60,10 @@ export async function handleContextFetch(
|
|||||||
try {
|
try {
|
||||||
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
|
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
// Resolve cache policy for headers
|
|
||||||
let cc: number | boolean = true
|
|
||||||
for (const fn of fnNames) {
|
|
||||||
const e = getFunction(fn)
|
|
||||||
if (e?.cache === false) { cc = false; break }
|
|
||||||
if (typeof e?.cache === 'number') cc = cc === true ? e.cache : Math.min(cc as number, e.cache)
|
|
||||||
}
|
|
||||||
const cacheControl = cc === false ? 'no-store' : typeof cc === 'number' ? `public, max-age=0, s-maxage=${cc}` : 'public, max-age=0, s-maxage=31536000'
|
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: JSON.parse(cached),
|
body: JSON.parse(cached),
|
||||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': cacheControl, 'X-Mizan-Cache': 'HIT' },
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* cache miss on error */ }
|
} catch { /* cache miss on error */ }
|
||||||
@@ -106,7 +98,7 @@ export async function handleContextFetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve effective cache policy (minimum TTL across all functions)
|
// Resolve effective cache policy for origin-side cache decision
|
||||||
let effectiveCache: number | boolean = true
|
let effectiveCache: number | boolean = true
|
||||||
for (const fnName of fnNames) {
|
for (const fnName of fnNames) {
|
||||||
const entry = getFunction(fnName)
|
const entry = getFunction(fnName)
|
||||||
@@ -119,16 +111,7 @@ export async function handleContextFetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cacheControl: string
|
// Store in origin-side cache (skip if cache=False)
|
||||||
if (effectiveCache === false) {
|
|
||||||
cacheControl = 'no-store'
|
|
||||||
} else if (typeof effectiveCache === 'number') {
|
|
||||||
cacheControl = `public, max-age=0, s-maxage=${effectiveCache}`
|
|
||||||
} else {
|
|
||||||
cacheControl = 'public, max-age=0, s-maxage=31536000'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in origin-side cache
|
|
||||||
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
||||||
try {
|
try {
|
||||||
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
||||||
@@ -140,7 +123,7 @@ export async function handleContextFetch(
|
|||||||
body: results,
|
body: results,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': cacheControl,
|
'Cache-Control': 'no-store',
|
||||||
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
|
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
|
|||||||
const { context, params } = entry
|
const { context, params } = entry
|
||||||
if (params && Object.keys(params).length > 0) {
|
if (params && Object.keys(params).length > 0) {
|
||||||
const paramStr = Object.entries(params)
|
const paramStr = Object.entries(params)
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
||||||
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
|
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
|
||||||
.join(';')
|
.join(';')
|
||||||
parts.push(`${context};${paramStr}`)
|
parts.push(`${context};${paramStr}`)
|
||||||
|
|||||||
@@ -52,11 +52,9 @@ describe('Edge Compatibility', () => {
|
|||||||
|
|
||||||
// ── Cache-Control correctness ───────────────────────────────────────
|
// ── Cache-Control correctness ───────────────────────────────────────
|
||||||
|
|
||||||
test('context GET is cacheable', async () => {
|
test('context GET emits no-store', async () => {
|
||||||
const r = await handleContextFetch('user', { userId: '5' })
|
const r = await handleContextFetch('user', { userId: '5' })
|
||||||
expect(r.headers['Cache-Control']).toContain('public')
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
expect(r.headers['Cache-Control']).toContain('s-maxage')
|
|
||||||
expect(r.headers['Cache-Control']).not.toContain('no-store')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('mutation POST not cacheable', async () => {
|
test('mutation POST not cacheable', async () => {
|
||||||
@@ -253,7 +251,7 @@ describe('Manifest', () => {
|
|||||||
expect(fn.cache).toBe(60)
|
expect(fn.cache).toBe(60)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('cache=60 sets s-maxage=60', async () => {
|
test('cache=60 still emits no-store on HTTP', async () => {
|
||||||
clearRegistry()
|
clearRegistry()
|
||||||
const Ctx = new ReactContext('live')
|
const Ctx = new ReactContext('live')
|
||||||
client({ context: Ctx, cache: 60 }, async function liveFn() {
|
client({ context: Ctx, cache: 60 }, async function liveFn() {
|
||||||
@@ -261,7 +259,7 @@ describe('Manifest', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const r = await handleContextFetch('live', {})
|
const r = await handleContextFetch('live', {})
|
||||||
expect(r.headers['Cache-Control']).toBe('public, max-age=0, s-maxage=60')
|
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('cache=false sets no-store', async () => {
|
test('cache=false sets no-store', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user