lib/gen_retry.ex

defmodule GenRetry do
  @moduledoc ~s"""
  GenRetry provides utilities for retrying Elixir functions,
  with configurable delay and backoff characteristics.

  ## Summary

  Given a 0-arity function which raises an exception upon failure, `retry/2`
  and `retry_link/2` repeatedly executes the function until success is
  reached or the maximum number of retries has occurred.

  `GenRetry.Task.async/2` and `GenRetry.Task.Supervisor.async/3`
  provide drop-in replacements for `Task.async/1` and
  `Task.Supervisor.async/2`, respectively, adding retry capability.
  They return plain `%Task{}` structs, usable with any other function in
  the `Task` module.


  ## Examples

      my_background_function = fn ->
        :ok = try_to_send_tps_reports()
      end
      GenRetry.retry(my_background_function, retries: 10, delay: 10_000)

      my_future_function = fn ->
        {:ok, val} = get_val_from_flaky_network_service()
        val
      end
      t = GenRetry.Task.async(my_future_function, retries: 3)
      my_val = Task.await(t)  # may raise exception


  ## Installation

  1. Add GenRetry to your list of dependencies in `mix.exs`:

          def deps do
            [{:gen_retry, "~> #{GenRetry.Mixfile.project()[:version]}"}]
          end

  2. Ensure GenRetry is started before your application:

          def application do
            [applications: [:gen_retry]]
          end

  3. (Optional) Specify a custom logging module in your config.exs

          config :gen_retry, GenRetry.Logger, logger: MyApp.CustomLogger

      where MyApp.CustomLogger implements the GenRetry.Logger behavior.
      The default module is GenRetry.Utils if none is specified in a config.

  ## Options

  * `:retries`, integer (default 1):
    Number of times to retry upon failure.  Set to
    0 to try exactly once; set to `:infinity` to retry forever.

  * `:delay`, integer (default 1000):
    Number of milliseconds to wait between first failure and first retry.
    Subsequent retries use this value as a starting point for exponential
    backoff.

  * `:jitter`, number (default 0):
    Proportion of current retry delay to randomly add to delay time.
    For example, given options `delay: 1000, jitter: 0.1`, the first delay
    will be a random time between 1000 and 1100 milliseconds.  Values
    under 0 will remove time rather than add it; beware of values under -1,
    which may result in nonsensical "negative time delays".

  * `:exp_base`, number (default 2):
    The base to use for exponentiation during exponential backoff.
    Set to `1` to disable backoff.  Values less than 1 are not very useful.

  * `:respond_to`, pid (ignored by `GenRetry.Task.*`):
    The process ID to which a message should be sent upon completion.
    Successful exits send `{:success, return_value, final_retry_state}`;
    unsuccessful exits send `{:failure, error, stacktrace, final_retry_state}`.

  * `:on_success`, `fn {result, final_retry_state} -> nil end` (default no-op):
    A function to run on success. The argument is a tuple containing the result and the final retry state.

  * `:on_failure`, `fn {exception, stacktrace, final_retry_state} -> nil end` (default no-op):
    A function to run on failure. The argument is a tuple containing the exception, stacktrace, and final retry state.

  """

  use Application

  @doc false
  def start(_type, _args) do
    import Supervisor.Spec, warn: false

    children = [
      worker(GenRetry.Launcher, [[], [name: :gen_retry_launcher]])
    ]

    opts = [strategy: :one_for_one, name: GenRetry.Supervisor]
    Supervisor.start_link(children, opts)
  end

  defmodule State do
    @moduledoc ~S"""
    Used to represent the state of a GenRetry invocation.
    This struct is part of the success or failure message to be
    optionally sent to another process, specified by `opts[:respond_to]`,
    upon completion.

    * `:function` and `:opts` are the invocation arguments supplied by the user.
    * `:tries` is the total number of attempts made before sending this message.
    * `:retry_at` is either the timestamp of the last attempt, or 0
      (if `opts[:retries] == 0`).
    """

    # the function to retry
    defstruct function: nil,
              # %GenRetry.Options{} from caller
              opts: nil,
              # number of tries performed so far
              tries: 0,
              # :erlang.system_time(:milli_seconds)
              retry_at: 0,
              # a function for logging
              logger: nil

    @type t :: %__MODULE__{
            function: GenRetry.retryable_fun(),
            opts: GenRetry.Options.t(),
            tries: non_neg_integer,
            retry_at: non_neg_integer
          }
  end

  defmodule Options do
    @moduledoc false
    defstruct retries: 1,
              delay: 1000,
              jitter: 0,
              exp_base: 2,
              respond_to: nil,
              on_success: nil,
              on_failure: nil

    @type t :: %__MODULE__{
            retries: :infinity | non_neg_integer,
            delay: non_neg_integer,
            jitter: number,
            exp_base: number,
            respond_to: pid | nil,
            on_success: GenRetry.on_success(),
            on_failure: GenRetry.on_failure()
          }

    use ExConstructor
  end

  @type option ::
          {:retries, :infinity | non_neg_integer}
          | {:delay, non_neg_integer}
          | {:jitter, number}
          | {:exp_base, number}
          | {:respond_to, pid}
          | {:on_success, on_success()}
          | {:on_failure, on_failure()}

  @type options :: [option]

  @type retryable_fun :: (() -> any | no_return)

  @type success_msg :: {:success, any, GenRetry.State.t()}

  @type failure_msg ::
          {:failure, Exception.t(), [:erlang.stack_item()], GenRetry.State.t()}

  @type on_success ::
          ({result :: any(), final_retry_state :: GenRetry.State.t()} -> any())

  @type on_failure ::
          ({exception :: any(), stacktrace :: list(),
            final_retry_state :: GenRetry.State.t()} ->
             any())

  @doc ~S"""
  Starts a retryable process linked to `GenRetry.Supervisor`, and returns its
  pid.  `fun` should be a function that raises an exception upon failure;
  any other return value is treated as success.
  """
  @spec retry(retryable_fun, options) :: pid
  def retry(fun, opts \\ []) do
    GenRetry.Launcher.launch(fun, opts)
  end

  @doc ~S"""
  Starts a retryable process linked to the current process, and returns its
  pid.  `fun` should be a function that raises an exception upon failure;
  any other return value is treated as success.
  """
  @spec retry_link(retryable_fun, options) :: pid
  def retry_link(fun, opts \\ []) do
    {:ok, pid} =
      GenServer.start_link(
        GenRetry.Worker,
        {fun, Options.new(opts)},
        timeout: :infinity
      )

    pid
  end
end