From e5f8fafc0125e4b86a90983f7f08d3ae6ccd2487 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 7 Apr 2026 01:38:24 -0400 Subject: [PATCH] Remove CDN Cache-Control headers; fix cross-language sort bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ROADMAP.md | 4 +- .../mizan-django/src/mizan/cache/__init__.py | 2 +- .../mizan-django/src/mizan/client/executor.py | 15 ++---- .../mizan-django/src/mizan/tests/test_core.py | 48 +++++-------------- packages/mizan-ts/src/cache/keys.ts | 2 +- packages/mizan-ts/src/dispatch.ts | 25 ++-------- packages/mizan-ts/src/invalidation.ts | 2 +- packages/mizan-ts/tests/edge-compat.test.ts | 10 ++-- 8 files changed, 28 insertions(+), 80 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 184959d..877924a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,7 @@ - **Shapes** — Pydantic + django-readers for typed query projections - **WebSocket channels** — real-time bidirectional communication - **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 @@ -128,7 +128,7 @@ The protocol is the product. Two invalidation transports. Every endpoint CDN-rea GET /api/mizan/ctx//?param=value 200 OK -Cache-Control: public, max-age=0, s-maxage=31536000 +Cache-Control: no-store { "function_a": { ... }, diff --git a/packages/mizan-django/src/mizan/cache/__init__.py b/packages/mizan-django/src/mizan/cache/__init__.py index 1aeb18a..a4780b6 100644 --- a/packages/mizan-django/src/mizan/cache/__init__.py +++ b/packages/mizan-django/src/mizan/cache/__init__.py @@ -121,7 +121,7 @@ def cache_purge( Broad purge (no params): scans by key prefix "ctx:{context}:*". 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) return 1 if backend.delete(key) else 0 else: diff --git a/packages/mizan-django/src/mizan/client/executor.py b/packages/mizan-django/src/mizan/client/executor.py index bde0967..6f31185 100644 --- a/packages/mizan-django/src/mizan/client/executor.py +++ b/packages/mizan-django/src/mizan/client/executor.py @@ -874,10 +874,7 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: ) if cached is not None: response = HttpResponse(cached, content_type="application/json") - if 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" + response["Cache-Control"] = "no-store" response["X-Mizan-Cache"] = "HIT" return response except Exception: @@ -902,13 +899,9 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: # Deterministic JSON (sorted keys) for consistent cache keys response = JsonResponse(result.data, json_dumps_params={"sort_keys": True}) - # CDN-ready headers based on effective cache policy - if effective_cache is False: - 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" + # Mizan's protocol layers handle caching (origin Redis, Edge Worker). + # The browser and non-Mizan intermediaries must not cache. + response["Cache-Control"] = "no-store" # Store in origin-side cache (skip if cache=False) if use_cache: diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index 3bb0106..55ab631 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -1019,9 +1019,8 @@ class ServerDrivenInvalidationTests(TestCase): self.assertIn("team_info", data) self.assertEqual(data["team_info"]["name"], "team_3") - # CDN-ready headers - self.assertIn("public", response["Cache-Control"]) - self.assertIn("s-maxage", response["Cache-Control"]) + # Mizan handles caching via its protocol; origin emits no-store + self.assertEqual(response["Cache-Control"], "no-store") def test_context_error_not_cached(self): """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_orders"]["count"], 50) - # CDN headers - self.assertIn("public", response["Cache-Control"]) - self.assertIn("s-maxage", response["Cache-Control"]) + # Mizan handles caching; origin emits no-store + self.assertEqual(response["Cache-Control"], "no-store") def test_context_fetch_string_to_int_coercion(self): """Query params arrive as strings. Pydantic must coerce to int.""" @@ -2142,16 +2140,10 @@ class EdgeCompatibilityTests(TestCase): # ── Cache-Control correctness ─────────────────────────────────────────── - def test_context_get_is_cacheable(self): - """Context GET has Cache-Control that allows CDN caching.""" + def test_context_get_no_store(self): + """Context GET emits no-store. Mizan's protocol layers handle caching.""" response = self.client.get("/api/mizan/ctx/user/?user_id=5") - - 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) + self.assertEqual(response["Cache-Control"], "no-store") def test_mutation_post_not_cacheable(self): """Mutation POST has no-store. CDN must never cache mutations.""" @@ -3196,8 +3188,8 @@ class CachePolicyIntegrationTests(TestCase): def tearDown(self): clear_registry() - def test_cache_int_sets_smaxage(self): - """Context with cache=60 emits s-maxage=60.""" + def test_cache_int_still_no_store_header(self): + """cache=60 affects origin Redis TTL, but HTTP header is always no-store.""" Ctx = ReactContext("trending") @client(context=Ctx, cache=60) @@ -3206,10 +3198,9 @@ class CachePolicyIntegrationTests(TestCase): register(trending, "trending") response = self.client.get("/api/mizan/ctx/trending/") - self.assertIn("s-maxage=60", response["Cache-Control"]) - self.assertNotIn("31536000", response["Cache-Control"]) + self.assertEqual(response["Cache-Control"], "no-store") - def test_cache_false_sets_nostore(self): + def test_cache_false_no_store(self): """Context with cache=False emits no-store.""" Ctx = ReactContext("random") @@ -3221,23 +3212,6 @@ class CachePolicyIntegrationTests(TestCase): response = self.client.get("/api/mizan/ctx/random/") 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): """Context with mixed revs uses the maximum for cache key.""" from mizan.cache.keys import derive_cache_key diff --git a/packages/mizan-ts/src/cache/keys.ts b/packages/mizan-ts/src/cache/keys.ts index bd223d9..3dc8e84 100644 --- a/packages/mizan-ts/src/cache/keys.ts +++ b/packages/mizan-ts/src/cache/keys.ts @@ -40,7 +40,7 @@ export function deriveCacheKey( rev: number = 0, ): string { const sortedParams: Record = {} - 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) } diff --git a/packages/mizan-ts/src/dispatch.ts b/packages/mizan-ts/src/dispatch.ts index b1c092b..8e7c9f1 100644 --- a/packages/mizan-ts/src/dispatch.ts +++ b/packages/mizan-ts/src/dispatch.ts @@ -60,18 +60,10 @@ export async function handleContextFetch( try { const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev) 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 { status: 200, 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 */ } @@ -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 for (const fnName of fnNames) { const entry = getFunction(fnName) @@ -119,16 +111,7 @@ export async function handleContextFetch( } } - let cacheControl: string - 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 + // Store in origin-side cache (skip if cache=False) if (cacheBackend && cacheSecret && effectiveCache !== false) { try { cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev) @@ -140,7 +123,7 @@ export async function handleContextFetch( body: results, headers: { 'Content-Type': 'application/json', - 'Cache-Control': cacheControl, + 'Cache-Control': 'no-store', ...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}), }, } diff --git a/packages/mizan-ts/src/invalidation.ts b/packages/mizan-ts/src/invalidation.ts index d4a9685..c51afde 100644 --- a/packages/mizan-ts/src/invalidation.ts +++ b/packages/mizan-ts/src/invalidation.ts @@ -88,7 +88,7 @@ export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string { const { context, params } = entry if (params && Object.keys(params).length > 0) { 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))}`) .join(';') parts.push(`${context};${paramStr}`) diff --git a/packages/mizan-ts/tests/edge-compat.test.ts b/packages/mizan-ts/tests/edge-compat.test.ts index f949c2e..ffdaec2 100644 --- a/packages/mizan-ts/tests/edge-compat.test.ts +++ b/packages/mizan-ts/tests/edge-compat.test.ts @@ -52,11 +52,9 @@ describe('Edge Compatibility', () => { // ── Cache-Control correctness ─────────────────────────────────────── - test('context GET is cacheable', async () => { + test('context GET emits no-store', async () => { const r = await handleContextFetch('user', { userId: '5' }) - expect(r.headers['Cache-Control']).toContain('public') - expect(r.headers['Cache-Control']).toContain('s-maxage') - expect(r.headers['Cache-Control']).not.toContain('no-store') + expect(r.headers['Cache-Control']).toBe('no-store') }) test('mutation POST not cacheable', async () => { @@ -253,7 +251,7 @@ describe('Manifest', () => { expect(fn.cache).toBe(60) }) - test('cache=60 sets s-maxage=60', async () => { + test('cache=60 still emits no-store on HTTP', async () => { clearRegistry() const Ctx = new ReactContext('live') client({ context: Ctx, cache: 60 }, async function liveFn() { @@ -261,7 +259,7 @@ describe('Manifest', () => { }) 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 () => {