Fix H3, H6, H11, H13, M11, M18 — quick wins from expert review
H3: mizanFetch retries 2x on server errors (5xx) and network failures. 200ms/400ms backoff. Mutations NOT retried (not idempotent). H6: refreshContext now uses GET /ctx/<name>/ instead of POST /call/. Context reads go to the context endpoint, not the mutation endpoint. H11: Python cache key derivation normalizes True→"true", False→"false", None→"null" for cross-language HMAC consistency with JavaScript's String() behavior. H13: Forms isValid now checks that all required fields have been touched, not just that touched fields have no errors. M11: execute_function return type updated to include HttpResponseBase for view-path functions. M18: registerContext cleanup uses ?. instead of ! to prevent crash if Map was cleared (already fixed in H2 commit but documenting). 373 Django + 33 React tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
packages/mizan-django/src/mizan/cache/keys.py
vendored
13
packages/mizan-django/src/mizan/cache/keys.py
vendored
@@ -32,7 +32,18 @@ def derive_cache_key(
|
|||||||
Returns a prefixed key: "ctx:{context}:{hmac_hex}" so that
|
Returns a prefixed key: "ctx:{context}:{hmac_hex}" so that
|
||||||
broad purge can SCAN by prefix "ctx:{context}:*".
|
broad purge can SCAN by prefix "ctx:{context}:*".
|
||||||
"""
|
"""
|
||||||
sorted_params = {k: str(v) for k, v in sorted(params.items())}
|
def _normalize(v: Any) -> str:
|
||||||
|
"""Normalize values for cross-language HMAC consistency.
|
||||||
|
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
|
||||||
|
if v is True:
|
||||||
|
return "true"
|
||||||
|
if v is False:
|
||||||
|
return "false"
|
||||||
|
if v is None:
|
||||||
|
return "null"
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
sorted_params = {k: _normalize(v) for k, v in sorted(params.items())}
|
||||||
|
|
||||||
key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev}
|
key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev}
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from enum import Enum
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
|
||||||
from django.views.decorators.csrf import csrf_protect
|
from django.views.decorators.csrf import csrf_protect
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ def execute_function(
|
|||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
fn_name: str,
|
fn_name: str,
|
||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> FunctionResult | FunctionError:
|
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
||||||
"""
|
"""
|
||||||
Execute a registered server function.
|
Execute a registered server function.
|
||||||
|
|
||||||
|
|||||||
@@ -367,11 +367,12 @@ export function MizanProvider({
|
|||||||
[contextStore]
|
[contextStore]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Refresh a specific context
|
// Refresh a specific context via GET /ctx/<name>/
|
||||||
const refreshContext = useCallback(
|
const refreshContext = useCallback(
|
||||||
async (name: string): Promise<void> => {
|
async (name: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const data = await call(name, {})
|
const response = await httpClient.request('GET', `${baseUrl}/ctx/${name}/`)
|
||||||
|
const data = await response.json()
|
||||||
setContextStore(prev => {
|
setContextStore(prev => {
|
||||||
const next = { ...prev, [name]: data }
|
const next = { ...prev, [name]: data }
|
||||||
// Notify listeners
|
// Notify listeners
|
||||||
|
|||||||
@@ -811,8 +811,16 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
|||||||
}, [errors, touchedFields])
|
}, [errors, touchedFields])
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
return errors !== null && !hasErrors
|
if (errors === null || hasErrors) return false
|
||||||
}, [errors, hasErrors])
|
// Also check that all required fields have been touched
|
||||||
|
if (schema) {
|
||||||
|
for (const fieldName of schema.fieldOrder) {
|
||||||
|
const field = schema.fields[fieldName]
|
||||||
|
if (field.required && !touchedFields.has(fieldName)) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, [errors, hasErrors, schema, touchedFields])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -151,6 +151,24 @@ function flush(): void {
|
|||||||
|
|
||||||
// === Fetch ===
|
// === Fetch ===
|
||||||
|
|
||||||
|
async function fetchWithRetry(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
retries = 2,
|
||||||
|
): Promise<Response> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(input, init)
|
||||||
|
// Don't retry client errors (4xx) — only server/network errors
|
||||||
|
if (res.ok || (res.status >= 400 && res.status < 500)) return res
|
||||||
|
if (attempt >= retries) return res
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt >= retries) throw e
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, (attempt + 1) * 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveHeaders(): Promise<Record<string, string>> {
|
async function resolveHeaders(): Promise<Record<string, string>> {
|
||||||
await initSession()
|
await initSession()
|
||||||
|
|
||||||
@@ -179,7 +197,7 @@ export async function mizanFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = await resolveHeaders()
|
const headers = await resolveHeaders()
|
||||||
const res = await fetch(url.toString(), { headers, credentials: 'same-origin' })
|
const res = await fetchWithRetry(url.toString(), { headers, credentials: 'same-origin' })
|
||||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
@@ -191,6 +209,7 @@ export async function mizanCall(
|
|||||||
const headers = await resolveHeaders()
|
const headers = await resolveHeaders()
|
||||||
headers['Content-Type'] = 'application/json'
|
headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
// Mutations are not retried — they are not idempotent
|
||||||
const res = await fetch(`${config.baseUrl}/call/`, {
|
const res = await fetch(`${config.baseUrl}/call/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
Reference in New Issue
Block a user