const DEFAULT_FETCH_TIMEOUT_MS = 15_000;

export interface FetchWithTimeoutOptions {
  timeoutMs?: number;
  resource?: string;
}

export class FetchTimeoutError extends Error {
  readonly name = 'FetchTimeoutError';

  constructor(
    readonly resource: string,
    readonly timeoutMs: number,
  ) {
    super(`Fetch timed out after ${timeoutMs}ms: ${resource}`);
  }
}

export async function fetchWithTimeout(
  input: RequestInfo | URL,
  init: RequestInit = {},
  options: FetchWithTimeoutOptions = {},
): Promise<Response> {
  const timeoutMs = Math.max(1, Math.trunc(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS));
  const resource = options.resource ?? describeFetchInput(input);
  const controller = new AbortController();
  let timedOut = false;

  const abortFromParent = () => controller.abort(init.signal?.reason);
  if (init.signal) {
    if (init.signal.aborted) {
      controller.abort(init.signal.reason);
    } else {
      init.signal.addEventListener('abort', abortFromParent, { once: true });
    }
  }

  const timeoutId = setTimeout(() => {
    timedOut = true;
    controller.abort();
  }, timeoutMs);

  try {
    return await fetch(input, { ...init, signal: controller.signal });
  } catch (error) {
    if (timedOut) {
      throw new FetchTimeoutError(resource, timeoutMs);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
    init.signal?.removeEventListener?.('abort', abortFromParent);
  }
}

function describeFetchInput(input: RequestInfo | URL): string {
  if (input instanceof URL) return input.toString();
  if (typeof input === 'string') return input;
  if (typeof Request !== 'undefined' && input instanceof Request) {
    return input.url;
  }
  return 'unknown resource';
}
