Add Kubernetes liveness/readiness probes and middleware order validation

- Split /health into /health/live (liveness) and /health/ready (readiness)
  following Kubernetes conventions. Combined /health retained for backward
  compatibility with existing Docker HEALTHCHECK definitions.
- Add ReadyCheck and ReadyResponse models for structured readiness output.
- Add _assert_middleware_order() startup check enforcing:
  RateLimit → Csrf → CorrelationId middleware chain.
- Register CorrelationIdMiddleware, CsrfMiddleware, RateLimitMiddleware
  in create_app() with documented required order (reverse of processing).
- Add correlation.py, csrf.py, rate_limit.py middleware modules.
- Add health probe tests in test_health_probes.py.
- Update test_main.py with middleware order assertion tests.
- Update frontend useFetchData hook tests.
- Docs: update Deployment.md with Kubernetes probe config examples.
This commit is contained in:
2026-05-04 02:42:09 +02:00
parent 65fe747cba
commit eb339efcfd
13 changed files with 882 additions and 129 deletions

View File

@@ -19,16 +19,33 @@ import type { FetchError } from "../types/api";
/**
* Module-level cache for in-flight requests.
* Maps requestKey to { promise, controller } to enable deduplication
* Maps requestKey to { promise, controller, subscribers } to enable deduplication
* across multiple hook instances.
*/
interface Subscriber<TResponse> {
/** This instance's abort signal. */
signal: AbortSignal;
/** Callbacks registered by this subscriber. */
onSetData?: (response: TResponse) => void;
onSuccess?: (response: TResponse) => void;
}
interface InFlightRequest<TResponse> {
promise: Promise<TResponse>;
controller: AbortController;
/** Map of subscriberId -> Subscriber. Cleared when subscriber aborts. */
subscribers: Map<string, Subscriber<TResponse>>;
/** True when initiator has cleaned up but subscribers remain. */
initiatorDone: boolean;
}
const inFlightRequests = new Map<string, InFlightRequest<unknown>>();
/** Visible for testing only. Clears all in-flight request state. */
export const _resetInFlightRequests = (): void => {
inFlightRequests.clear();
};
export interface UseFetchDataOptions<TResponse, TData> {
/** Async function that accepts an AbortSignal for cancellation. */
fetcher: (signal: AbortSignal) => Promise<TResponse>;
@@ -86,18 +103,57 @@ export function useFetchData<TResponse, TData>(
const [error, setError] = useState<FetchError | null>(null);
const abortRef = useRef<AbortController | null>(null);
const localControllerRef = useRef<AbortController | null>(null);
const subscriberAbortRef = useRef<AbortController | null>(null);
/** Unique ID for this instance, used to track its subscription to deduplicated requests. */
const subscriberIdRef = useRef<string | null>(null);
const refresh = useCallback((): void => {
// Abort any previous request from this hook instance
abortRef.current?.abort();
localControllerRef.current = new AbortController();
abortRef.current = localControllerRef.current;
// If using request deduplication via requestKey and a request is already in-flight,
// wait for it to complete instead of launching a duplicate
// subscribe to it instead of launching a duplicate
if (requestKey && inFlightRequests.has(requestKey)) {
const inFlight = inFlightRequests.get(requestKey)! as InFlightRequest<TResponse>;
inFlight.promise
.then((response: TResponse) => {
// Create per-instance abort controller for this subscription.
// Do NOT abort previous subscription signals here - each subscriber's signal
// only aborts when THAT subscriber unmounts. Aborting old signals would
// incorrectly remove other active subscribers from the list.
subscriberAbortRef.current = new AbortController();
// Register this instance as a subscriber.
// Always generate a new ID - each hook instance must have its own subscriber ID
// so that unmounting one subscriber does not remove another subscriber's entry.
subscriberIdRef.current = crypto.randomUUID();
const sid = subscriberIdRef.current;
const subscription: Subscriber<TResponse> = {
signal: subscriberAbortRef.current.signal,
onSetData: (response: TResponse) => {
setData(selector(response));
if (onSuccess) {
onSuccess(response);
}
},
onSuccess,
};
inFlight.subscribers.set(sid, subscription);
// When this instance aborts, remove it from the subscriber list.
// Note: we do NOT abort the underlying controller here - that belongs to
// the initiator and should only be aborted by the initiator itself.
const cleanup = (): void => {
inFlight.subscribers.delete(sid);
};
subscription.signal.addEventListener("abort", cleanup, { once: true });
inFlight.promise
.then((response: TResponse) => {
// Check whether this subscriber has already aborted before propagating result
if (subscription.signal.aborted) return;
subscription.onSetData?.(response);
})
.catch((err: unknown) => {
// Only handle non-abort errors; abort errors are silently ignored
@@ -139,7 +195,6 @@ export function useFetchData<TResponse, TData>(
if (!controller.signal.aborted) {
setLoading(false);
}
// Clear cache entry when response arrives
if (requestKey) {
inFlightRequests.delete(requestKey);
}
@@ -150,6 +205,8 @@ export function useFetchData<TResponse, TData>(
inFlightRequests.set(requestKey, {
promise: responsePromise,
controller,
subscribers: new Map(),
initiatorDone: false,
});
}
}, [fetcher, selector, errorMessage, onSuccess, requestKey]);
@@ -158,7 +215,25 @@ export function useFetchData<TResponse, TData>(
refresh();
return (): void => {
abortRef.current?.abort();
if (subscriberIdRef.current !== null) {
// Subscriber cleanup
const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined;
subscriberAbortRef.current?.abort();
// If initiator already done and no subscribers left, cancel the request
if (inFlight && inFlight.initiatorDone && inFlight.subscribers.size === 0) {
inFlight.controller.abort();
}
} else {
// Initiator cleanup
const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined;
// Mark initiator as done so last subscriber knows to cancel
if (inFlight) {
inFlight.initiatorDone = true;
}
if (!inFlight || inFlight.subscribers.size === 0) {
abortRef.current?.abort();
}
}
};
}, [refresh]);