defmodule Moar.Retry do
# @related [test](/test/retry_test.exs)
@moduledoc """
Retryable functions.
These functions are particularly useful in higher-level tests, though they can be used for any
kind of waiting.
See also the [retry](https://hex.pm/packages/retry) Hex package.
One use of `rescue_for/3` in a test is to wait until an assertion succeeds:
```elixir
# wait up to 5 seconds for the element with ID "status" to be "finished"
Moar.Retry.rescue_for!(5000, fn ->
assert view |> element("#status") |> render() == "finished"
end)
```
One use of `retry_for/3` in a test is to wait until something changes before making an assertion:
```elixir
Moar.Retry.retry_for(5000, fn ->
view |> element("#status") |> render() == "finished"
end)
assert view |> element("#total") |> render() == "8,675,309"
```
"""
@default_interval 100
@doc """
Run `fun` every `interval_ms` until it doesn't raise an exception.
If `timeout` expires, the exception will be re-raised.
* `timeout` can be an integer in milliseconds or a `Moar.Duration` tuple
```elixir
iex> Moar.Retry.rescue_for!(20, fn -> raise "always fails" end, 2)
** (RuntimeError) always fails
iex> Moar.Retry.rescue_for!({20, :millisecond}, fn -> raise "always fails" end, 2)
** (RuntimeError) always fails
```
"""
@spec rescue_for!(pos_integer() | Moar.Duration.t(), (() -> any()), pos_integer()) :: any() | no_return
def rescue_for!(timeout, fun, interval_ms \\ @default_interval) do
expiry = Moar.DateTime.add(DateTime.utc_now(), duration(timeout))
rescue_until!(expiry, fun, interval_ms)
end
@doc """
Run `fun` every `interval_ms` until it doesn't raise an exception.
If the current time reaches `expiry`, the exception will be re-raised.
```elixir
iex> date_time = DateTime.add(DateTime.utc_now(), 20, :millisecond)
iex> Moar.Retry.rescue_until!(date_time, fn -> raise "always fails" end, 2)
** (RuntimeError) always fails
```
"""
@spec rescue_until!(DateTime.t(), (() -> any()), pos_integer()) :: any() | no_return
def rescue_until!(%DateTime{} = expiry, fun, interval_ms \\ @default_interval) do
fun.()
rescue
e ->
if DateTime.compare(expiry, DateTime.utc_now()) == :gt do
:timer.sleep(interval_ms)
rescue_until!(expiry, fun)
else
reraise e, __STACKTRACE__
end
end
@doc """
Run `fun` every `interval_ms` until it returns a truthy value, returning `{:ok, <value>}`.
If `timeout` expires, returns `{:error, :timeout}`.
* `timeout` can be an integer in milliseconds or a `Moar.Duration` tuple
```elixir
iex> Moar.Retry.retry_for(20, fn -> 10 end, 2)
{:ok, 10}
iex> Moar.Retry.retry_for(20, fn -> false end, 2)
{:error, :timeout}
```
"""
@spec retry_for(pos_integer() | Moar.Duration.t(), fun(), pos_integer()) :: {:ok, any()} | {:error, :timeout}
def retry_for(timeout, fun, interval_ms \\ @default_interval) do
expiry = Moar.DateTime.add(DateTime.utc_now(), duration(timeout))
retry_until(expiry, fun, interval_ms)
end
@doc """
Run `fun` every `interval_ms` until it returns a truthy value, returning `{:ok, <value>}`.
If the current time reaches `expiry`, returns `{:error, :timeout}`.
```elixir
iex> date_time = DateTime.add(DateTime.utc_now(), 20, :millisecond)
iex> Moar.Retry.retry_until(date_time, fn -> false end, 2)
{:error, :timeout}
```
"""
@spec retry_until(DateTime.t(), fun(), pos_integer()) :: {:ok, any()} | {:error, :timeout}
def retry_until(expiry, fun, interval_ms \\ @default_interval) do
cond do
result = fun.() ->
{:ok, result}
DateTime.compare(expiry, DateTime.utc_now()) == :gt ->
:timer.sleep(interval_ms)
retry_until(expiry, fun, interval_ms)
true ->
{:error, :timeout}
end
end
# # #
defp duration({_time, _unit} = duration), do: duration
defp duration(time) when is_integer(time), do: {time, :millisecond}
end