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:
2026-04-07 12:30:10 -04:00
parent cdd15b3810
commit 9c837cf285
5 changed files with 47 additions and 8 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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,

View File

@@ -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,