Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More flexible result matching #61

Merged
merged 11 commits into from
Sep 22, 2024
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,23 @@ Check out the [API reference](https://hexdocs.pm/retry/api-reference.html) for t

The `retry([with: _,] do: _, after: _, else: _)` macro provides a way to retry a block of code on failure with a variety of delay and give up behaviors. By default, the execution of a block is considered a failure if it returns `:error`, `{:error, _}` or raises a runtime error.

An optional list of atoms can be specified in `:atoms` if you need to retry anything other than `:error` or `{:error, _}`, e.g. `retry([with: _, atoms: [:not_ok]], do: _, after: _, else: _)`.
Both the values and exceptions that will be retried can be customized. To control which values will be retried, provide the `atoms` option. To control which exceptions are retried, provide the `rescue_only` option. For example:

Similarly, an optional list of exceptions can be specified in `:rescue_only` if you need to retry anything other than `RuntimeError`, e.g. `retry([with: _, rescue_only: [CustomError]], do: _, after: _, else: _)`.
```
retry with: ..., atoms: [:not_ok], rescue_only: [CustomError] do
...
end
```

Both `atoms` and `rescue_only` can accept a number of different types:

* An atom (for example: `:not_okay`, `SomeStruct`, or `CustomError`). In this case, the `do` block will be retried in any of the following cases:
* The atom itself is returned
* The atom is returned in the first position of a two-tuple (for example, `{:not_okay, _}`)
* A struct of that type is returned/raised
* The special atom `:all`. In this case, all values/exceptions will be retried.
* A function (for example: `fn val -> val.starts_with("ok") end`) or partial function (for example: `fn {:error, %SomeStruct{reason: "busy"}} -> true`). The function will be called with the return value and the `do` block will be retried if the function returns a truthy value. If the function returns a falsy value or if no function clause matches, the `do` block will not be retried.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: val.starts_with("ok") looks more like snake-cased Scala than Elixir 😛. Do you mean String.starts_with?(val, "ok")?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol. Yeah my scala background is showing. I'll fix it.

* A list of any of the above. The `do` block will be retried if any of the items in the list matches.

The `after` block evaluates only when the `do` block returns a valid value before timeout. On the other hand, the `else` block evaluates only when the `do` block remains erroneous after timeout. Both are optional. By default, the `else` clause will return the last erroneous value or re-raise the last exception. The default `after` clause will simply return the last successful value.

Expand Down
91 changes: 63 additions & 28 deletions lib/retry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,31 @@ defmodule Retry do
Retry a block of code delaying between each attempt the duration specified by
the next item in the `with` delay stream.

If the block returns any of the atoms specified in `atoms`, a retry will be attempted.
Other atoms or atom-result tuples will not be retried. If `atoms` is not specified,
it defaults to `[:error]`.
Both the values and exceptions that will be retried can be customized. To control which values
will be retried, provide the `atoms` option. To control which exceptions are retried, provide
the `rescue_only` option. For example:

Similary, if the block raises any of the exceptions specified in `rescue_only`, a retry
will be attempted. Other exceptions will not be retried. If `rescue_only` is
not specified, it defaults to `[RuntimeError]`.
```
retry with: ..., atoms: [:not_ok], rescue_only: [CustomError] do
...
end
```

Both `atoms` and `rescue_only` can accept a number of different types:

* An atom (for example: `:not_okay`, `SomeStruct`, or `CustomError`). In this case, the `do`
block will be retried in any of the following cases:
* The atom itself is returned
* The atom is returned in the first position of a two-tuple (for example, `{:not_okay, _}`)
* A struct of that type is returned/raised
* The special atom `:all`. In this case, all values/exceptions will be retried.
* A function (for example: `fn val -> val.starts_with("ok") end`) or partial function (for
example: `fn {:error, %SomeStruct{reason: "busy"}} -> true`). The function will be called with
the return value and the `do` block will be retried if the function returns a truthy value.
If the function returns a falsy value or if no function clause matches, the `do` block
will not be retried.
* A list of any of the above. The `do` block will be retried if any of the items in the list
matches.

The `after` block evaluates only when the `do` block returns a valid value before timeout.

Expand Down Expand Up @@ -151,7 +169,6 @@ defmodule Retry do
opts = parse_opts(opts, @retry_meta)
[do_clause, after_clause, else_clause] = parse_clauses(clauses, @retry_meta)
stream_builder = Keyword.fetch!(opts, :with)
atoms = Keyword.fetch!(opts, :atoms)

quote generated: true do
fun = unquote(block_runner(do_clause, opts))
Expand All @@ -167,12 +184,7 @@ defmodule Retry do
unquote(else_clause)
end

e = {atom, _} when atom in unquote(atoms) ->
case e do
unquote(else_clause)
end

e when is_atom(e) and e in unquote(atoms) ->
{:retriable, e} ->
case e do
unquote(else_clause)
end
Expand Down Expand Up @@ -313,29 +325,52 @@ defmodule Retry do

defp block_runner(block, opts) do
atoms = Keyword.get(opts, :atoms)
exceptions = Keyword.get(opts, :rescue_only)
rescue_onlies = Keyword.get(opts, :rescue_only)

quote generated: true do
call_partial = fn f, x ->
try do
!!f.(x)
rescue
FunctionClauseError -> false
end
end

should_retry = fn
_x, :all -> true
x, a when is_atom(x) and is_atom(a) -> x == a
x, a when is_struct(x) and is_atom(a) -> is_struct(x, a)
{x, _}, a when is_atom(x) and is_atom(a) -> x == a
x, f when is_function(f) -> call_partial.(f, x)
_, _ -> false
end

fn ->
try do
case unquote(block) do
{atom, _} = result ->
if atom in unquote(atoms) do
{:cont, result}
else
{:halt, result}
end
result = unquote(block)

result ->
if is_atom(result) and result in unquote(atoms) do
{:cont, result}
else
{:halt, result}
end
retry? =
if is_list(unquote(atoms)) do
Enum.any?(unquote(atoms), &should_retry.(result, &1))
else
should_retry.(result, unquote(atoms))
end

if retry? do
{:cont, {:retriable, result}}
else
{:halt, result}
end
rescue
e ->
if e.__struct__ in unquote(exceptions) do
retry? =
if is_list(unquote(rescue_onlies)) do
Enum.any?(unquote(rescue_onlies), &should_retry.(e, &1))
else
should_retry.(e, unquote(rescue_onlies))
end

if retry? do
{:cont, {:exception, e}}
else
reraise e, __STACKTRACE__
Expand Down
148 changes: 98 additions & 50 deletions test/retry_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ defmodule RetryTest do
use Retry

import Stream
import ExUnit.CaptureLog
require Logger

doctest Retry

defmodule(CustomError, do: defexception(message: "custom error!"))
defmodule(NotOkay, do: defstruct([]))

describe "retry" do
test "retries execution for specified attempts when result is error tuple" do
Expand Down Expand Up @@ -45,44 +48,59 @@ defmodule RetryTest do
assert elapsed / 1_000 >= 250
end

test "retries execution for specified attempts when result is a specified atom" do
retry_atom = :not_ok

{elapsed, _} =
:timer.tc(fn ->
result =
retry with: linear_backoff(50, 1) |> take(5), atoms: [retry_atom] do
retry_atom
after
_ -> :ok
else
error -> error
end

assert result == retry_atom
end)
test "retries execution for specified attempts when allowed result is returned" do
testcases = [
{:not_ok, :all},
{:not_ok, [:foo, :all]},
{:not_ok, :not_ok},
{:not_ok, [:foo, :not_ok]},
{{:not_ok, :foo}, [:foo, :not_ok]},
{%NotOkay{}, NotOkay},
{%NotOkay{}, [Foo, NotOkay]},
{:not_ok, fn _ -> true end},
{:not_ok, [fn _ -> false end, fn _ -> true end]},
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I think this is a great feature, I feel like passing a function or a list of functions to atoms is semantically weird 🤔. The original idea behind atoms is to allowlist things like :error, {:error, foo}, :kaput, etc. Although come to think of it, a more inclusive name for it would be patterns. I think we can add patterns and deprecate atoms if necessary.

Thinking out loud, what if we create a new option called when/when_truthy that accepts one or more functions instead? I personally think it reads better as well, i.e. "retry when a function that accepts the return value evaluates to true".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that atoms is no longer the appropriate name, but we obviously don't want to remove atoms for backward compatibility reasons so I was hoping we could merge this PR and then maybe in a follow-up add an alias for it and deprecate it.

As far as choosing a new name, I think when or patterns are both fine. Personally I wouldn't add a third parameter for functions separate from one for atoms (not sure if that's what you were suggesting). If anything, I would go the opposite direction and combing the rescue_only functionality in with when (or whatever we call it) so you just have a single parameter for deciding when to retry.

{:not_ok, [fn _ -> nil end, fn _ -> 1 end]},
{:not_ok, [fn :partial -> false end, fn _ -> true end]},
{:not_ok,
fn
:partial -> false
:not_ok -> true
end}
]

