246 lines
7.8 KiB
TypeScript
246 lines
7.8 KiB
TypeScript
import { describe, test, expect, mock } from "bun:test";
|
|
import { withCwRetry } from "../../src/modules/cw-utils/withCwRetry";
|
|
|
|
describe("withCwRetry", () => {
|
|
// -------------------------------------------------------------------
|
|
// Successful execution
|
|
// -------------------------------------------------------------------
|
|
test("returns result on first successful call", async () => {
|
|
const fn = mock(() => Promise.resolve("ok"));
|
|
const result = await withCwRetry(fn);
|
|
expect(result).toBe("ok");
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("returns result when call succeeds after transient failure", async () => {
|
|
let attempt = 0;
|
|
const fn = mock(() => {
|
|
attempt++;
|
|
if (attempt === 1) {
|
|
const err: any = new Error("timeout");
|
|
err.isAxiosError = true;
|
|
err.code = "ETIMEDOUT";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("recovered");
|
|
});
|
|
const result = await withCwRetry(fn, { baseDelayMs: 1 });
|
|
expect(result).toBe("recovered");
|
|
expect(fn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Retry on transient errors
|
|
// -------------------------------------------------------------------
|
|
test("retries on ECONNABORTED", async () => {
|
|
let calls = 0;
|
|
const fn = mock(() => {
|
|
calls++;
|
|
if (calls < 3) {
|
|
const err: any = new Error("aborted");
|
|
err.isAxiosError = true;
|
|
err.code = "ECONNABORTED";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("done");
|
|
});
|
|
const result = await withCwRetry(fn, { maxAttempts: 3, baseDelayMs: 1 });
|
|
expect(result).toBe("done");
|
|
expect(fn).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test("retries on ECONNRESET", async () => {
|
|
let calls = 0;
|
|
const fn = mock(() => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
const err: any = new Error("reset");
|
|
err.isAxiosError = true;
|
|
err.code = "ECONNRESET";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("ok");
|
|
});
|
|
const result = await withCwRetry(fn, { baseDelayMs: 1 });
|
|
expect(result).toBe("ok");
|
|
});
|
|
|
|
test("retries on ECONNREFUSED", async () => {
|
|
let calls = 0;
|
|
const fn = mock(() => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
const err: any = new Error("refused");
|
|
err.isAxiosError = true;
|
|
err.code = "ECONNREFUSED";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("ok");
|
|
});
|
|
const result = await withCwRetry(fn, { baseDelayMs: 1 });
|
|
expect(result).toBe("ok");
|
|
});
|
|
|
|
test("retries on ERR_NETWORK", async () => {
|
|
let calls = 0;
|
|
const fn = mock(() => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
const err: any = new Error("network");
|
|
err.isAxiosError = true;
|
|
err.code = "ERR_NETWORK";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("ok");
|
|
});
|
|
const result = await withCwRetry(fn, { baseDelayMs: 1 });
|
|
expect(result).toBe("ok");
|
|
});
|
|
|
|
test("retries on ENETUNREACH", async () => {
|
|
let calls = 0;
|
|
const fn = mock(() => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
const err: any = new Error("unreachable");
|
|
err.isAxiosError = true;
|
|
err.code = "ENETUNREACH";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("ok");
|
|
});
|
|
const result = await withCwRetry(fn, { baseDelayMs: 1 });
|
|
expect(result).toBe("ok");
|
|
});
|
|
|
|
test("retries on 5xx server errors", async () => {
|
|
let calls = 0;
|
|
const fn = mock(() => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
const err: any = new Error("server error");
|
|
err.isAxiosError = true;
|
|
err.response = { status: 502 };
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("ok");
|
|
});
|
|
const result = await withCwRetry(fn, { baseDelayMs: 1 });
|
|
expect(result).toBe("ok");
|
|
});
|
|
|
|
test("retries on 500 status", async () => {
|
|
let calls = 0;
|
|
const fn = mock(() => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
const err: any = new Error("internal");
|
|
err.isAxiosError = true;
|
|
err.response = { status: 500 };
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve("ok");
|
|
});
|
|
const result = await withCwRetry(fn, { baseDelayMs: 1 });
|
|
expect(result).toBe("ok");
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Non-retryable errors
|
|
// -------------------------------------------------------------------
|
|
test("does not retry on 4xx errors", async () => {
|
|
const err: any = new Error("not found");
|
|
err.isAxiosError = true;
|
|
err.response = { status: 404 };
|
|
const fn = mock(() => Promise.reject(err));
|
|
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("does not retry on 400 errors", async () => {
|
|
const err: any = new Error("bad request");
|
|
err.isAxiosError = true;
|
|
err.response = { status: 400 };
|
|
const fn = mock(() => Promise.reject(err));
|
|
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("does not retry on non-Axios errors", async () => {
|
|
const fn = mock(() => Promise.reject(new Error("generic")));
|
|
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow(
|
|
"generic",
|
|
);
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("does not retry on non-object errors", async () => {
|
|
const fn = mock(() => Promise.reject("string error"));
|
|
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toBe(
|
|
"string error",
|
|
);
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Max attempts exhausted
|
|
// -------------------------------------------------------------------
|
|
test("throws after maxAttempts exhausted", async () => {
|
|
const err: any = new Error("timeout");
|
|
err.isAxiosError = true;
|
|
err.code = "ETIMEDOUT";
|
|
const fn = mock(() => Promise.reject(err));
|
|
await expect(
|
|
withCwRetry(fn, { maxAttempts: 2, baseDelayMs: 1 }),
|
|
).rejects.toThrow();
|
|
expect(fn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("throws the last error when retries exhausted", async () => {
|
|
const err: any = new Error("persistent timeout");
|
|
err.isAxiosError = true;
|
|
err.code = "ETIMEDOUT";
|
|
const fn = mock(() => Promise.reject(err));
|
|
try {
|
|
await withCwRetry(fn, { maxAttempts: 3, baseDelayMs: 1 });
|
|
expect(true).toBe(false); // should not reach
|
|
} catch (e: any) {
|
|
expect(e.message).toBe("persistent timeout");
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Options
|
|
// -------------------------------------------------------------------
|
|
test("defaults to 3 maxAttempts", async () => {
|
|
const err: any = new Error("timeout");
|
|
err.isAxiosError = true;
|
|
err.code = "ETIMEDOUT";
|
|
const fn = mock(() => Promise.reject(err));
|
|
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
|
|
expect(fn).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test("accepts custom maxAttempts", async () => {
|
|
const err: any = new Error("timeout");
|
|
err.isAxiosError = true;
|
|
err.code = "ETIMEDOUT";
|
|
const fn = mock(() => Promise.reject(err));
|
|
await expect(
|
|
withCwRetry(fn, { maxAttempts: 5, baseDelayMs: 1 }),
|
|
).rejects.toThrow();
|
|
expect(fn).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
test("maxAttempts of 1 means no retries", async () => {
|
|
const err: any = new Error("timeout");
|
|
err.isAxiosError = true;
|
|
err.code = "ETIMEDOUT";
|
|
const fn = mock(() => Promise.reject(err));
|
|
await expect(
|
|
withCwRetry(fn, { maxAttempts: 1, baseDelayMs: 1 }),
|
|
).rejects.toThrow();
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|