lib/ex_waiter/polling.ex

defmodule ExWaiter.Polling do
  alias ExWaiter.Polling.Attempt
  alias ExWaiter.Polling.InvalidResult
  alias ExWaiter.Polling.Poller

  @valid_options [:delay, :num_attempts, :on_complete]

  def poll(polling_fn, opts) do
    Enum.each(opts, fn {key, _} ->
      unless key in @valid_options do
        raise "#{key} is not a valid option"
      end
    end)

    num_attempts = Keyword.get(opts, :num_attempts, 5)

    unless is_integer(num_attempts) || num_attempts == :infinity do
      raise ":num_attempts must be either an integer (ms) or :infinity"
    end

    %Poller{
      polling_fn: polling_fn,
      delay: Keyword.get(opts, :delay, &delay_default/1),
      num_attempts: Keyword.get(opts, :num_attempts, 5),
      on_complete: Keyword.get(opts, :on_complete, & &1)
    }
    |> attempt()
  end

  defp attempt(%Poller{attempt_num: num, num_attempts: num} = poller) do
    poller.on_complete.(poller)
    {:error, poller}
  end

  defp attempt(%Poller{} = poller) do
    poller = init_attempt(poller)

    delay = determine_delay(poller)
    Process.sleep(delay)

    case handle_polling_fn(poller) do
      {:ok, _} = result -> handle_successful_attempt(poller, result, delay)
      :ok = result -> handle_successful_attempt(poller, result, delay)
      {:error, _} = result -> handle_failed_attempt(poller, result, delay)
      :error = result -> handle_failed_attempt(poller, result, delay)
      result -> raise InvalidResult, result
    end
  end

  defp init_attempt(%Poller{} = poller) do
    %{poller | attempt_num: poller.attempt_num + 1}
  end

  defp handle_successful_attempt(%Poller{} = poller, value, delay) do
    poller = record_attempt(poller, value, delay)
    poller.on_complete.(poller)
    {:ok, poller}
  end

  defp handle_failed_attempt(%Poller{} = poller, value, delay) do
    poller
    |> record_attempt(value, delay)
    |> attempt()
  end

  defp record_attempt(%Poller{} = poller, value, delay) do
    attempts =
      [
        %Attempt{
          value: value,
          delay: delay
        }
        | Enum.reverse(poller.attempts)
      ]
      |> Enum.reverse()

    %{
      poller
      | attempts: attempts,
        value: value,
        total_delay: poller.total_delay + delay
    }
  end

  defp determine_delay(%Poller{delay: ms}) when is_integer(ms), do: ms

  defp determine_delay(%Poller{delay: delay_fn} = poller)
       when is_function(delay_fn),
       do: delay_fn.(poller)

  defp delay_default(%Poller{} = poller) do
    poller.attempt_num * 10
  end

  defp handle_polling_fn(%Poller{polling_fn: polling_fn} = poller) do
    case :erlang.fun_info(polling_fn)[:arity] do
      1 -> polling_fn.(poller)
      0 -> polling_fn.()
      _ -> raise "Poller function must have an arity of either 0 or 1"
    end
  end
end