alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
439 lines (356 loc) • 12.6 kB
text/typescript
import { Alepha } from "alepha";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { RetryCancelError } from "../errors/RetryCancelError.ts";
import { RetryTimeoutError } from "../errors/RetryTimeoutError.ts";
import { RetryProvider } from "../providers/RetryProvider.ts";
describe("RetryProvider", () => {
let alepha: Alepha;
let retryProvider: RetryProvider;
beforeEach(async () => {
alepha = Alepha.create();
retryProvider = alepha.inject(RetryProvider);
await alepha.start();
});
test("should retry handler up to max retries", async () => {
let attempts = 0;
const handler = vi.fn(() => {
attempts++;
if (attempts < 3) {
throw new Error("Retry");
}
return "success";
});
const result = await retryProvider.retry({ handler, max: 3 });
expect(result).toBe("success");
expect(handler).toHaveBeenCalledTimes(3);
});
test("should throw error after max retries exceeded", async () => {
const handler = vi.fn(() => {
throw new Error("Always fails");
});
await expect(retryProvider.retry({ handler, max: 3 })).rejects.toThrowError(
"Always fails",
);
expect(handler).toHaveBeenCalledTimes(3);
});
test("should only retry when condition matches", async () => {
let attempts = 0;
const handler = vi.fn(() => {
attempts++;
throw new Error(`Error${attempts}`);
});
const when = (error: Error) => error.message === "Error1";
await expect(
retryProvider.retry({ handler, max: 10, when }),
).rejects.toThrowError("Error2");
// Should fail on second attempt because when() returns false
expect(handler).toHaveBeenCalledTimes(2);
});
test("should use fixed backoff delay", async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
const startTime = Date.now();
await expect(
retryProvider.retry({ handler, max: 3, backoff: 100 }),
).rejects.toThrowError("Retry");
const duration = Date.now() - startTime;
// Should have waited ~200ms total (2 retries × 100ms)
expect(duration).toBeGreaterThanOrEqual(190);
expect(duration).toBeLessThan(300);
});
test("should use exponential backoff with jitter", async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
const startTime = Date.now();
await expect(
retryProvider.retry({
handler,
max: 3,
backoff: { initial: 100, factor: 2, jitter: true },
}),
).rejects.toThrowError("Retry");
const duration = Date.now() - startTime;
// First retry: ~100ms, Second retry: ~200ms
// With jitter, expect at least base delays
expect(duration).toBeGreaterThanOrEqual(280);
});
test("should respect maxDuration timeout", { retry: 3 }, async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
await expect(
retryProvider.retry({
handler,
max: 10,
maxDuration: [100, "milliseconds"],
backoff: 50,
}),
).rejects.toThrowError(RetryTimeoutError);
// Should not reach max retries due to timeout
expect(handler).toHaveBeenCalledTimes(2);
});
test("should respect AbortSignal cancellation", async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
const abortController = new AbortController();
// Abort after first attempt
setTimeout(() => abortController.abort(), 50);
await expect(
retryProvider.retry({
handler,
max: 10,
backoff: 100,
signal: abortController.signal,
}),
).rejects.toThrowError(RetryCancelError);
expect(handler).toHaveBeenCalledTimes(1);
});
test("should call onError callback on each failure", async () => {
let attempts = 0;
const handler = vi.fn(() => {
attempts++;
if (attempts < 3) {
throw new Error(`Attempt ${attempts}`);
}
return "success";
});
const onError = vi.fn();
await retryProvider.retry({ handler, max: 3, onError });
expect(onError).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ message: "Attempt 1" }),
1,
);
expect(onError).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ message: "Attempt 2" }),
2,
);
});
test("should pass arguments to handler", async () => {
const handler = vi.fn((a: number, b: string) => {
return `${a}-${b}`;
});
const result = await retryProvider.retry({ handler, max: 1 }, 42, "test");
expect(result).toBe("42-test");
expect(handler).toHaveBeenCalledWith(42, "test");
});
test("should log warnings on retry failures", async () => {
const handler = vi.fn(() => {
throw new Error("Test error");
});
// Spy on the log method
// @ts-expect-error - accessing protected property for testing
const logSpy = vi.spyOn(retryProvider.log, "warn");
await expect(retryProvider.retry({ handler, max: 2 })).rejects.toThrowError(
"Test error",
);
expect(logSpy).toHaveBeenCalledTimes(2);
expect(logSpy).toHaveBeenCalledWith(
"Retry attempt failed",
expect.objectContaining({
attempt: 1,
maxAttempts: 2,
remainingAttempts: 1,
error: "Test error",
errorName: "Error",
}),
);
});
test("should support additionalSignal for combined cancellation", async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
const additionalController = new AbortController();
// Abort the additional signal
setTimeout(() => additionalController.abort(), 50);
await expect(
retryProvider.retry({
handler,
max: 10,
backoff: 100,
additionalSignal: additionalController.signal,
}),
).rejects.toThrowError(RetryCancelError);
expect(handler).toHaveBeenCalledTimes(1);
});
test("should not retry on non-Error throws", async () => {
const handler = vi.fn(() => {
throw "string error";
});
await expect(retryProvider.retry({ handler, max: 3 })).rejects.toBe(
"string error",
);
expect(handler).toHaveBeenCalledTimes(1);
});
test("should succeed on first attempt without retries", async () => {
const handler = vi.fn(() => "immediate success");
const result = await retryProvider.retry({ handler, max: 3 });
expect(result).toBe("immediate success");
expect(handler).toHaveBeenCalledTimes(1);
});
test("should use default max of 3 when not specified", async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
await expect(retryProvider.retry({ handler })).rejects.toThrowError(
"Retry",
);
expect(handler).toHaveBeenCalledTimes(3);
});
// Bug #1 (Red/Critical): Signal precedence issue
// When both signal and additionalSignal are provided, ONLY signal is respected during backoff
// This means additionalSignal (app lifecycle) cannot cancel during backoff if user signal exists
test("should respect both user signal and additionalSignal during backoff waits", async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
const userController = new AbortController();
const additionalController = new AbortController();
// Start retry with both signals provided
const promise = retryProvider.retry({
handler,
max: 10,
backoff: 200, // Long backoff to ensure we have time to abort
signal: userController.signal,
additionalSignal: additionalController.signal,
});
// Wait for first attempt to fail and backoff to start
await new Promise((resolve) => setTimeout(resolve, 10));
// Abort additionalSignal during backoff (user signal still active)
additionalController.abort();
// BUG: This currently does NOT throw RetryCancelError because line 169 only uses signal
// The additionalSignal abort is ignored during wait
// EXPECTED: Should throw RetryCancelError
// ACTUAL: Continues retrying until user signal aborts
const startTime = Date.now();
try {
await promise;
// If we reach here, the bug exists - additionalSignal was ignored
throw new Error("Should have thrown RetryCancelError");
} catch (error) {
const duration = Date.now() - startTime;
// If bug exists: continues for ~200ms+ (full backoff)
// If fixed: cancels immediately (~10-30ms)
if (error instanceof RetryCancelError) {
// Fixed: should cancel quickly when additionalSignal aborts
expect(duration).toBeLessThan(100);
} else {
// Bug exists: keeps waiting for full backoff despite additionalSignal abort
expect(duration).toBeGreaterThanOrEqual(180);
}
}
});
// Bug #2 (Yellow/High): Abort race condition
test("should throw RetryCancelError when aborted after handler error", async () => {
let attempts = 0;
const handler = vi.fn(() => {
attempts++;
if (attempts === 1) {
throw new Error("Handler failed");
}
throw new Error("Should not reach here");
});
const abortController = new AbortController();
// Abort immediately after first error (before backoff wait)
setTimeout(() => abortController.abort(), 10);
await expect(
retryProvider.retry({
handler,
max: 10,
backoff: 100,
signal: abortController.signal,
}),
).rejects.toThrowError(RetryCancelError);
// Should have attempted once, then aborted
expect(handler).toHaveBeenCalledTimes(1);
});
// Bug #3 (Yellow/High): Timeout check timing
test("should not allow handler to complete after maxDuration", async () => {
const handler = vi.fn(async () => {
// Handler takes 150ms
await new Promise((resolve) => setTimeout(resolve, 150));
throw new Error("Retry");
});
const startTime = Date.now();
await expect(
retryProvider.retry({
handler,
max: 10,
maxDuration: [100, "milliseconds"],
backoff: 10,
}),
).rejects.toThrowError(RetryTimeoutError);
const duration = Date.now() - startTime;
// Should timeout around 100ms, not wait for handler to complete (150ms)
// With current bug, this will fail because handler completes at ~150ms
expect(duration).toBeLessThan(200);
expect(handler).toHaveBeenCalledTimes(1);
});
// Bug #7 (Medium): onError not called on final attempt
test("should call onError on the final failed attempt", async () => {
const handler = vi.fn(() => {
throw new Error("Always fails");
});
const onError = vi.fn();
await expect(
retryProvider.retry({
handler,
max: 3,
backoff: 0,
onError,
}),
).rejects.toThrowError("Always fails");
// Should call onError for ALL attempts, including the final one
// BUG: Currently only called 2 times (attempts 1-2), not 3 times
expect(onError).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ message: "Always fails" }),
1,
);
expect(onError).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ message: "Always fails" }),
2,
);
expect(onError).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ message: "Always fails" }),
3,
);
});
// Bug #8 (Critical): AbortSignal.any() memory leak
test("should not create multiple AbortSignal instances during retries", async () => {
const handler = vi.fn(() => {
throw new Error("Retry");
});
const userController = new AbortController();
const additionalController = new AbortController();
// Track how many times AbortSignal.any is called
const originalAny = AbortSignal.any;
const anySpy = vi.fn(originalAny);
AbortSignal.any = anySpy as any;
try {
await expect(
retryProvider.retry({
handler,
max: 5,
backoff: 10,
signal: userController.signal,
additionalSignal: additionalController.signal,
}),
).rejects.toThrowError("Retry");
// BUG: Currently creates a NEW signal for each backoff (4 times for 5 attempts)
// EXPECTED: Should only create ONE combined signal at the start
expect(anySpy).toHaveBeenCalledTimes(1);
} finally {
// Restore original
AbortSignal.any = originalAny;
}
});
});