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