feat(core/ui): enhance retry mechanism and UX (#16489)

This commit is contained in:
Sehoon Shon
2026-01-13 23:03:19 -05:00
committed by GitHub
parent 428e602882
commit 4afd3741df
9 changed files with 200 additions and 26 deletions

View File

@@ -97,6 +97,17 @@ export interface HookEndPayload extends HookPayload {
success: boolean;
}
/**
* Payload for the 'retry-attempt' event.
*/
export interface RetryAttemptPayload {
attempt: number;
maxAttempts: number;
delayMs: number;
error?: string;
model: string;
}
export enum CoreEvent {
UserFeedback = 'user-feedback',
ModelChanged = 'model-changed',
@@ -108,6 +119,7 @@ export enum CoreEvent {
HookStart = 'hook-start',
HookEnd = 'hook-end',
AgentsRefreshed = 'agents-refreshed',
RetryAttempt = 'retry-attempt',
}
export interface CoreEvents {
@@ -121,6 +133,7 @@ export interface CoreEvents {
[CoreEvent.HookStart]: [HookStartPayload];
[CoreEvent.HookEnd]: [HookEndPayload];
[CoreEvent.AgentsRefreshed]: never[];
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
}
type EventBacklogItem = {
@@ -229,6 +242,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.AgentsRefreshed);
}
/**
* Notifies subscribers that a retry attempt is happening.
*/
emitRetryAttempt(payload: RetryAttemptPayload): void {
this.emit(CoreEvent.RetryAttempt, payload);
}
/**
* Flushes buffered messages. Call this immediately after primary UI listener
* subscribes.

View File

@@ -101,33 +101,33 @@ describe('retryWithBackoff', () => {
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should default to 3 maxAttempts if no options are provided', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(10);
it('should default to 10 maxAttempts if no options are provided', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
const promise = retryWithBackoff(mockFn);
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 3'),
expect(promise).rejects.toThrow('Simulated error attempt 10'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(3);
expect(mockFn).toHaveBeenCalledTimes(10);
});
it('should default to 3 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(10);
it('should default to 10 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
const promise = retryWithBackoff(mockFn, { maxAttempts: undefined });
// Expect it to fail with the error from the 5th attempt.
// Expect it to fail with the error from the 10th attempt.
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 3'),
expect(promise).rejects.toThrow('Simulated error attempt 10'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(3);
expect(mockFn).toHaveBeenCalledTimes(10);
});
it('should not retry if shouldRetry returns false', async () => {

View File

@@ -32,10 +32,11 @@ export interface RetryOptions {
retryFetchErrors?: boolean;
signal?: AbortSignal;
getAvailabilityContext?: () => RetryAvailabilityContext | undefined;
onRetry?: (attempt: number, error: unknown, delayMs: number) => void;
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxAttempts: 3,
maxAttempts: 10,
initialDelayMs: 5000,
maxDelayMs: 30000, // 30 seconds
shouldRetryOnError: isRetryableError,
@@ -149,6 +150,7 @@ export async function retryWithBackoff<T>(
retryFetchErrors,
signal,
getAvailabilityContext,
onRetry,
} = {
...DEFAULT_RETRY_OPTIONS,
shouldRetryOnError: isRetryableError,
@@ -172,6 +174,9 @@ export async function retryWithBackoff<T>(
) {
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
if (onRetry) {
onRetry(attempt, new Error('Invalid content'), delayWithJitter);
}
await delay(delayWithJitter, signal);
currentDelay = Math.min(maxDelayMs, currentDelay * 2);
continue;
@@ -252,6 +257,9 @@ export async function retryWithBackoff<T>(
debugLogger.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
if (onRetry) {
onRetry(attempt, error, classifiedError.retryDelayMs);
}
await delay(classifiedError.retryDelayMs, signal);
continue;
} else {
@@ -261,6 +269,9 @@ export async function retryWithBackoff<T>(
// Exponential backoff with jitter for non-quota errors
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
if (onRetry) {
onRetry(attempt, error, delayWithJitter);
}
await delay(delayWithJitter, signal);
currentDelay = Math.min(maxDelayMs, currentDelay * 2);
continue;
@@ -281,6 +292,9 @@ export async function retryWithBackoff<T>(
// Exponential backoff with jitter for non-quota errors
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
if (onRetry) {
onRetry(attempt, error, delayWithJitter);
}
await delay(delayWithJitter, signal);
currentDelay = Math.min(maxDelayMs, currentDelay * 2);
}