assert elapsed / 1_000 >= 250
for {rval, atoms} <- testcases do
{elapsed, _} =
:timer.tc(fn ->
result =
retry with: linear_backoff(50, 1) |> take(5), atoms: atoms do
rval
after
_ -> :ok
else
error -> error
end

assert result == rval
end)

assert elapsed / 1_000 >= 250
end
end

test "retries execution for specified attempts when result is a tuple with a specified atom" do
retry_atom = :not_ok

{elapsed, _} =
:timer.tc(fn ->
result =
retry with: linear_backoff(50, 1) |> take(5), atoms: [retry_atom] do
{retry_atom, "Some error message"}
after
_ -> :ok
else
error -> error
end

assert result == {retry_atom, "Some error message"}
end)
test "does not retry on :error if atoms is specified" do
f = fn ->
retry with: linear_backoff(50, 1) |> take(5), atoms: :not_ok do
Logger.info("running")
:error
after
result -> result
else
error -> :not_this
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind fixing the warning(s) by renaming this to _error?

end
end

assert elapsed / 1_000 >= 250
assert f.() == :error
assert Regex.scan(~r/running/, capture_log(f)) |> length == 1
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to capture the log messages here and elsewhere? I personally find it redundant and a bit noisy.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was my way of counting the number of executions. Basically just using logs as a side effect that I can count with capture_log. I prefer it over the duration measuring, but I'll use that method if you prefer.

