"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const withRetry_1 = require("../../../src/clients/withRetry");
describe("withRetry", () => {
    afterEach(() => {
        jest.useRealTimers();
    });
    describe("success path", () => {
        it("returns the result immediately when fn succeeds on the first attempt", async () => {
            const fn = jest.fn().mockResolvedValue({ answer: 42 });
            const result = await (0, withRetry_1.withRetry)(fn, { maxRetries: 2 });
            expect(result).toEqual({ answer: 42 });
            expect(fn).toHaveBeenCalledTimes(1);
        });
        it("passes undefined signal to fn when no timeout or external signal is provided", async () => {
            let receivedSignal = "not-called";
            const fn = jest.fn().mockImplementation((signal) => {
                receivedSignal = signal;
                return Promise.resolve("ok");
            });
            await (0, withRetry_1.withRetry)(fn, {});
            expect(receivedSignal).toBeUndefined();
        });
        it("passes a signal to fn when a timeout is provided", async () => {
            let receivedSignal;
            const fn = jest.fn().mockImplementation((signal) => {
                receivedSignal = signal;
                return Promise.resolve("ok");
            });
            await (0, withRetry_1.withRetry)(fn, { timeout: 5000 });
            expect(receivedSignal).toBeInstanceOf(AbortSignal);
            expect(receivedSignal.aborted).toBe(false);
        });
    });
    describe("retry on retriable errors", () => {
        it("retries on a 429 rate-limit error and eventually succeeds", async () => {
            jest.useFakeTimers();
            const fn = jest
                .fn()
                .mockRejectedValueOnce(new Error("429 Too Many Requests"))
                .mockResolvedValueOnce({ answer: "retried" });
            const promise = (0, withRetry_1.withRetry)(fn, { maxRetries: 2, backoffMs: 100 });
            await jest.runAllTimersAsync();
            const result = await promise;
            expect(result).toEqual({ answer: "retried" });
            expect(fn).toHaveBeenCalledTimes(2);
        });
        it("retries on a 500 server error and eventually succeeds", async () => {
            jest.useFakeTimers();
            const fn = jest
                .fn()
                .mockRejectedValueOnce(new Error("500 Internal Server Error"))
                .mockResolvedValueOnce({
                choices: [{ message: { role: "assistant", content: "hi" } }],
            });
            const promise = (0, withRetry_1.withRetry)(fn, { maxRetries: 2, backoffMs: 100 });
            await jest.runAllTimersAsync();
            const result = await promise;
            expect(result).toMatchObject({
                choices: [{ message: { content: "hi" } }],
            });
            expect(fn).toHaveBeenCalledTimes(2);
        });
        it("retries on ECONNRESET and succeeds", async () => {
            jest.useFakeTimers();
            const fn = jest
                .fn()
                .mockRejectedValueOnce(new Error("ECONNRESET"))
                .mockResolvedValueOnce("success");
            const promise = (0, withRetry_1.withRetry)(fn, { maxRetries: 2, backoffMs: 100 });
            await jest.runAllTimersAsync();
            const result = await promise;
            expect(result).toBe("success");
            expect(fn).toHaveBeenCalledTimes(2);
        });
        it("retries up to maxRetries times then throws", async () => {
            jest.useFakeTimers();
            const fn = jest.fn().mockImplementation(() => Promise.reject(new Error("500 Server Error")));
            const resultPromise = (0, withRetry_1.withRetry)(fn, { maxRetries: 2, backoffMs: 10 });
            resultPromise.catch(() => { });
            await jest.runAllTimersAsync();
            await expect(resultPromise).rejects.toThrow("500 Server Error");
            expect(fn).toHaveBeenCalledTimes(3);
        });
        it("does NOT retry on a non-retriable error (e.g. 400 Bad Request)", async () => {
            const fn = jest
                .fn()
                .mockRejectedValue(new Error("400 Bad Request"));
            await expect((0, withRetry_1.withRetry)(fn, { maxRetries: 2 })).rejects.toThrow("400 Bad Request");
            expect(fn).toHaveBeenCalledTimes(1);
        });
        it("does NOT retry when maxRetries is 0", async () => {
            const fn = jest
                .fn()
                .mockRejectedValue(new Error("500 Server Error"));
            await expect((0, withRetry_1.withRetry)(fn, { maxRetries: 0 })).rejects.toThrow("500 Server Error");
            expect(fn).toHaveBeenCalledTimes(1);
        });
        it("uses exponential backoff between retries", async () => {
            jest.useFakeTimers();
            const error = new Error("500 err");
            const fn = jest
                .fn()
                .mockRejectedValueOnce(error)
                .mockRejectedValueOnce(error)
                .mockResolvedValueOnce("done");
            const observedDelays = [];
            const origSetTimeout = global.setTimeout;
            const spy = jest
                .spyOn(global, "setTimeout")
                .mockImplementation((cb, delay, ...args) => {
                if (delay === 100 || delay === 200) {
                    observedDelays.push(delay);
                }
                return origSetTimeout(cb, 0, ...args);
            });
            const promise = (0, withRetry_1.withRetry)(fn, { maxRetries: 2, backoffMs: 100 });
            await jest.runAllTimersAsync();
            await promise;
            spy.mockRestore();
            expect(observedDelays).toContain(100);
            expect(observedDelays).toContain(200);
        });
    });
    describe("per-attempt timeout", () => {
        it("aborts fn via signal when timeout fires and retries on the next attempt", async () => {
            jest.useFakeTimers();
            const fn = jest
                .fn()
                .mockImplementationOnce((signal) => {
                return new Promise((_, reject) => {
                    signal.addEventListener("abort", () => reject(signal.reason));
                });
            })
                .mockResolvedValueOnce("completed after timeout retry");
            const promise = (0, withRetry_1.withRetry)(fn, {
                timeout: 1000,
                maxRetries: 2,
                backoffMs: 10,
            });
            await jest.runAllTimersAsync();
            const result = await promise;
            expect(result).toBe("completed after timeout retry");
            expect(fn).toHaveBeenCalledTimes(2);
        });
        it("throws after all retries are exhausted by timeouts", async () => {
            jest.useFakeTimers();
            const fn = jest.fn().mockImplementation((signal) => {
                return new Promise((_, reject) => {
                    signal.addEventListener("abort", () => reject(signal.reason));
                });
            });
            const promise = (0, withRetry_1.withRetry)(fn, {
                timeout: 500,
                maxRetries: 1,
                backoffMs: 10,
            });
            promise.catch(() => { });
            await jest.runAllTimersAsync();
            await expect(promise).rejects.toMatchObject({ name: "TimeoutError" });
            expect(fn).toHaveBeenCalledTimes(2);
        });
    });
    describe("external AbortSignal", () => {
        it("does not call fn when signal is already aborted before invocation", async () => {
            const controller = new AbortController();
            controller.abort();
            const fn = jest.fn().mockResolvedValue("should not reach");
            await expect((0, withRetry_1.withRetry)(fn, { signal: controller.signal, maxRetries: 2 })).rejects.toMatchObject({ name: "AbortError" });
            expect(fn).not.toHaveBeenCalled();
        });
        it("cancels an in-flight request when signal is aborted externally", async () => {
            const controller = new AbortController();
            const fn = jest.fn().mockImplementation((signal) => {
                return new Promise((_, reject) => {
                    signal.addEventListener("abort", () => reject(signal.reason));
                });
            });
            const promise = (0, withRetry_1.withRetry)(fn, {
                signal: controller.signal,
                maxRetries: 2,
            });
            setImmediate(() => controller.abort(new DOMException("User cancelled", "AbortError")));
            await expect(promise).rejects.toMatchObject({ name: "AbortError" });
            expect(fn).toHaveBeenCalledTimes(1);
        });
        it("does not retry after external abort even if error string looks retriable", async () => {
            const controller = new AbortController();
            controller.abort(new DOMException("Aborted", "AbortError"));
            const fn = jest.fn().mockResolvedValue("ok");
            await expect((0, withRetry_1.withRetry)(fn, { signal: controller.signal, maxRetries: 3 })).rejects.toMatchObject({ name: "AbortError" });
            expect(fn).not.toHaveBeenCalled();
        });
        it("forwards the combined signal to fn and it is not aborted on success", async () => {
            const controller = new AbortController();
            let receivedSignal;
            const fn = jest.fn().mockImplementation((signal) => {
                receivedSignal = signal;
                return Promise.resolve("ok");
            });
            await (0, withRetry_1.withRetry)(fn, { signal: controller.signal, timeout: 5000 });
            expect(receivedSignal).toBeInstanceOf(AbortSignal);
            expect(receivedSignal.aborted).toBe(false);
        });
    });
    describe("timeout + external signal combined", () => {
        it("abort via external signal takes priority over pending timeout", async () => {
            const controller = new AbortController();
            const fn = jest.fn().mockImplementation((signal) => {
                return new Promise((_, reject) => {
                    signal.addEventListener("abort", () => reject(signal.reason));
                });
            });
            const promise = (0, withRetry_1.withRetry)(fn, {
                signal: controller.signal,
                timeout: 10_000,
                maxRetries: 2,
            });
            setImmediate(() => controller.abort(new DOMException("User cancelled", "AbortError")));
            await expect(promise).rejects.toMatchObject({ name: "AbortError" });
            expect(fn).toHaveBeenCalledTimes(1);
        });
    });
});
//# sourceMappingURL=withRetry.test.js.map