import { afterEach, describe, expect, it, vi } from "vitest"; import { deliveryCertaintyOf, ProviderServerError, ProviderTransportError, RateLimitError } from "./errors.js"; import { RateLimitedHttpClient, setHttpRequestHookProvider, type HttpRequestEvent } from "./http.js"; const URL_ = "https://api.example.test/v1/things"; function scripted(responses: (() => Response)[]): { fetchImpl: typeof fetch; calls: RequestInit[] } { const calls: RequestInit[] = []; const fetchImpl = (async (_input: Parameters[0], init?: RequestInit) => { calls.push(init ?? {}); const next = responses[Math.min(calls.length - 1, responses.length - 1)]; if (next === undefined) throw new Error("no scripted response"); return next(); }) as typeof fetch; return { fetchImpl, calls }; } function client(fetchImpl: typeof fetch): RateLimitedHttpClient { return new RateLimitedHttpClient({ fetchImpl, requestsPerSecond: 1000, sleep: async () => Promise.resolve() }); } afterEach(() => setHttpRequestHookProvider(undefined)); describe("http client retry classification", () => { it("retries idempotent GET on 503 and 429", async () => { const { fetchImpl, calls } = scripted([ () => new Response("", { status: 503 }), () => new Response("", { status: 429 }), () => new Response("ok", { status: 200 }), ]); const response = await client(fetchImpl).request("p", URL_); expect(response.status).toBe(200); expect(calls).toHaveLength(3); }); it("retries PUT and DELETE", async () => { for (const method of ["PUT", "DELETE"]) { const { fetchImpl, calls } = scripted([() => new Response("", { status: 502 }), () => new Response(null, { status: 204 })]); await client(fetchImpl).request("p", URL_, { method }); expect(calls).toHaveLength(2); } }); it("never resends a POST on 429: one call, RateLimitError with header Retry-After", async () => { const { fetchImpl, calls } = scripted([() => new Response("", { status: 429, headers: { "retry-after": "7" } })]); const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e); expect(calls).toHaveLength(1); expect(error).toBeInstanceOf(RateLimitError); expect(error).toMatchObject({ retryAfterMs: 7000, delivery: "rejected", status: 429 }); }); it("reads retry_after from a JSON 429 body when no header is present", async () => { const { fetchImpl } = scripted([ () => new Response(JSON.stringify({ ok: false, error_code: 429, parameters: { retry_after: 3 } }), { status: 429 }), ]); await expect(client(fetchImpl).request("p", URL_, { method: "POST" })).rejects.toMatchObject({ retryAfterMs: 3000 }); }); it("surfaces POST 5xx as ProviderServerError with unknown delivery, without resending", async () => { const { fetchImpl, calls } = scripted([() => new Response("boom", { status: 500 })]); const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e); expect(calls).toHaveLength(1); expect(error).toBeInstanceOf(ProviderServerError); expect(error).not.toBeInstanceOf(RateLimitError); expect(error).toMatchObject({ status: 500, method: "POST", delivery: "unknown" }); expect(deliveryCertaintyOf(error)).toBe("unknown"); }); it("wraps POST transport loss as ProviderTransportError (unknown delivery)", async () => { let calls = 0; const fetchImpl = (async () => { calls += 1; throw new TypeError("socket hang up"); }) as typeof fetch; const error = await client(fetchImpl).request("p", URL_, { method: "POST" }).catch((e: unknown) => e); expect(calls).toBe(1); expect(error).toBeInstanceOf(ProviderTransportError); expect(deliveryCertaintyOf(error)).toBe("unknown"); }); it("returns 4xx responses to the caller untouched", async () => { const { fetchImpl } = scripted([() => new Response("{}", { status: 422 })]); const response = await client(fetchImpl).request("p", URL_, { method: "POST" }); expect(response.status).toBe(422); }); it("opts a read-only POST into retries and a mutation out of them", async () => { const retried = scripted([() => new Response("", { status: 503 }), () => new Response("ok")]); await client(retried.fetchImpl).request("p", URL_, { method: "POST" }, { retry: true }); expect(retried.calls).toHaveLength(2); const once = scripted([() => new Response("", { status: 503 })]); await expect(client(once.fetchImpl).request("p", URL_, { method: "PUT" }, { retry: false })).rejects.toBeInstanceOf( ProviderServerError, ); expect(once.calls).toHaveLength(1); }); it("exhausted GET 5xx is a ProviderServerError, not a RateLimitError", async () => { const { fetchImpl, calls } = scripted([() => new Response("", { status: 503 })]); const error = await new RateLimitedHttpClient({ fetchImpl, maxRetries: 2, requestsPerSecond: 1000, sleep: async () => Promise.resolve() }) .request("p", URL_) .catch((e: unknown) => e); expect(calls).toHaveLength(3); expect(error).toBeInstanceOf(ProviderServerError); expect(error).toMatchObject({ delivery: "rejected" }); }); it("exhausted GET 429 stays a RateLimitError carrying Retry-After", async () => { const { fetchImpl } = scripted([() => new Response("", { status: 429, headers: { "retry-after": "10" } })]); await expect(client(fetchImpl).request("p", URL_)).rejects.toMatchObject({ name: "RateLimitError", retryAfterMs: 10_000 }); }); }); describe("http request hook", () => { it("runs beforeRequest per actual send, without the URL path", async () => { const events: HttpRequestEvent[] = []; setHttpRequestHookProvider(() => ({ beforeRequest: (event) => void events.push(event) })); const { fetchImpl } = scripted([() => new Response("", { status: 503 }), () => new Response("ok")]); await client(fetchImpl).request("p", "https://api.example.test/bot123:secret/getMe"); expect(events.map((e) => e.attempt)).toEqual([0, 1]); expect(events[0]).toEqual({ provider: "p", method: "GET", origin: "https://api.example.test", attempt: 0, mutating: false }); expect(JSON.stringify(events)).not.toContain("secret"); }); it("a throwing beforeRequest blocks the send entirely", async () => { setHttpRequestHookProvider(() => ({ beforeRequest: () => { throw new Error("fence lost"); }, })); const { fetchImpl, calls } = scripted([() => new Response("ok")]); await expect(client(fetchImpl).request("p", URL_, { method: "POST" })).rejects.toThrow("fence lost"); expect(calls).toHaveLength(0); }); }); describe("around-send dispatch serialization", () => { it("holds the gate across the actual response, after the rate limiter", async () => { const order: string[] = []; const { fetchImpl } = scripted([() => { order.push("send"); return new Response("ok"); }]); setHttpRequestHookProvider(() => ({ aroundMutation: async (_event, send) => { order.push("lock"); const response = await send(); order.push("unlock"); return response; } })); await client(fetchImpl).request("p", URL_, { method: "POST" }, { retry: false }); expect(order).toEqual(["lock", "send", "unlock"]); }); it("preserves gate errors and never classifies them as transport delivery", async () => { const failure = new Error("gate refused"); const { fetchImpl, calls } = scripted([() => new Response("ok")]); setHttpRequestHookProvider(() => ({ aroundMutation: async () => { throw failure; } })); await expect(client(fetchImpl).request("p", URL_, { method: "POST" }, { retry: false })).rejects.toBe(failure); expect(calls).toHaveLength(0); }); }); describe("rate wait precedes the dispatch gate", () => { it("does not acquire the mutation gate or stamp a send until the limiter releases", async () => { vi.useFakeTimers(); try { const { fetchImpl, calls } = scripted([() => new Response("ok")]); const gate = vi.fn(async (_event: HttpRequestEvent, send: () => Promise) => send()); setHttpRequestHookProvider(() => ({ aroundMutation: gate })); const http = new RateLimitedHttpClient({ fetchImpl, requestsPerSecond: 1 }); await http.request("p", URL_); const pending = http.request("p", URL_, { method: "POST" }, { retry: false }); await vi.advanceTimersByTimeAsync(999); expect(gate).not.toHaveBeenCalled(); expect(calls).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); await pending; expect(gate).toHaveBeenCalledTimes(1); expect(calls).toHaveLength(2); } finally { vi.useRealTimers(); } }); });