File size: 4,840 Bytes
fb38ec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import { FastifyBaseLogger } from "fastify";
import {
  BaseLaunchError,
  ConfigurationError,
  LaunchTimeoutError,
  ResourceError,
} from "../services/cdp/errors/launch-errors.js";

export interface RetryOptions {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  backoffMultiplier: number;
  jitterMs?: number;
}

export interface RetryResult<T> {
  result: T;
  attempt: number;
  totalDuration: number;
}

export class RetryError extends Error {
  public readonly attempts: number;
  public readonly lastError: Error;
  public readonly allErrors: Error[];

  constructor(attempts: number, lastError: Error, allErrors: Error[]) {
    super(`Failed after ${attempts} attempts. Last error: ${lastError.message}`);
    this.name = "RetryError";
    this.attempts = attempts;
    this.lastError = lastError;
    this.allErrors = allErrors;
  }
}

/**
 * Retry utility with exponential backoff and jitter for retryable launch errors
 */
export class RetryManager {
  private logger: FastifyBaseLogger;
  private defaultOptions: RetryOptions = {
    maxAttempts: 3,
    baseDelayMs: 500,
    maxDelayMs: 5000,
    backoffMultiplier: 2,
    jitterMs: 250,
  };

  constructor(logger: FastifyBaseLogger) {
    this.logger = logger;
  }

  /**
   * Execute a function with retry logic for retryable errors
   */
  async executeWithRetry<T>(
    operation: () => Promise<T>,
    operationName: string,
    options: Partial<RetryOptions> = {},
  ): Promise<RetryResult<T>> {
    const opts = { ...this.defaultOptions, ...options };
    const errors: Error[] = [];
    const startTime = Date.now();

    for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
      try {
        this.logger.info(
          `[RetryManager] ${operationName} - Attempt ${attempt}/${opts.maxAttempts}`,
        );

        const result = await operation();
        const totalDuration = Date.now() - startTime;

        if (attempt > 1) {
          this.logger.info(
            `[RetryManager] ${operationName} succeeded on attempt ${attempt}/${opts.maxAttempts} after ${totalDuration}ms`,
          );
        }

        return {
          result,
          attempt,
          totalDuration,
        };
      } catch (error) {
        const err = error instanceof Error ? error : new Error(String(error));
        errors.push(err);

        const isRetryable = this.isErrorRetryable(err);
        const isLastAttempt = attempt === opts.maxAttempts;

        this.logger.warn(
          {
            error: err.message,
            isRetryable,
            isLastAttempt,
            errorType: err instanceof BaseLaunchError ? err.type : "unknown",
          },
          `[RetryManager] ${operationName} failed on attempt ${attempt}/${opts.maxAttempts}`,
        );

        if (!isRetryable || isLastAttempt) {
          if (!isRetryable) {
            this.logger.error(
              `[RetryManager] ${operationName} failed with non-retryable error: ${err.message}`,
            );
            throw err; // Throw original error for non-retryable errors
          } else {
            this.logger.error(
              `[RetryManager] ${operationName} failed after ${opts.maxAttempts} attempts`,
            );
            throw new RetryError(attempt, err, errors);
          }
        }

        // Calculate delay with exponential backoff and jitter
        const baseDelay = opts.baseDelayMs * Math.pow(opts.backoffMultiplier, attempt - 1);
        const jitter = opts.jitterMs ? Math.random() * opts.jitterMs : 0;
        const delay = Math.min(baseDelay + jitter, opts.maxDelayMs);

        this.logger.info(
          `[RetryManager] Waiting ${Math.round(delay)}ms before retry ${attempt + 1}/${
            opts.maxAttempts
          }`,
        );
        await this.sleep(delay);
      }
    }

    // This should never be reached, but TypeScript needs it
    throw new RetryError(opts.maxAttempts, errors[errors.length - 1], errors);
  }

  private isErrorRetryable(error: Error): boolean {
    if (
      error instanceof ConfigurationError ||
      error instanceof ResourceError ||
      error instanceof LaunchTimeoutError
    ) {
      return false;
    }

    if (error instanceof BaseLaunchError) {
      return error.isRetryable;
    }

    // For non-categorized errors, we'll be conservative and not retry.
    return false;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  createRetryWrapper<T extends any[], R>(
    method: (...args: T) => Promise<R>,
    operationName: string,
    options: Partial<RetryOptions> = {},
  ): (...args: T) => Promise<R> {
    return async (...args: T): Promise<R> => {
      const result = await this.executeWithRetry(() => method(...args), operationName, options);
      return result.result;
    };
  }
}