/*
 * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
 * @generated-id: b24a69d15639
 */

import { isConnectionError, isTimeoutError } from "./http.js";

export type BackoffStrategy = {
  initialInterval: number;
  maxInterval: number;
  exponent: number;
  maxElapsedTime: number;
};

const defaultBackoff: BackoffStrategy = {
  initialInterval: 500,
  maxInterval: 60000,
  exponent: 1.5,
  maxElapsedTime: 3600000,
};

export type RetryConfig =
  | { strategy: "none" }
  | {
      strategy: "backoff";
      backoff?: BackoffStrategy;
      retryConnectionErrors?: boolean;
    };

/**
 * PermanentError is an error that is not recoverable. Throwing this error will
 * cause a retry loop to terminate.
 */
export class PermanentError extends Error {
  /** The underlying cause of the error. */
  override readonly cause: unknown;

  constructor(message: string, options?: { cause?: unknown }) {
    let msg = message;
    if (options?.cause) {
      msg += `: ${options.cause}`;
    }

    super(msg, options);
    this.name = "PermanentError";
    // In older runtimes, the cause field would not have been assigned through
    // the super() call.
    if (typeof this.cause === "undefined") {
      this.cause = options?.cause;
    }

    Object.setPrototypeOf(this, PermanentError.prototype);
  }
}

/**
 * TemporaryError is an error is used to signal that an HTTP request can be
 * retried as part of a retry loop. If retry attempts are exhausted and this
 * error is thrown, the response will be returned to the caller.
 */
export class TemporaryError extends Error {
  response: Response;

  constructor(message: string, response: Response) {
    super(message);
    this.response = response;
    this.name = "TemporaryError";

    Object.setPrototypeOf(this, TemporaryError.prototype);
  }
}

export async function retry(
  fetchFn: () => Promise<Response>,
  options: {
    config: RetryConfig;
    statusCodes: string[];
  },
): Promise<Response> {
  switch (options.config.strategy) {
    case "backoff":
      return retryBackoff(
        wrapFetcher(fetchFn, {
          statusCodes: options.statusCodes,
          retryConnectionErrors: !!options.config.retryConnectionErrors,
        }),
        options.config.backoff ?? defaultBackoff,
      );
    default:
      return await fetchFn();
  }
}

function wrapFetcher(
  fn: () => Promise<Response>,
  options: {
    statusCodes: string[];
    retryConnectionErrors: boolean;
  },
): () => Promise<Response> {
  return async () => {
    try {
      const res = await fn();
      if (isRetryableResponse(res, options.statusCodes)) {
        throw new TemporaryError(
          "Response failed with retryable status code",
          res,
        );
      }

      return res;
    } catch (err: unknown) {
      if (err instanceof TemporaryError) {
        throw err;
      }

      if (
        options.retryConnectionErrors &&
        (isTimeoutError(err) || isConnectionError(err))
      ) {
        throw err;
      }

      throw new PermanentError("Permanent error", { cause: err });
    }
  };
}

const codeRangeRE = new RegExp("^[0-9]xx$", "i");

function isRetryableResponse(res: Response, statusCodes: string[]): boolean {
  const actual = `${res.status}`;

  return statusCodes.some((code) => {
    if (!codeRangeRE.test(code)) {
      return code === actual;
    }

    const expectFamily = code.charAt(0);
    if (!expectFamily) {
      throw new Error("Invalid status code range");
    }

    const actualFamily = actual.charAt(0);
    if (!actualFamily) {
      throw new Error(`Invalid response status code: ${actual}`);
    }

    return actualFamily === expectFamily;
  });
}

async function retryBackoff(
  fn: () => Promise<Response>,
  strategy: BackoffStrategy,
): Promise<Response> {
  const { maxElapsedTime, initialInterval, exponent, maxInterval } = strategy;

  const start = Date.now();
  let x = 0;

  while (true) {
    try {
      const res = await fn();
      return res;
    } catch (err: unknown) {
      if (err instanceof PermanentError) {
        throw err.cause;
      }
      const elapsed = Date.now() - start;
      if (elapsed > maxElapsedTime) {
        if (err instanceof TemporaryError) {
          return err.response;
        }

        throw err;
      }

      let retryInterval = 0;
      if (err instanceof TemporaryError) {
        retryInterval = retryIntervalFromResponse(err.response);
      }

      if (retryInterval <= 0) {
        retryInterval =
          initialInterval * Math.pow(x, exponent) + Math.random() * 1000;
      }

      const d = Math.min(retryInterval, maxInterval);

      await delay(d);
      x++;
    }
  }
}

function retryIntervalFromResponse(res: Response): number {
  const retryVal = res.headers.get("retry-after") || "";
  if (!retryVal) {
    return 0;
  }

  const parsedNumber = Number(retryVal);
  if (Number.isInteger(parsedNumber)) {
    return parsedNumber * 1000;
  }

  const parsedDate = Date.parse(retryVal);
  if (Number.isInteger(parsedDate)) {
    const deltaMS = parsedDate - Date.now();
    return deltaMS > 0 ? Math.ceil(deltaMS) : 0;
  }

  return 0;
}

async function delay(delay: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, delay));
}
