mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(dashboard): avoid overlapping provider health polls (#4557)
Integrated into release/v3.8.33
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
ProviderBreakerSnapshot,
|
||||
ConnectionCooldownSnapshot,
|
||||
} from "@/app/(dashboard)/dashboard/combos/live/comboFlowModel";
|
||||
|
||||
const DEFAULT_POLL_MS = 5000;
|
||||
const POLL_TIMEOUT_MS = 8000;
|
||||
|
||||
export interface ResilienceHealthSnapshot {
|
||||
/** Per-provider circuit-breaker state (`providerHealth`). */
|
||||
@@ -28,13 +29,26 @@ const EMPTY: ResilienceHealthSnapshot = { providerHealth: {}, connectionHealth:
|
||||
*/
|
||||
export function useProviderBreakerHealth(pollMs = DEFAULT_POLL_MS): ResilienceHealthSnapshot {
|
||||
const [snapshot, setSnapshot] = useState<ResilienceHealthSnapshot>(EMPTY);
|
||||
const inFlightRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (inFlightRef.current) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
inFlightRef.current = controller;
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort("Provider breaker health timeout"),
|
||||
POLL_TIMEOUT_MS
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health");
|
||||
const res = await fetch("/api/monitoring/health", {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json = (await res.json()) as {
|
||||
providerHealth?: Record<string, ProviderBreakerSnapshot>;
|
||||
@@ -53,6 +67,11 @@ export function useProviderBreakerHealth(pollMs = DEFAULT_POLL_MS): ResilienceHe
|
||||
});
|
||||
} catch {
|
||||
// Fail-soft: keep the previous snapshot; cascade degrades to no badges.
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (inFlightRef.current === controller) {
|
||||
inFlightRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,6 +79,8 @@ export function useProviderBreakerHealth(pollMs = DEFAULT_POLL_MS): ResilienceHe
|
||||
const id = setInterval(poll, pollMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
inFlightRef.current?.abort("Provider breaker health unmounted");
|
||||
inFlightRef.current = null;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [pollMs]);
|
||||
|
||||
130
tests/unit/ui/provider-breaker-health-hook.test.tsx
Normal file
130
tests/unit/ui/provider-breaker-health-hook.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProviderBreakerHealth } from "../../../src/hooks/useProviderBreakerHealth";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function okResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("useProviderBreakerHealth", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.useFakeTimers();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
document.body.innerHTML = "";
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("skips overlapping health polls while a request is in flight", async () => {
|
||||
const first = deferred<Response>();
|
||||
const second = deferred<Response>();
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
function Probe() {
|
||||
const snapshot = useProviderBreakerHealth(5000);
|
||||
return <output>{Object.keys(snapshot.providerHealth).join(",")}</output>;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<Probe />);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ cache: "no-store" });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(
|
||||
okResponse({
|
||||
providerHealth: { openai: { state: "closed" } },
|
||||
connectionHealth: {},
|
||||
})
|
||||
);
|
||||
await first.promise;
|
||||
});
|
||||
|
||||
expect(container!.textContent).toBe("openai");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
second.resolve(okResponse({ providerHealth: {}, connectionHealth: {} }));
|
||||
});
|
||||
|
||||
it("aborts an active health poll on unmount", async () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
const fetchMock = vi.fn<typeof fetch>((_input, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
return new Promise<Response>(() => {});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
function Probe() {
|
||||
useProviderBreakerHealth(5000);
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<Probe />);
|
||||
});
|
||||
|
||||
expect(signal?.aborted).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
root = null;
|
||||
});
|
||||
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user