2
0

endpoint-circuit-breaker.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import { afterEach, describe, expect, test, vi } from "vitest";
  2. type SavedEndpointCircuitState = {
  3. failureCount: number;
  4. lastFailureTime: number | null;
  5. circuitState: "closed" | "open" | "half-open";
  6. circuitOpenUntil: number | null;
  7. halfOpenSuccessCount: number;
  8. };
  9. function createLoggerMock() {
  10. return {
  11. debug: vi.fn(),
  12. info: vi.fn(),
  13. warn: vi.fn(),
  14. trace: vi.fn(),
  15. error: vi.fn(),
  16. fatal: vi.fn(),
  17. };
  18. }
  19. async function flushPromises(rounds = 2): Promise<void> {
  20. for (let i = 0; i < rounds; i++) {
  21. await new Promise((resolve) => setTimeout(resolve, 0));
  22. }
  23. }
  24. afterEach(() => {
  25. vi.useRealTimers();
  26. });
  27. describe("endpoint-circuit-breaker", () => {
  28. test("达到阈值后应打开熔断;到期后进入 half-open;成功后关闭并清零", async () => {
  29. vi.resetModules();
  30. let redisState: SavedEndpointCircuitState | null = null;
  31. const loadMock = vi.fn(async () => redisState);
  32. const saveMock = vi.fn(async (_endpointId: number, state: SavedEndpointCircuitState) => {
  33. redisState = state;
  34. });
  35. const deleteMock = vi.fn(async () => {
  36. redisState = null;
  37. });
  38. vi.doMock("@/lib/config/env.schema", () => ({
  39. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  40. }));
  41. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  42. const sendAlertMock = vi.fn(async () => {});
  43. vi.doMock("@/lib/notification/notifier", () => ({
  44. sendCircuitBreakerAlert: sendAlertMock,
  45. }));
  46. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  47. loadEndpointCircuitState: loadMock,
  48. saveEndpointCircuitState: saveMock,
  49. deleteEndpointCircuitState: deleteMock,
  50. }));
  51. vi.useFakeTimers();
  52. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  53. const {
  54. isEndpointCircuitOpen,
  55. recordEndpointFailure,
  56. recordEndpointSuccess,
  57. resetEndpointCircuit,
  58. } = await import("@/lib/endpoint-circuit-breaker");
  59. await recordEndpointFailure(1, new Error("boom"));
  60. await recordEndpointFailure(1, new Error("boom"));
  61. await recordEndpointFailure(1, new Error("boom"));
  62. const openState = saveMock.mock.calls[
  63. saveMock.mock.calls.length - 1
  64. ]?.[1] as SavedEndpointCircuitState;
  65. expect(openState.circuitState).toBe("open");
  66. expect(openState.failureCount).toBe(3);
  67. expect(openState.circuitOpenUntil).toBe(Date.now() + 300000);
  68. // Prime env module cache: under fake timers, dynamic import() inside isEndpointCircuitOpen
  69. // may fail to resolve the vi.doMock unless the module is already in the import cache.
  70. await import("@/lib/config/env.schema");
  71. expect(await isEndpointCircuitOpen(1)).toBe(true);
  72. vi.advanceTimersByTime(300000 + 1);
  73. expect(await isEndpointCircuitOpen(1)).toBe(false);
  74. const halfOpenState = saveMock.mock.calls[
  75. saveMock.mock.calls.length - 1
  76. ]?.[1] as SavedEndpointCircuitState;
  77. expect(halfOpenState.circuitState).toBe("half-open");
  78. await recordEndpointSuccess(1);
  79. const closedState = saveMock.mock.calls[
  80. saveMock.mock.calls.length - 1
  81. ]?.[1] as SavedEndpointCircuitState;
  82. expect(closedState.circuitState).toBe("closed");
  83. expect(closedState.failureCount).toBe(0);
  84. expect(closedState.circuitOpenUntil).toBeNull();
  85. expect(closedState.lastFailureTime).toBeNull();
  86. expect(closedState.halfOpenSuccessCount).toBe(0);
  87. expect(await isEndpointCircuitOpen(1)).toBe(false);
  88. await resetEndpointCircuit(1);
  89. expect(deleteMock).toHaveBeenCalledWith(1);
  90. // 说明:recordEndpointFailure 在达到阈值后会触发异步告警(dynamic import + await)。
  91. // 在 CI/bun 环境下,告警 Promise 可能在下一个测试开始后才完成,从而“借用”后续用例的 module mock,
  92. // 导致 sendAlertMock 被额外调用而产生偶发失败。这里用真实计时器让事件循环前进,确保告警任务尽快落地。
  93. vi.useRealTimers();
  94. const startedAt = Date.now();
  95. while (sendAlertMock.mock.calls.length === 0 && Date.now() - startedAt < 1000) {
  96. await new Promise<void>((resolve) => setTimeout(resolve, 0));
  97. }
  98. });
  99. test("recordEndpointSuccess: closed 且 failureCount>0 时应清零", async () => {
  100. vi.resetModules();
  101. const saveMock = vi.fn(async () => {});
  102. vi.doMock("@/lib/config/env.schema", () => ({
  103. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  104. }));
  105. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  106. vi.useFakeTimers();
  107. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  108. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  109. loadEndpointCircuitState: vi.fn(async () => null),
  110. saveEndpointCircuitState: saveMock,
  111. deleteEndpointCircuitState: vi.fn(async () => {}),
  112. }));
  113. const { recordEndpointFailure, recordEndpointSuccess, getEndpointHealthInfo } = await import(
  114. "@/lib/endpoint-circuit-breaker"
  115. );
  116. await recordEndpointFailure(2, new Error("boom"));
  117. await recordEndpointSuccess(2);
  118. const { health } = await getEndpointHealthInfo(2);
  119. expect(health.failureCount).toBe(0);
  120. expect(health.circuitState).toBe("closed");
  121. const lastState = saveMock.mock.calls[
  122. saveMock.mock.calls.length - 1
  123. ]?.[1] as SavedEndpointCircuitState;
  124. expect(lastState.failureCount).toBe(0);
  125. });
  126. test("triggerEndpointCircuitBreakerAlert should call sendCircuitBreakerAlert", async () => {
  127. vi.resetModules();
  128. const sendAlertMock = vi.fn(async () => {});
  129. vi.doMock("@/lib/config/env.schema", () => ({
  130. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  131. }));
  132. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  133. vi.doMock("@/lib/notification/notifier", () => ({
  134. sendCircuitBreakerAlert: sendAlertMock,
  135. }));
  136. vi.doMock("@/repository", () => ({
  137. findProviderEndpointById: vi.fn(async () => null),
  138. }));
  139. // recordEndpointFailure 会 non-blocking 触发告警;先让 event-loop 跑完再清空计数,避免串台导致误判
  140. await flushPromises();
  141. sendAlertMock.mockClear();
  142. // Prime module cache for dynamic import() consumers
  143. await import("@/lib/config/env.schema");
  144. await import("@/lib/notification/notifier");
  145. const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker");
  146. await triggerEndpointCircuitBreakerAlert(
  147. 5,
  148. 3,
  149. "2026-01-01T00:05:00.000Z",
  150. "connection refused"
  151. );
  152. expect(sendAlertMock).toHaveBeenCalledTimes(1);
  153. expect(sendAlertMock).toHaveBeenCalledWith({
  154. providerId: 0,
  155. providerName: "endpoint:5",
  156. failureCount: 3,
  157. retryAt: "2026-01-01T00:05:00.000Z",
  158. lastError: "connection refused",
  159. incidentSource: "endpoint",
  160. endpointId: 5,
  161. endpointUrl: undefined,
  162. });
  163. });
  164. test("triggerEndpointCircuitBreakerAlert should include endpointUrl when available", async () => {
  165. vi.resetModules();
  166. const sendAlertMock = vi.fn(async () => {});
  167. vi.doMock("@/lib/config/env.schema", () => ({
  168. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  169. }));
  170. vi.doMock("@/lib/notification/notifier", () => ({
  171. sendCircuitBreakerAlert: sendAlertMock,
  172. }));
  173. vi.doMock("@/repository", () => ({
  174. findProviderEndpointById: vi.fn(async () => ({
  175. id: 10,
  176. url: "https://custom.example.com/v1/chat/completions",
  177. vendorId: 1,
  178. providerType: "openai",
  179. label: "Custom Endpoint",
  180. sortOrder: 0,
  181. isEnabled: true,
  182. lastProbedAt: null,
  183. lastProbeOk: null,
  184. lastProbeStatusCode: null,
  185. lastProbeLatencyMs: null,
  186. lastProbeErrorType: null,
  187. lastProbeErrorMessage: null,
  188. createdAt: new Date(),
  189. updatedAt: new Date(),
  190. deletedAt: null,
  191. })),
  192. }));
  193. // recordEndpointFailure 会 non-blocking 触发告警;先让 event-loop 跑完再清空计数,避免串台导致误判
  194. await flushPromises();
  195. sendAlertMock.mockClear();
  196. // Prime module cache for dynamic import() consumers
  197. await import("@/lib/config/env.schema");
  198. await import("@/lib/notification/notifier");
  199. const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker");
  200. await triggerEndpointCircuitBreakerAlert(10, 3, "2026-01-01T00:05:00.000Z", "timeout");
  201. expect(sendAlertMock).toHaveBeenCalledTimes(1);
  202. expect(sendAlertMock).toHaveBeenCalledWith({
  203. providerId: 1,
  204. providerName: "Custom Endpoint",
  205. failureCount: 3,
  206. retryAt: "2026-01-01T00:05:00.000Z",
  207. lastError: "timeout",
  208. incidentSource: "endpoint",
  209. endpointId: 10,
  210. endpointUrl: "https://custom.example.com/v1/chat/completions",
  211. });
  212. });
  213. test("recordEndpointFailure should NOT reset circuitOpenUntil when already open", async () => {
  214. vi.resetModules();
  215. let redisState: SavedEndpointCircuitState | null = null;
  216. const saveMock = vi.fn(async (_endpointId: number, state: SavedEndpointCircuitState) => {
  217. redisState = state;
  218. });
  219. vi.doMock("@/lib/config/env.schema", () => ({
  220. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  221. }));
  222. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  223. vi.doMock("@/lib/notification/notifier", () => ({
  224. sendCircuitBreakerAlert: vi.fn(async () => {}),
  225. }));
  226. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  227. loadEndpointCircuitState: vi.fn(async () => redisState),
  228. saveEndpointCircuitState: saveMock,
  229. deleteEndpointCircuitState: vi.fn(async () => {}),
  230. }));
  231. vi.useFakeTimers();
  232. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  233. const { recordEndpointFailure, isEndpointCircuitOpen, getEndpointHealthInfo } = await import(
  234. "@/lib/endpoint-circuit-breaker"
  235. );
  236. // Record 3 failures to open the circuit
  237. await recordEndpointFailure(100, new Error("fail"));
  238. await recordEndpointFailure(100, new Error("fail"));
  239. await recordEndpointFailure(100, new Error("fail"));
  240. // Verify circuit was opened (also serves as async flush before isEndpointCircuitOpen)
  241. const { health: healthSnap } = await getEndpointHealthInfo(100);
  242. expect(healthSnap.circuitState).toBe("open");
  243. // Prime the env module cache: under fake timers, the dynamic import("@/lib/config/env.schema")
  244. // inside isEndpointCircuitOpen may fail to resolve the mock unless the module is already cached.
  245. const envMod = await import("@/lib/config/env.schema");
  246. expect(envMod.getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER).toBe(true);
  247. expect(await isEndpointCircuitOpen(100)).toBe(true);
  248. const originalOpenUntil = redisState!.circuitOpenUntil;
  249. expect(originalOpenUntil).toBe(Date.now() + 300000);
  250. // Advance 1 min and record another failure — timer must NOT reset
  251. vi.advanceTimersByTime(60_000);
  252. await recordEndpointFailure(100, new Error("fail again"));
  253. expect(redisState!.circuitState).toBe("open");
  254. expect(redisState!.circuitOpenUntil).toBe(originalOpenUntil); // unchanged!
  255. expect(redisState!.failureCount).toBe(4);
  256. });
  257. test("getEndpointCircuitStateSync returns correct state for known and unknown endpoints", async () => {
  258. vi.resetModules();
  259. vi.doMock("@/lib/config/env.schema", () => ({
  260. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  261. }));
  262. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  263. vi.doMock("@/lib/notification/notifier", () => ({
  264. sendCircuitBreakerAlert: vi.fn(async () => {}),
  265. }));
  266. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  267. loadEndpointCircuitState: vi.fn(async () => null),
  268. saveEndpointCircuitState: vi.fn(async () => {}),
  269. deleteEndpointCircuitState: vi.fn(async () => {}),
  270. }));
  271. const { getEndpointCircuitStateSync, recordEndpointFailure } = await import(
  272. "@/lib/endpoint-circuit-breaker"
  273. );
  274. // Unknown endpoint returns "closed"
  275. expect(getEndpointCircuitStateSync(9999)).toBe("closed");
  276. // After opening the circuit, sync accessor reflects "open"
  277. await recordEndpointFailure(200, new Error("a"));
  278. await recordEndpointFailure(200, new Error("b"));
  279. await recordEndpointFailure(200, new Error("c"));
  280. expect(getEndpointCircuitStateSync(200)).toBe("open");
  281. });
  282. describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => {
  283. test("isEndpointCircuitOpen returns false when ENABLE_ENDPOINT_CIRCUIT_BREAKER=false", async () => {
  284. vi.resetModules();
  285. vi.doMock("@/lib/config/env.schema", () => ({
  286. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  287. }));
  288. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  289. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  290. loadEndpointCircuitState: vi.fn(async () => null),
  291. saveEndpointCircuitState: vi.fn(async () => {}),
  292. deleteEndpointCircuitState: vi.fn(async () => {}),
  293. }));
  294. const { isEndpointCircuitOpen } = await import("@/lib/endpoint-circuit-breaker");
  295. expect(await isEndpointCircuitOpen(1)).toBe(false);
  296. expect(await isEndpointCircuitOpen(999)).toBe(false);
  297. });
  298. test("recordEndpointFailure is no-op when disabled", async () => {
  299. vi.resetModules();
  300. const saveMock = vi.fn(async () => {});
  301. vi.doMock("@/lib/config/env.schema", () => ({
  302. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  303. }));
  304. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  305. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  306. loadEndpointCircuitState: vi.fn(async () => null),
  307. saveEndpointCircuitState: saveMock,
  308. deleteEndpointCircuitState: vi.fn(async () => {}),
  309. }));
  310. const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker");
  311. await recordEndpointFailure(1, new Error("boom"));
  312. await recordEndpointFailure(1, new Error("boom"));
  313. await recordEndpointFailure(1, new Error("boom"));
  314. expect(saveMock).not.toHaveBeenCalled();
  315. });
  316. test("recordEndpointSuccess is no-op when disabled", async () => {
  317. vi.resetModules();
  318. const saveMock = vi.fn(async () => {});
  319. vi.doMock("@/lib/config/env.schema", () => ({
  320. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  321. }));
  322. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  323. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  324. loadEndpointCircuitState: vi.fn(async () => null),
  325. saveEndpointCircuitState: saveMock,
  326. deleteEndpointCircuitState: vi.fn(async () => {}),
  327. }));
  328. const { recordEndpointSuccess } = await import("@/lib/endpoint-circuit-breaker");
  329. await recordEndpointSuccess(1);
  330. expect(saveMock).not.toHaveBeenCalled();
  331. });
  332. test("triggerEndpointCircuitBreakerAlert is no-op when disabled", async () => {
  333. vi.resetModules();
  334. const sendAlertMock = vi.fn(async () => {});
  335. vi.doMock("@/lib/config/env.schema", () => ({
  336. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  337. }));
  338. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  339. vi.doMock("@/lib/notification/notifier", () => ({
  340. sendCircuitBreakerAlert: sendAlertMock,
  341. }));
  342. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  343. loadEndpointCircuitState: vi.fn(async () => null),
  344. saveEndpointCircuitState: vi.fn(async () => {}),
  345. deleteEndpointCircuitState: vi.fn(async () => {}),
  346. }));
  347. const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker");
  348. await triggerEndpointCircuitBreakerAlert(
  349. 5,
  350. 3,
  351. "2026-01-01T00:05:00.000Z",
  352. "connection refused"
  353. );
  354. expect(sendAlertMock).not.toHaveBeenCalled();
  355. });
  356. test("initEndpointCircuitBreaker clears in-memory state and Redis keys when disabled", async () => {
  357. vi.resetModules();
  358. const redisMock = {
  359. scan: vi
  360. .fn()
  361. .mockResolvedValueOnce([
  362. "0",
  363. ["endpoint_circuit_breaker:state:1", "endpoint_circuit_breaker:state:2"],
  364. ]),
  365. del: vi.fn(async () => {}),
  366. };
  367. vi.doMock("@/lib/config/env.schema", () => ({
  368. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  369. }));
  370. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  371. vi.doMock("@/lib/redis/client", () => ({
  372. getRedisClient: () => redisMock,
  373. }));
  374. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  375. loadEndpointCircuitState: vi.fn(async () => null),
  376. saveEndpointCircuitState: vi.fn(async () => {}),
  377. deleteEndpointCircuitState: vi.fn(async () => {}),
  378. }));
  379. const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
  380. await initEndpointCircuitBreaker();
  381. expect(redisMock.scan).toHaveBeenCalled();
  382. expect(redisMock.del).toHaveBeenCalledWith(
  383. "endpoint_circuit_breaker:state:1",
  384. "endpoint_circuit_breaker:state:2"
  385. );
  386. });
  387. test("initEndpointCircuitBreaker is no-op when enabled", async () => {
  388. vi.resetModules();
  389. const redisMock = {
  390. scan: vi.fn(),
  391. del: vi.fn(),
  392. };
  393. vi.doMock("@/lib/config/env.schema", () => ({
  394. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  395. }));
  396. vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
  397. vi.doMock("@/lib/redis/client", () => ({
  398. getRedisClient: () => redisMock,
  399. }));
  400. vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
  401. loadEndpointCircuitState: vi.fn(async () => null),
  402. saveEndpointCircuitState: vi.fn(async () => {}),
  403. deleteEndpointCircuitState: vi.fn(async () => {}),
  404. }));
  405. const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
  406. await initEndpointCircuitBreaker();
  407. expect(redisMock.scan).not.toHaveBeenCalled();
  408. expect(redisMock.del).not.toHaveBeenCalled();
  409. });
  410. });
  411. });