end

test "retries execution for specified attempts when error is raised" do
Expand All @@ -102,23 +120,33 @@ defmodule RetryTest do
assert elapsed / 1_000 >= 250
end

test "retries execution when a whitelisted exception is raised" do
custom_error_list = [CustomError]
test "retries execution when an allowed exception is raised" do
testcases = [
CustomError,
[OtherThing, CustomError],
:all,
[:other_thing, :all],
fn _ -> true end,
[fn _ -> false end, fn _ -> true end],
[fn :partial -> false end, fn _ -> true end]
]

{elapsed, _} =
:timer.tc(fn ->
assert_raise CustomError, fn ->
retry with: linear_backoff(50, 1) |> take(5), rescue_only: custom_error_list do
raise CustomError
after
_ -> :ok
else
error -> raise error
for testcase <- testcases do
{elapsed, _} =
:timer.tc(fn ->
assert_raise CustomError, fn ->
retry with: linear_backoff(50, 1) |> take(5), rescue_only: testcase do
raise CustomError
after
_ -> :ok
else
error -> raise error
end
end
end
end)
end)

assert elapsed / 1_000 >= 250
assert elapsed / 1_000 >= 250
end
end

test "does not retry execution when an unknown exception is raised" do
Expand All @@ -138,17 +166,37 @@ defmodule RetryTest do
assert elapsed / 1_000 < 250
end

test "does not retry on RuntimeError if some other rescue_only is specified" do
f = fn ->
assert_raise RuntimeError, fn ->
retry with: linear_backoff(50, 1) |> take(5), rescue_only: CustomError do
Logger.info("running")
raise RuntimeError
after
_ -> :ok
else
error -> raise error
end
end
end

assert Regex.scan(~r/running/, capture_log(f)) |> length == 1
end

test "does not have to retry execution when there is no error" do
result =
f = fn ->
retry with: linear_backoff(50, 1) |> take(5) do
Logger.info("running")
{:ok, "Everything's so awesome!"}
after
result -> result
else
_ -> :error
end
end

assert result == {:ok, "Everything's so awesome!"}
assert f.() == {:ok, "Everything's so awesome!"}
assert Regex.scan(~r/running/, capture_log(f)) |> length == 1
end

test "uses the default 'after' action" do
Expand Down