agent-pool.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. /**
  2. * Agent Pool Tests
  3. *
  4. * TDD: Tests written first, implementation follows
  5. */
  6. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  7. // Mock undici before importing agent-pool
  8. vi.mock("undici", () => ({
  9. Agent: vi.fn().mockImplementation((options) => ({
  10. options,
  11. close: vi.fn().mockResolvedValue(undefined),
  12. destroy: vi.fn().mockResolvedValue(undefined),
  13. })),
  14. ProxyAgent: vi.fn().mockImplementation((options) => ({
  15. options,
  16. close: vi.fn().mockResolvedValue(undefined),
  17. destroy: vi.fn().mockResolvedValue(undefined),
  18. })),
  19. }));
  20. vi.mock("fetch-socks", () => ({
  21. socksDispatcher: vi.fn().mockImplementation((proxy, options) => ({
  22. proxy,
  23. options,
  24. close: vi.fn().mockResolvedValue(undefined),
  25. destroy: vi.fn().mockResolvedValue(undefined),
  26. })),
  27. }));
  28. import {
  29. type AgentPool,
  30. AgentPoolImpl,
  31. generateAgentCacheKey,
  32. getGlobalAgentPool,
  33. resetGlobalAgentPool,
  34. type AgentPoolConfig,
  35. } from "@/lib/proxy-agent/agent-pool";
  36. describe("generateAgentCacheKey", () => {
  37. it("should generate correct cache key for direct connection", () => {
  38. const key = generateAgentCacheKey({
  39. endpointUrl: "https://api.anthropic.com/v1/messages",
  40. proxyUrl: null,
  41. enableHttp2: false,
  42. });
  43. expect(key).toBe("https://api.anthropic.com|direct|h1");
  44. });
  45. it("should generate correct cache key with proxy", () => {
  46. const key = generateAgentCacheKey({
  47. endpointUrl: "https://api.openai.com/v1/chat/completions",
  48. proxyUrl: "http://proxy.example.com:8080",
  49. enableHttp2: false,
  50. });
  51. expect(key).toBe("https://api.openai.com|http://proxy.example.com:8080|h1");
  52. });
  53. it("should generate correct cache key with HTTP/2 enabled", () => {
  54. const key = generateAgentCacheKey({
  55. endpointUrl: "https://api.anthropic.com/v1/messages",
  56. proxyUrl: null,
  57. enableHttp2: true,
  58. });
  59. expect(key).toBe("https://api.anthropic.com|direct|h2");
  60. });
  61. it("should generate correct cache key with proxy and HTTP/2", () => {
  62. const key = generateAgentCacheKey({
  63. endpointUrl: "https://api.anthropic.com/v1/messages",
  64. proxyUrl: "https://secure-proxy.example.com:443",
  65. enableHttp2: true,
  66. });
  67. // URL API strips default port 443 for HTTPS
  68. expect(key).toBe("https://api.anthropic.com|https://secure-proxy.example.com|h2");
  69. });
  70. it("should use origin only (strip path and query)", () => {
  71. const key = generateAgentCacheKey({
  72. endpointUrl: "https://api.anthropic.com/v1/messages?key=value",
  73. proxyUrl: null,
  74. enableHttp2: false,
  75. });
  76. expect(key).toBe("https://api.anthropic.com|direct|h1");
  77. });
  78. it("should handle different ports", () => {
  79. const key = generateAgentCacheKey({
  80. endpointUrl: "https://api.example.com:8443/v1/messages",
  81. proxyUrl: null,
  82. enableHttp2: false,
  83. });
  84. expect(key).toBe("https://api.example.com:8443|direct|h1");
  85. });
  86. it("should differentiate HTTP and HTTPS", () => {
  87. const httpKey = generateAgentCacheKey({
  88. endpointUrl: "http://api.example.com/v1/messages",
  89. proxyUrl: null,
  90. enableHttp2: false,
  91. });
  92. const httpsKey = generateAgentCacheKey({
  93. endpointUrl: "https://api.example.com/v1/messages",
  94. proxyUrl: null,
  95. enableHttp2: false,
  96. });
  97. expect(httpKey).not.toBe(httpsKey);
  98. expect(httpKey).toBe("http://api.example.com|direct|h1");
  99. expect(httpsKey).toBe("https://api.example.com|direct|h1");
  100. });
  101. });
  102. describe("AgentPool", () => {
  103. let pool: AgentPool;
  104. const defaultConfig: AgentPoolConfig = {
  105. maxTotalAgents: 10,
  106. agentTtlMs: 300000, // 5 minutes
  107. connectionIdleTimeoutMs: 60000, // 1 minute
  108. cleanupIntervalMs: 30000, // 30 seconds
  109. };
  110. beforeEach(() => {
  111. vi.useFakeTimers();
  112. pool = new AgentPoolImpl(defaultConfig);
  113. });
  114. afterEach(async () => {
  115. await pool.shutdown();
  116. vi.useRealTimers();
  117. vi.clearAllMocks();
  118. });
  119. describe("caching behavior", () => {
  120. it("should reuse Agent for same endpoint", async () => {
  121. const params = {
  122. endpointUrl: "https://api.anthropic.com/v1/messages",
  123. proxyUrl: null,
  124. enableHttp2: false,
  125. };
  126. const result1 = await pool.getAgent(params);
  127. const result2 = await pool.getAgent(params);
  128. expect(result1.cacheKey).toBe(result2.cacheKey);
  129. expect(result1.agent).toBe(result2.agent);
  130. expect(result1.isNew).toBe(true);
  131. expect(result2.isNew).toBe(false);
  132. });
  133. it("should create different Agent for different endpoints", async () => {
  134. const result1 = await pool.getAgent({
  135. endpointUrl: "https://api.anthropic.com/v1/messages",
  136. proxyUrl: null,
  137. enableHttp2: false,
  138. });
  139. const result2 = await pool.getAgent({
  140. endpointUrl: "https://api.openai.com/v1/chat/completions",
  141. proxyUrl: null,
  142. enableHttp2: false,
  143. });
  144. expect(result1.cacheKey).not.toBe(result2.cacheKey);
  145. expect(result1.agent).not.toBe(result2.agent);
  146. expect(result1.isNew).toBe(true);
  147. expect(result2.isNew).toBe(true);
  148. });
  149. it("should create different Agent for different proxy configs", async () => {
  150. const result1 = await pool.getAgent({
  151. endpointUrl: "https://api.anthropic.com/v1/messages",
  152. proxyUrl: null,
  153. enableHttp2: false,
  154. });
  155. const result2 = await pool.getAgent({
  156. endpointUrl: "https://api.anthropic.com/v1/messages",
  157. proxyUrl: "http://proxy.example.com:8080",
  158. enableHttp2: false,
  159. });
  160. expect(result1.cacheKey).not.toBe(result2.cacheKey);
  161. expect(result1.agent).not.toBe(result2.agent);
  162. });
  163. it("should create different Agent for HTTP/2 vs HTTP/1.1", async () => {
  164. const result1 = await pool.getAgent({
  165. endpointUrl: "https://api.anthropic.com/v1/messages",
  166. proxyUrl: null,
  167. enableHttp2: false,
  168. });
  169. const result2 = await pool.getAgent({
  170. endpointUrl: "https://api.anthropic.com/v1/messages",
  171. proxyUrl: null,
  172. enableHttp2: true,
  173. });
  174. expect(result1.cacheKey).not.toBe(result2.cacheKey);
  175. expect(result1.agent).not.toBe(result2.agent);
  176. });
  177. it("should track request count", async () => {
  178. const params = {
  179. endpointUrl: "https://api.anthropic.com/v1/messages",
  180. proxyUrl: null,
  181. enableHttp2: false,
  182. };
  183. await pool.getAgent(params);
  184. await pool.getAgent(params);
  185. await pool.getAgent(params);
  186. const stats = pool.getPoolStats();
  187. expect(stats.totalRequests).toBe(3);
  188. expect(stats.cacheHits).toBe(2);
  189. expect(stats.cacheMisses).toBe(1);
  190. });
  191. });
  192. describe("health management", () => {
  193. it("should create new Agent after marking unhealthy", async () => {
  194. const params = {
  195. endpointUrl: "https://api.anthropic.com/v1/messages",
  196. proxyUrl: null,
  197. enableHttp2: false,
  198. };
  199. const result1 = await pool.getAgent(params);
  200. pool.markUnhealthy(result1.cacheKey, "SSL certificate error");
  201. const result2 = await pool.getAgent(params);
  202. expect(result2.isNew).toBe(true);
  203. expect(result2.agent).not.toBe(result1.agent);
  204. });
  205. it("should track unhealthy agents in stats", async () => {
  206. const params = {
  207. endpointUrl: "https://api.anthropic.com/v1/messages",
  208. proxyUrl: null,
  209. enableHttp2: false,
  210. };
  211. const result = await pool.getAgent(params);
  212. pool.markUnhealthy(result.cacheKey, "SSL certificate error");
  213. const stats = pool.getPoolStats();
  214. expect(stats.unhealthyAgents).toBe(1);
  215. });
  216. it("should evict all Agents for endpoint on evictEndpoint", async () => {
  217. // Create agents for same endpoint with different configs
  218. await pool.getAgent({
  219. endpointUrl: "https://api.anthropic.com/v1/messages",
  220. proxyUrl: null,
  221. enableHttp2: false,
  222. });
  223. await pool.getAgent({
  224. endpointUrl: "https://api.anthropic.com/v1/messages",
  225. proxyUrl: null,
  226. enableHttp2: true,
  227. });
  228. await pool.getAgent({
  229. endpointUrl: "https://api.openai.com/v1/chat/completions",
  230. proxyUrl: null,
  231. enableHttp2: false,
  232. });
  233. const statsBefore = pool.getPoolStats();
  234. expect(statsBefore.cacheSize).toBe(3);
  235. await pool.evictEndpoint("https://api.anthropic.com");
  236. const statsAfter = pool.getPoolStats();
  237. expect(statsAfter.cacheSize).toBe(1);
  238. expect(statsAfter.evictedAgents).toBe(2);
  239. });
  240. });
  241. describe("expiration cleanup", () => {
  242. it("should cleanup expired Agents", async () => {
  243. const shortTtlPool = new AgentPoolImpl({
  244. ...defaultConfig,
  245. agentTtlMs: 1000, // 1 second TTL
  246. });
  247. await shortTtlPool.getAgent({
  248. endpointUrl: "https://api.anthropic.com/v1/messages",
  249. proxyUrl: null,
  250. enableHttp2: false,
  251. });
  252. expect(shortTtlPool.getPoolStats().cacheSize).toBe(1);
  253. // Advance time past TTL
  254. vi.advanceTimersByTime(2000);
  255. const cleaned = await shortTtlPool.cleanup();
  256. expect(cleaned).toBe(1);
  257. expect(shortTtlPool.getPoolStats().cacheSize).toBe(0);
  258. await shortTtlPool.shutdown();
  259. });
  260. it("should not cleanup recently used Agents", async () => {
  261. const shortTtlPool = new AgentPoolImpl({
  262. ...defaultConfig,
  263. agentTtlMs: 1000,
  264. });
  265. const params = {
  266. endpointUrl: "https://api.anthropic.com/v1/messages",
  267. proxyUrl: null,
  268. enableHttp2: false,
  269. };
  270. await shortTtlPool.getAgent(params);
  271. // Advance time but not past TTL
  272. vi.advanceTimersByTime(500);
  273. // Use the agent again (updates lastUsedAt)
  274. await shortTtlPool.getAgent(params);
  275. // Advance time again
  276. vi.advanceTimersByTime(500);
  277. const cleaned = await shortTtlPool.cleanup();
  278. expect(cleaned).toBe(0);
  279. expect(shortTtlPool.getPoolStats().cacheSize).toBe(1);
  280. await shortTtlPool.shutdown();
  281. });
  282. it("should implement LRU eviction when max size reached", async () => {
  283. const smallPool = new AgentPoolImpl({
  284. ...defaultConfig,
  285. maxTotalAgents: 2,
  286. });
  287. // Create 3 agents (exceeds max of 2)
  288. await smallPool.getAgent({
  289. endpointUrl: "https://api1.example.com/v1",
  290. proxyUrl: null,
  291. enableHttp2: false,
  292. });
  293. vi.advanceTimersByTime(100);
  294. await smallPool.getAgent({
  295. endpointUrl: "https://api2.example.com/v1",
  296. proxyUrl: null,
  297. enableHttp2: false,
  298. });
  299. vi.advanceTimersByTime(100);
  300. await smallPool.getAgent({
  301. endpointUrl: "https://api3.example.com/v1",
  302. proxyUrl: null,
  303. enableHttp2: false,
  304. });
  305. // Should have evicted the oldest (LRU)
  306. const stats = smallPool.getPoolStats();
  307. expect(stats.cacheSize).toBeLessThanOrEqual(2);
  308. await smallPool.shutdown();
  309. });
  310. });
  311. describe("proxy support", () => {
  312. it("should create ProxyAgent for HTTP proxy", async () => {
  313. const result = await pool.getAgent({
  314. endpointUrl: "https://api.anthropic.com/v1/messages",
  315. proxyUrl: "http://proxy.example.com:8080",
  316. enableHttp2: false,
  317. });
  318. expect(result.isNew).toBe(true);
  319. expect(result.cacheKey).toContain("http://proxy.example.com:8080");
  320. });
  321. it("should create SOCKS dispatcher for SOCKS proxy", async () => {
  322. const result = await pool.getAgent({
  323. endpointUrl: "https://api.anthropic.com/v1/messages",
  324. proxyUrl: "socks5://proxy.example.com:1080",
  325. enableHttp2: false,
  326. });
  327. expect(result.isNew).toBe(true);
  328. expect(result.cacheKey).toContain("socks5://proxy.example.com:1080");
  329. });
  330. });
  331. describe("pool stats", () => {
  332. it("should return accurate pool statistics", async () => {
  333. await pool.getAgent({
  334. endpointUrl: "https://api.anthropic.com/v1/messages",
  335. proxyUrl: null,
  336. enableHttp2: false,
  337. });
  338. await pool.getAgent({
  339. endpointUrl: "https://api.anthropic.com/v1/messages",
  340. proxyUrl: null,
  341. enableHttp2: false,
  342. });
  343. await pool.getAgent({
  344. endpointUrl: "https://api.openai.com/v1/chat/completions",
  345. proxyUrl: null,
  346. enableHttp2: false,
  347. });
  348. const stats = pool.getPoolStats();
  349. expect(stats.cacheSize).toBe(2);
  350. expect(stats.totalRequests).toBe(3);
  351. expect(stats.cacheHits).toBe(1);
  352. expect(stats.cacheMisses).toBe(2);
  353. expect(stats.hitRate).toBeCloseTo(1 / 3, 2);
  354. });
  355. });
  356. describe("shutdown", () => {
  357. it("should close all agents on shutdown", async () => {
  358. await pool.getAgent({
  359. endpointUrl: "https://api.anthropic.com/v1/messages",
  360. proxyUrl: null,
  361. enableHttp2: false,
  362. });
  363. await pool.getAgent({
  364. endpointUrl: "https://api.openai.com/v1/chat/completions",
  365. proxyUrl: null,
  366. enableHttp2: false,
  367. });
  368. await pool.shutdown();
  369. const stats = pool.getPoolStats();
  370. expect(stats.cacheSize).toBe(0);
  371. });
  372. });
  373. });
  374. describe("getGlobalAgentPool", () => {
  375. afterEach(async () => {
  376. await resetGlobalAgentPool();
  377. });
  378. it("should return singleton instance", () => {
  379. const pool1 = getGlobalAgentPool();
  380. const pool2 = getGlobalAgentPool();
  381. expect(pool1).toBe(pool2);
  382. });
  383. it("should create new instance after reset", async () => {
  384. const pool1 = getGlobalAgentPool();
  385. await resetGlobalAgentPool();
  386. const pool2 = getGlobalAgentPool();
  387. expect(pool1).not.toBe(pool2);
  388. });
  389. });