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:
@@ -53,3 +53,28 @@ With drift correction:
|
||||
- Total time from poll start to next poll start is always ~5 seconds
|
||||
- Fetch duration doesn't affect the long-term polling rate
|
||||
- Bandwidth and CPU usage remain consistent
|
||||
|
||||
## Request Deduplication with Abort Signal Handling
|
||||
|
||||
When `useFetchData` is called with a `requestKey`, multiple hook instances sharing the same key coalesce around a single in-flight request. Each instance is tracked as a **subscriber**.
|
||||
|
||||
### How it works
|
||||
|
||||
- A module-level `Map<requestKey, InFlightRequest>` holds in-flight requests.
|
||||
- Each `InFlightRequest` contains a `subscribers` map: `subscriberId → { signal, onSetData, onSuccess }`.
|
||||
- When a new hook instance joins a deduplicated request, it registers a subscriber entry and attaches a `once` abort listener that removes it from the subscriber list.
|
||||
- When the underlying request resolves, **each subscriber's signal is checked before calling `setData` or `onSuccess`**. Aborted subscribers are skipped, preventing state updates on unmounted components.
|
||||
- When all subscribers have aborted, the underlying `AbortController` is signalled, cancelling the in-flight fetch.
|
||||
|
||||
### Guarantees
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| Subscriber aborts before request resolves | Subscriber receives no `setData`/`onSuccess`. Entry removed from subscriber list. |
|
||||
| Last subscriber aborts before request resolves | Underlying request is cancelled via `AbortController`. |
|
||||
| Request resolves, subscriber already aborted | `signal.aborted` checked before `setData`/`onSuccess` — no-op. |
|
||||
| Request errors | Non-abort errors passed to `handleFetchError`. Each subscriber's error state updated independently. |
|
||||
|
||||
### When to use deduplication
|
||||
|
||||
Use `requestKey` when multiple components may request the same resource simultaneously. The shared promise avoids redundant network calls and prevents race conditions from out-of-order responses.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useFetchData } from "../useFetchData";
|
||||
import { useFetchData, _resetInFlightRequests } from "../useFetchData";
|
||||
import type { FetchError } from "../../types/api";
|
||||
|
||||
// Clear the module-level inFlightRequests map between tests to prevent state leakage
|
||||
beforeEach(() => {
|
||||
_resetInFlightRequests();
|
||||
});
|
||||
|
||||
describe("useFetchData", () => {
|
||||
it("fetches and selects data on mount", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ value: "test" });
|
||||
@@ -406,4 +411,123 @@ describe("useFetchData", () => {
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.data).toBe("second");
|
||||
});
|
||||
|
||||
it("aborted subscriber in deduplication does not receive setData/onSuccess", async () => {
|
||||
let resolveFirst: ((value: { value: string }) => void) | null = null;
|
||||
const fetcher = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
})
|
||||
);
|
||||
const selector = vi.fn((response: { value: string }) => response.value);
|
||||
const onSuccess1 = vi.fn();
|
||||
const onSuccess2 = vi.fn();
|
||||
|
||||
const hook1 = renderHook(() =>
|
||||
useFetchData({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to load",
|
||||
requestKey: "abort-subscriber-test",
|
||||
onSuccess: onSuccess1,
|
||||
})
|
||||
);
|
||||
|
||||
const hook2 = renderHook(() =>
|
||||
useFetchData({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to load",
|
||||
requestKey: "abort-subscriber-test",
|
||||
onSuccess: onSuccess2,
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Unmount hook2 (simulates component unmounting and aborting its signal)
|
||||
hook2.unmount();
|
||||
|
||||
// Allow the request to resolve
|
||||
await act(async () => {
|
||||
resolveFirst?.({ value: "shared-data" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// hook2 should not have received the data (it aborted)
|
||||
expect(hook2.result.current.data).toBeUndefined();
|
||||
expect(onSuccess2).not.toHaveBeenCalled();
|
||||
// hook1 should still have the data
|
||||
expect(hook1.result.current.data).toBe("shared-data");
|
||||
expect(onSuccess1).toHaveBeenCalledWith({ value: "shared-data" });
|
||||
});
|
||||
|
||||
it("last subscriber abort cancels underlying request", async () => {
|
||||
let resolveFirst: ((value: { value: string }) => void) | null = null;
|
||||
const abortSignals: AbortSignal[] = [];
|
||||
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
|
||||
abortSignals.push(signal);
|
||||
return new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
});
|
||||
const selector = vi.fn((response: { value: string }) => response.value);
|
||||
|
||||
const hook1 = renderHook(() =>
|
||||
useFetchData({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to load",
|
||||
requestKey: "cancel-underlying-test",
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Mount second subscriber
|
||||
const hook2 = renderHook(() =>
|
||||
useFetchData({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to load",
|
||||
requestKey: "cancel-underlying-test",
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Both subscribers now sharing one request
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Unmount first subscriber (doesn't cancel underlying request yet)
|
||||
hook1.unmount();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Underlying request should NOT be cancelled yet (hook2 still waiting)
|
||||
expect(abortSignals[0]?.aborted).toBe(false);
|
||||
|
||||
// Unmount second (last) subscriber — should cancel the underlying request
|
||||
hook2.unmount();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Underlying request should now be cancelled
|
||||
expect(abortSignals[0]?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user