Skip to content

Commit 9eeae7d

Browse files
More flexible result matching (#61)
* Make after & else clauses optional for retry macro * Run formatter * Support flexible rescue_only parameters * Support flexible atoms parameters * Fix extra runs * Add a few test cases * Update documentation * Fix unused variable warning * Fix example code in docs
1 parent 5de2698 commit 9eeae7d

File tree

3 files changed

+177
-80
lines changed

3 files changed

+177
-80
lines changed

README.md

+16-2
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,23 @@ Check out the [API reference](https://hexdocs.pm/retry/api-reference.html) for t
3232

3333
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.
3434

35-
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: _)`.
35+
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:
3636

37-
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: _)`.
37+
```
38+
retry with: ..., atoms: [:not_ok], rescue_only: [CustomError] do
39+
...
40+
end
41+
```
42+
43+
Both `atoms` and `rescue_only` can accept a number of different types:
44+
45+
* An atom (for example: `:not_okay`, `SomeStruct`, or `CustomError`). In this case, the `do` block will be retried in any of the following cases:
46+
* The atom itself is returned
47+
* The atom is returned in the first position of a two-tuple (for example, `{:not_okay, _}`)
48+
* A struct of that type is returned/raised
49+
* The special atom `:all`. In this case, all values/exceptions will be retried.
50+
* A function (for example: `fn val -> String.starts_with?(val, "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.
51+
* A list of any of the above. The `do` block will be retried if any of the items in the list matches.
3852

3953
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.
4054

lib/retry.ex

+63-28
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,31 @@ defmodule Retry do
103103
Retry a block of code delaying between each attempt the duration specified by
104104
the next item in the `with` delay stream.
105105
106-
If the block returns any of the atoms specified in `atoms`, a retry will be attempted.
107-
Other atoms or atom-result tuples will not be retried. If `atoms` is not specified,
108-
it defaults to `[:error]`.
106+
Both the values and exceptions that will be retried can be customized. To control which values
107+
will be retried, provide the `atoms` option. To control which exceptions are retried, provide
108+
the `rescue_only` option. For example:
109109
110-
Similary, if the block raises any of the exceptions specified in `rescue_only`, a retry
111-
will be attempted. Other exceptions will not be retried. If `rescue_only` is
112-
not specified, it defaults to `[RuntimeError]`.
110+
```
111+
retry with: ..., atoms: [:not_ok], rescue_only: [CustomError] do
112+
...
113+
end
114+
```
115+
116+
Both `atoms` and `rescue_only` can accept a number of different types:
117+
118+
* An atom (for example: `:not_okay`, `SomeStruct`, or `CustomError`). In this case, the `do`
119+
block will be retried in any of the following cases:
120+
* The atom itself is returned
121+
* The atom is returned in the first position of a two-tuple (for example, `{:not_okay, _}`)
122+
* A struct of that type is returned/raised
123+
* The special atom `:all`. In this case, all values/exceptions will be retried.
124+
* A function (for example: `fn val -> String.starts_with?(val, "ok") end`) or partial function
125+
(for example: `fn {:error, %SomeStruct{reason: "busy"}} -> true`). The function will be called
126+
with the return value and the `do` block will be retried if the function returns a truthy value.
127+
If the function returns a falsy value or if no function clause matches, the `do` block
128+
will not be retried.
129+
* A list of any of the above. The `do` block will be retried if any of the items in the list
130+
matches.
113131
114132
The `after` block evaluates only when the `do` block returns a valid value before timeout.
115133
@@ -151,7 +169,6 @@ defmodule Retry do
151169
opts = parse_opts(opts, @retry_meta)
152170
[do_clause, after_clause, else_clause] = parse_clauses(clauses, @retry_meta)
153171
stream_builder = Keyword.fetch!(opts, :with)
154-
atoms = Keyword.fetch!(opts, :atoms)
155172

156173
quote generated: true do
157174
fun = unquote(block_runner(do_clause, opts))
@@ -167,12 +184,7 @@ defmodule Retry do
167184
unquote(else_clause)
168185
end
169186

170-
e = {atom, _} when atom in unquote(atoms) ->
171-
case e do
172-
unquote(else_clause)
173-
end
174-
175-
e when is_atom(e) and e in unquote(atoms) ->
187+
{:retriable, e} ->
176188
case e do
177189
unquote(else_clause)
178190
end
@@ -313,29 +325,52 @@ defmodule Retry do
313325

314326
defp block_runner(block, opts) do
315327
atoms = Keyword.get(opts, :atoms)
316-
exceptions = Keyword.get(opts, :rescue_only)
328+
rescue_onlies = Keyword.get(opts, :rescue_only)
317329

318330
quote generated: true do
331+
call_partial = fn f, x ->
332+
try do
333+
!!f.(x)
334+
rescue
335+
FunctionClauseError -> false
336+
end
337+
end
338+
339+
should_retry = fn
340+
_x, :all -> true
341+
x, a when is_atom(x) and is_atom(a) -> x == a
342+
x, a when is_struct(x) and is_atom(a) -> is_struct(x, a)
343+
{x, _}, a when is_atom(x) and is_atom(a) -> x == a
344+
x, f when is_function(f) -> call_partial.(f, x)
345+
_, _ -> false
346+
end
347+
319348
fn ->
320349
try do
321-
case unquote(block) do
322-
{atom, _} = result ->
323-
if atom in unquote(atoms) do
324-
{:cont, result}
325-
else
326-
{:halt, result}
327-
end
350+
result = unquote(block)
328351

329-
result ->
330-
if is_atom(result) and result in unquote(atoms) do
331-
{:cont, result}
332-
else
333-
{:halt, result}
334-
end
352+
retry? =
353+
if is_list(unquote(atoms)) do
354+
Enum.any?(unquote(atoms), &should_retry.(result, &1))
355+
else
356+
should_retry.(result, unquote(atoms))
357+
end
358+
359+
if retry? do
360+
{:cont, {:retriable, result}}
361+
else
362+
{:halt, result}
335363
end
336364
rescue
337365
e ->
338-
if e.__struct__ in unquote(exceptions) do
366+
retry? =
367+
if is_list(unquote(rescue_onlies)) do
368+
Enum.any?(unquote(rescue_onlies), &should_retry.(e, &1))
369+
else
370+
should_retry.(e, unquote(rescue_onlies))
371+
end
372+
373+
if retry? do
339374
{:cont, {:exception, e}}
340375
else
341376
reraise e, __STACKTRACE__

test/retry_test.exs

+98-50
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ defmodule RetryTest do
33
use Retry
44

55
import Stream
6+
import ExUnit.CaptureLog
7+
require Logger
68

79
doctest Retry
810

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

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

48-
test "retries execution for specified attempts when result is a specified atom" do
49-
retry_atom = :not_ok
50-
51-
{elapsed, _} =
52-
:timer.tc(fn ->
53-
result =
54-
retry with: linear_backoff(50, 1) |> take(5), atoms: [retry_atom] do
55-
retry_atom
56-
after
57-
_ -> :ok
58-
else
59-
error -> error
60-
end
61-
62-
assert result == retry_atom
63-
end)
51+
test "retries execution for specified attempts when allowed result is returned" do
52+
testcases = [
53+
{:not_ok, :all},
54+
{:not_ok, [:foo, :all]},
55+
{:not_ok, :not_ok},
56+
{:not_ok, [:foo, :not_ok]},
57+
{{:not_ok, :foo}, [:foo, :not_ok]},
58+
{%NotOkay{}, NotOkay},
59+
{%NotOkay{}, [Foo, NotOkay]},
60+
{:not_ok, fn _ -> true end},
61+
{:not_ok, [fn _ -> false end, fn _ -> true end]},
62+
{:not_ok, [fn _ -> nil end, fn _ -> 1 end]},
63+
{:not_ok, [fn :partial -> false end, fn _ -> true end]},
64+
{:not_ok,
65+
fn
66+
:partial -> false
67+
:not_ok -> true
68+
end}
69+
]
6470

65-
assert elapsed / 1_000 >= 250
71+
for {rval, atoms} <- testcases do
72+
{elapsed, _} =
73+
:timer.tc(fn ->
74+
result =
75+
retry with: linear_backoff(50, 1) |> take(5), atoms: atoms do
76+
rval
77+
after
78+
_ -> :ok
79+
else
80+
error -> error
81+
end
82+
83+
assert result == rval
84+
end)
85+
86+
assert elapsed / 1_000 >= 250
87+
end
6688
end
6789

68-
test "retries execution for specified attempts when result is a tuple with a specified atom" do
69-
retry_atom = :not_ok
70-
71-
{elapsed, _} =
72-
:timer.tc(fn ->
73-
result =
74-
retry with: linear_backoff(50, 1) |> take(5), atoms: [retry_atom] do
75-
{retry_atom, "Some error message"}
76-
after
77-
_ -> :ok
78-
else
79-
error -> error
80-
end
81-
82-
assert result == {retry_atom, "Some error message"}
83-
end)
90+
test "does not retry on :error if atoms is specified" do
91+
f = fn ->
92+
retry with: linear_backoff(50, 1) |> take(5), atoms: :not_ok do
93+
Logger.info("running")
94+
:error
95+
after
96+
result -> result
97+
else
98+
_error -> :not_this
99+
end
100+
end
84101

85-
assert elapsed / 1_000 >= 250
102+
assert f.() == :error
103+
assert Regex.scan(~r/running/, capture_log(f)) |> length == 1
86104
end
87105

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

105-
test "retries execution when a whitelisted exception is raised" do
106-
custom_error_list = [CustomError]
123+
test "retries execution when an allowed exception is raised" do
124+
testcases = [
125+
CustomError,
126+
[OtherThing, CustomError],
127+
:all,
128+
[:other_thing, :all],
129+
fn _ -> true end,
130+
[fn _ -> false end, fn _ -> true end],
131+
[fn :partial -> false end, fn _ -> true end]
132+
]
107133

108-
{elapsed, _} =
109-
:timer.tc(fn ->
110-
assert_raise CustomError, fn ->
111-
retry with: linear_backoff(50, 1) |> take(5), rescue_only: custom_error_list do
112-
raise CustomError
113-
after
114-
_ -> :ok
115-
else
116-
error -> raise error
134+
for testcase <- testcases do
135+
{elapsed, _} =
136+
:timer.tc(fn ->
137+
assert_raise CustomError, fn ->
138+
retry with: linear_backoff(50, 1) |> take(5), rescue_only: testcase do
139+
raise CustomError
140+
after
141+
_ -> :ok
142+
else
143+
error -> raise error
144+
end
117145
end
118-
end
119-
end)
146+
end)
120147

121-
assert elapsed / 1_000 >= 250
148+
assert elapsed / 1_000 >= 250
149+
end
122150
end
123151

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

169+
test "does not retry on RuntimeError if some other rescue_only is specified" do
170+
f = fn ->
171+
assert_raise RuntimeError, fn ->
172+
retry with: linear_backoff(50, 1) |> take(5), rescue_only: CustomError do
173+
Logger.info("running")
174+
raise RuntimeError
175+
after
176+
_ -> :ok
177+
else
178+
error -> raise error
179+
end
180+
end
181+
end
182+
183+
assert Regex.scan(~r/running/, capture_log(f)) |> length == 1
184+
end
185+
141186
test "does not have to retry execution when there is no error" do
142-
result =
187+
f = fn ->
143188
retry with: linear_backoff(50, 1) |> take(5) do
189+
Logger.info("running")
144190
{:ok, "Everything's so awesome!"}
145191
after
146192
result -> result
147193
else
148194
_ -> :error
149195
end
196+
end
150197

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

154202
test "uses the default 'after' action" do

0 commit comments

Comments
 (0)