alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
256 lines (214 loc) • 6.8 kB
text/typescript
import { $pipeline, Alepha } from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { $retry } from "../index.ts";
describe("$retry", () => {
let alepha: Alepha;
let time: DateTimeProvider;
beforeEach(() => {
alepha = Alepha.create();
time = alepha.inject(DateTimeProvider);
});
afterEach(async () => {
await alepha.stop();
});
test("should retry handler up to max retries", async () => {
class Dummy {
inc = 0;
workRetry = $pipeline({
use: [$retry({ max: 3 })],
handler: (n: number, end: number) => {
this.inc += n;
if (this.inc < end) {
throw new Error("Retry");
}
return this.inc;
},
});
work = async (n: number, end: number) => {
this.inc = 0;
return await this.workRetry.run(n, end);
};
}
const basic = alepha.inject(Dummy);
expect(await basic.work(1, 2)).toBe(2);
expect(await basic.work(1, 3)).toBe(3);
await expect(() => basic.work(1, 4)).rejects.toThrow(Error);
});
test("should only retry when condition matches", async () => {
class Dummy {
inc = 0;
workRetry = $pipeline({
use: [
$retry({
max: 10,
when: (err: Error) => err.message === "Retry1",
}),
],
handler: (n: number, end: number) => {
this.inc += n;
if (this.inc < end) {
throw new Error(`Retry${this.inc}`);
}
return this.inc;
},
});
async work(n: number, end: number) {
this.inc = 0;
return await this.workRetry.run(n, end);
}
}
const basic = alepha.inject(Dummy);
expect(await basic.work(1, 2)).toBe(2);
await expect(() => basic.work(1, 3)).rejects.toThrow(Error);
});
test("should succeed on the first attempt", async () => {
const handler = vi.fn().mockResolvedValue("success");
const retryFunc = alepha.inject(
class {
retry = $pipeline({
use: [$retry()],
handler,
});
},
).retry;
await expect(retryFunc.run()).resolves.toBe("success");
expect(handler).toHaveBeenCalledTimes(1);
});
test("should retry up to max attempts and then fail", async () => {
const handler = vi.fn().mockRejectedValue(new Error("Failed"));
const onError = vi.fn();
const retryFunc = alepha.inject(
class {
retry = $pipeline({
use: [$retry({ max: 3, backoff: 0, onError })],
handler,
});
},
).retry;
await expect(retryFunc.run()).rejects.toThrow("Failed");
expect(handler).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledWith(expect.any(Error), 1);
expect(onError).toHaveBeenCalledWith(expect.any(Error), 2);
expect(onError).toHaveBeenCalledWith(expect.any(Error), 3);
});
test("should succeed after a few failed attempts", async () => {
let attempt = 0;
const handler = vi.fn(() => {
attempt++;
if (attempt < 3) {
return Promise.reject(new Error("Try again"));
}
return Promise.resolve("success");
});
const retryFunc = alepha.inject(
class {
retry = $pipeline({
use: [$retry({ max: 4, backoff: 0 })],
handler,
});
},
).retry;
await expect(retryFunc.run()).resolves.toBe("success");
expect(handler).toHaveBeenCalledTimes(3);
});
test("should respect maxDuration and time out", async () => {
const handler = vi.fn(async () => {
await time.wait(200);
throw new Error("Failed");
});
const retryFunc = alepha.inject(
class {
retry = $pipeline({
use: [$retry({ max: 5, maxDuration: [300, "ms"], backoff: 0 })],
handler,
});
},
).retry;
await expect(retryFunc.run()).rejects.toThrow(
"Retry operation timed out after 300ms.",
);
expect(handler).toHaveBeenCalledTimes(2);
});
test("should be cancellable with an AbortSignal", async () => {
const handler = vi.fn(async () => {
await time.wait(500); // Long delay
throw new Error("Failed");
});
const abortController = new AbortController();
const retryFunc = alepha.inject(
class {
retry = $pipeline({
use: [
$retry({
max: 5,
backoff: 100,
signal: abortController.signal,
}),
],
handler,
});
},
).retry;
const promise = retryFunc.run();
// Let the first attempt start
await time.travel(100);
// Abort during the first handler execution
abortController.abort();
await expect(promise).rejects.toThrow("Retry operation was cancelled.");
// Handler was called once but never completed
expect(handler).toHaveBeenCalledTimes(1);
});
test("should be cancellable by application shutdown", async () => {
// Create a fresh alepha instance for this test
const testAlepha = Alepha.create();
const handler = vi.fn(async () => {
// Throw immediately to trigger retry
throw new Error("Failed");
});
const retryFunc = testAlepha.inject(
class {
retry = $pipeline({
use: [$retry({ max: 10, backoff: 100 })],
handler,
});
},
).retry;
await testAlepha.start();
// Start the retry operation
const promise = retryFunc.run();
// Give it a moment to start
await new Promise((resolve) => setTimeout(resolve, 10));
// Stop the application which should abort all retries
await testAlepha.stop();
// The promise should reject with cancellation error
await expect(promise).rejects.toThrow("Retry operation was cancelled.");
// Handler should have been called at least once but not 10 times
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls.length).toBeLessThan(10);
});
test("should not retry if `when` condition returns false", async () => {
class CustomError extends Error {}
const handler = vi
.fn()
.mockRejectedValueOnce(new CustomError("Do not retry me"))
.mockRejectedValue(new Error("Retry me"));
const retryFunc = alepha.inject(
class {
retry = $pipeline({
use: [
$retry({
max: 3,
backoff: 0,
when: (error) => !(error instanceof CustomError),
}),
],
handler,
});
},
).retry;
await expect(retryFunc.run()).rejects.toThrow(CustomError);
expect(handler).toHaveBeenCalledTimes(1);
});
});