Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/appkit/src/plugin/interceptors/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ export class RetryInterceptor implements ExecutionInterceptor {
private attempts: number;
private initialDelay: number;
private maxDelay: number;
private jitter: boolean;
Comment thread
MarioCadenas marked this conversation as resolved.
Outdated

constructor(config: RetryConfig) {
this.attempts = config.attempts ?? 3;
this.initialDelay = config.initialDelay ?? 1000;
this.maxDelay = config.maxDelay ?? 30000;
this.jitter = config.jitter ?? true;
}

async intercept<T>(
Expand Down Expand Up @@ -59,11 +61,13 @@ export class RetryInterceptor implements ExecutionInterceptor {
}

private calculateDelay(attempt: number): number {
// exponential backoff
const delay = this.initialDelay * 2 ** (attempt - 1);
const capped = Math.min(delay, this.maxDelay);

// max delay cap
return Math.min(delay, this.maxDelay);
if (!this.jitter) return capped;

// Full jitter: random value between 50% and 100% of the calculated delay
return capped * (0.5 + Math.random() * 0.5);
Comment thread
MarioCadenas marked this conversation as resolved.
Outdated
}

private sleep(ms: number): Promise<void> {
Expand Down
71 changes: 69 additions & 2 deletions packages/appkit/src/plugin/tests/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,12 @@ describe("RetryInterceptor", () => {
expect(fn).toHaveBeenCalledTimes(3);
});

test("should use exponential backoff", async () => {
test("should use exponential backoff with jitter disabled", async () => {
const config: RetryConfig = {
enabled: true,
attempts: 4,
initialDelay: 1000,
jitter: false,
};
const interceptor = new RetryInterceptor(config);
const fn = vi
Expand Down Expand Up @@ -111,7 +112,8 @@ describe("RetryInterceptor", () => {
enabled: true,
attempts: 10,
initialDelay: 1000,
maxDelay: 5000, // Cap at 5 seconds
maxDelay: 5000,
jitter: false,
};
const interceptor = new RetryInterceptor(config);
const fn = vi.fn().mockRejectedValue(new Error("fail"));
Expand Down Expand Up @@ -170,4 +172,69 @@ describe("RetryInterceptor", () => {
await expect(interceptor.intercept(fn, context)).rejects.toThrow("fail");
expect(fn).toHaveBeenCalledTimes(1);
});

test("should add jitter to delay by default", async () => {
const config: RetryConfig = {
enabled: true,
attempts: 3,
initialDelay: 1000,
};
const interceptor = new RetryInterceptor(config);

vi.spyOn(Math, "random").mockReturnValue(0.5);

const fn = vi
.fn()
.mockRejectedValueOnce(new Error("fail 1"))
.mockResolvedValue("success");

interceptor.intercept(fn, context);

// With jitter: delay = 1000 * (0.5 + 0.5 * 0.5) = 750ms
await vi.advanceTimersByTimeAsync(749);
expect(fn).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(1);
expect(fn).toHaveBeenCalledTimes(2);

vi.spyOn(Math, "random").mockRestore();
});

test("should produce delays between 50% and 100% of base delay with jitter", async () => {
const config: RetryConfig = {
enabled: true,
attempts: 3,
initialDelay: 1000,
};

// At Math.random() = 0, delay = 1000 * (0.5 + 0) = 500ms (minimum)
vi.spyOn(Math, "random").mockReturnValue(0);
const interceptorMin = new RetryInterceptor(config);
const fnMin = vi
.fn()
.mockRejectedValueOnce(new Error("fail"))
.mockResolvedValue("ok");

interceptorMin.intercept(fnMin, context);
await vi.advanceTimersByTimeAsync(499);
expect(fnMin).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(fnMin).toHaveBeenCalledTimes(2);

// At Math.random() = 1, delay = 1000 * (0.5 + 0.5) = 1000ms (maximum)
vi.spyOn(Math, "random").mockReturnValue(1);
const interceptorMax = new RetryInterceptor(config);
const fnMax = vi
.fn()
.mockRejectedValueOnce(new Error("fail"))
.mockResolvedValue("ok");

interceptorMax.intercept(fnMax, context);
await vi.advanceTimersByTimeAsync(999);
expect(fnMax).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(fnMax).toHaveBeenCalledTimes(2);

vi.spyOn(Math, "random").mockRestore();
});
});
4 changes: 3 additions & 1 deletion packages/shared/src/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ export interface StreamConfig {
maxActiveStreams?: number;
}

/** Retry configuration for the RetryInterceptor. Uses exponential backoff between attempts. */
/** Retry configuration for the RetryInterceptor. Uses exponential backoff with jitter between attempts. */
export interface RetryConfig {
enabled?: boolean;
attempts?: number;
initialDelay?: number;
maxDelay?: number;
/** Whether to add random jitter to retry delays to avoid thundering herd. Defaults to `true`. */
jitter?: boolean;
}

/** Telemetry configuration for the TelemetryInterceptor. Controls span creation and custom attributes. */
Expand Down
Loading