lib/xandra/retry_strategy.ex

defmodule Xandra.RetryStrategy do
  @moduledoc """
  A behaviour that handles how to retry failed queries.

  This behaviour makes it possible to customize the strategy that Xandra uses to
  retry failed queries. By default, Xandra does not retry failed queries, and
  does not provide any default retry strategy since retrying queries based on
  the failure reason is very tied to application logic.

  A module that implements the `Xandra.RetryStrategy` behaviour can be passed to
  several functions in the `Xandra` module: look at the documentation for
  `Xandra` for more information.

  ## Usage

  When a query fails and a retry strategy module was passed as an option, Xandra
  will:

    1. invoke the `c:new/1` callback with the options passed to the failing
       function to initialize the given retry strategy

    1. ask the retry strategy whether to retry or error out (`c:retry/3`) until
       either the query succeeds or `c:retry/3` says to error out

  The `c:new/1` and `c:retry/3` callbacks will be invoked in the same
  process that executed the original query.

  If `c:retry/3` says to retry a query, such query will be retried on a
  different Xandra connection than the one the query was first executed
  through. For more information, see the documentation for `c:retry/3`.

  ## Examples

  This is an example of a retry strategy that retries a fixed number of times
  before failing. It reads the allowed number of retries from the options.

      defmodule MyApp.CounterRetryStrategy do
        @behaviour Xandra.RetryStrategy

        def new(options) do
          Keyword.fetch!(options, :retry_count)
        end

        def retry(_error, _options, _retries_left = 0) do
          :error
        end

        def retry(_error, options, retries_left) do
          {:retry, options, retries_left - 1}
        end
      end

  Another interesting example could be a retry strategy based on downgrading
  consistency: for example, we could execute all queries with a high consistency
  (such as `:all`) at first, and in case of failure, try again with a lower
  consistency (such as `:quorum`), finally giving up if that fails as well.

      defmodule MyApp.DowngradingConsistencyRetryStrategy do
        @behaviour Xandra.RetryStrategy

        def new(_options) do
          :no_state
        end

        def retry(_error, options, :no_state) do
          case Keyword.fetch(options, :consistency) do
            # No consistency was specified, so we don't bother to retry.
            :error ->
              :error
            {:ok, :all} ->
              {:retry, Keyword.put(options, :consistency, :quorum), :no_state}
            {:ok, _other} ->
              :error
          end
        end
      end

  """

  @type state :: term

  @doc """
  Initializes the state of a retry strategy based on the given `options`.
  """
  @callback new(options :: keyword) :: state

  @doc """
  Determines whether to retry the failed query or return the error.

  The first argument is the error that caused the query to fail: for example, it
  could be a `Xandra.Error` struct with reason `:read_timeout`. This can be used
  to determine the retry strategy based on the failure reason. The second
  argument is the options given to the function that failed while executing the
  query. The third argument is the retry strategy state returned either by
  `c:new/1` (if this was the first attempt to retry) or by subsequent calls to
  `c:retry/3`.

  If `:error` is returned, the function that was trying to execute the query
  will return the error to the caller instead of retrying.

  If `{:retry, new_options, new_state}` is returned, the function that was
  trying to execute the query will be invoked again with the same query and
  `new_options` as its options. `new_state` will be used if the query fails
  again: in that case, `c:retry/3` will be invoked again with `new_state` as its
  third argument. This process will continue until either the query is executed
  successfully or this callback returns `:error`.

  Note that when `{:retry, new_options, new_state}` is returned, the query will
  be executed again *on a different Xandra connection*. This behaviour is
  particularly useful with pooled connections and especially when using
  `Xandra.Cluster` as the pool, since it will mean that there's a chance the
  retried query will be executed on a different node altogether.
  """
  @callback retry(error :: term, options :: keyword, state) ::
              :error | {:retry, new_options :: keyword, new_state :: state}

  @doc false
  @spec run_with_retrying(keyword, (() -> result)) :: result when result: var
  def run_with_retrying(options, fun) do
    case Keyword.pop(options, :retry_strategy) do
      {nil, _options} -> fun.()
      {retry_strategy, options} -> run_with_retrying(options, retry_strategy, fun)
    end
  end

  defp run_with_retrying(options, retry_strategy, fun) do
    with {:error, reason} <- fun.() do
      {retry_state, options} =
        Keyword.pop_lazy(options, :retrying_state, fn ->
          retry_strategy.new(options)
        end)

      case retry_strategy.retry(reason, options, retry_state) do
        :error ->
          {:error, reason}

        {:retry, new_options, new_retry_state} ->
          new_options = Keyword.put(new_options, :retrying_state, new_retry_state)
          run_with_retrying(new_options, retry_strategy, fun)

        other ->
          raise ArgumentError,
                "invalid return value #{inspect(other)} from " <>
                  "retry strategy #{inspect(retry_strategy)} " <>
                  "with state #{inspect(retry_state)}"
      end
    end
  end
end