Skip to content

Instantly share code, notes, and snippets.

@trbngr
Last active December 20, 2018 22:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save trbngr/051684fc2b302becdf46b3e68b3b113e to your computer and use it in GitHub Desktop.
Save trbngr/051684fc2b302becdf46b3e68b3b113e to your computer and use it in GitHub Desktop.
Elixir Retryable
defmodule Retryable do
require Logger
@enforce_keys [:mfa]
defstruct mfa: nil,
retries: 1,
should_retry: nil,
before_execution: nil
def retry(%Retryable{mfa: {m, f, a}, retries: retries} = spec) when retries > 0 do
spec = %{spec | mfa: {m, f, List.wrap(a)}}
result = execute(spec, {0, retries})
run(spec, {1, result}, retries)
end
def retry(%Retryable{mfa: {m, f}, retries: retries} = spec) when retries > 0 do
retry(%{spec | mfa: {m, f, []}})
end
def retry!(%Retryable{mfa: {_, _, _}, retries: retries} = spec) when retries > 0 do
{_, result} = retry(spec)
result
end
def retry!(%Retryable{mfa: {m, f}, retries: retries} = spec) when retries > 0 do
{_, result} = retry(%{spec | mfa: {m, f, []}})
result
end
defp run(_, result, 0), do: result
defp run(spec, {run_count, result}, remaining_attempts) do
should_retry = get_retry_predicate(spec)
if should_retry.(result) do
next_result = execute(spec, {run_count, remaining_attempts - 1})
run(spec, {run_count + 1, next_result}, remaining_attempts - 1)
else
{run_count, result}
end
end
defp execute(%{mfa: {m, f, a}} = spec, {run_count, remaining_attempts}) do
before_execution = get_before_execution(spec)
before_execution.(%{attempt: run_count + 1, remaining_attempts: remaining_attempts})
try do
apply(m, f, a)
rescue
e -> {:error, e}
end
end
defp get_retry_predicate(%{should_retry: nil}), do: &default_should_retry/1
defp get_retry_predicate(%{should_retry: should_retry}), do: should_retry
defp default_should_retry({:error, _meta}), do: true
defp default_should_retry(_), do: false
defp get_before_execution(%{before_execution: nil}), do: fn _ -> nil end
defp get_before_execution(%{before_execution: before_execution}), do: before_execution
end
defmodule RetryableTest do
require Logger
use ExUnit.Case
import ExUnit.CaptureLog
alias RetryableTest.TestError
defmodule Tests do
def run, do: {:error, :nope}
def run(n), do: {:error, n}
def rand(range), do: {:error, range |> Enum.random()}
def err(msg), do: raise(TestError, message: msg)
end
defmodule TestError do
defexception [:message]
end
import Retryable
describe "raises error:" do
test "when mfa is not a 2 or 3 element tuple" do
["", 1, 1.9, {}, {1, 2, 3, 4}, RetryableTest.Tests, {RetryableTest.Tests}]
|> Enum.each(fn mfa ->
assert_raise FunctionClauseError, fn ->
%Retryable{mfa: mfa} |> retry()
end
end)
end
test "when mfa doesn't exist" do
[{RetryableTest, :noop}, {NoModule, :run}]
|> Enum.each(fn mfa ->
assert_raise UndefinedFunctionError, fn ->
%Retryable{mfa: mfa} |> retry()
end
end)
end
end
test "defaults to 1 retry" do
spec = %Retryable{mfa: {RetryableTest.Tests, :run}}
{execution_count, result} = retry(spec)
assert execution_count == 2
assert result == {:error, :nope}
result = retry!(spec)
assert result == {:error, :nope}
end
test "can pass args" do
spec = %Retryable{mfa: {RetryableTest.Tests, :run, 42}}
{execution_count, result} = retry(spec)
assert execution_count == 2
assert result == {:error, 42}
result = retry!(spec)
assert result == {:error, 42}
end
test "will retry exceptions" do
spec = %Retryable{mfa: {RetryableTest.Tests, :err, "fuck!"}}
{execution_count, result} = retry(spec)
assert execution_count == 2
assert {:error, %TestError{message: "fuck!"}} == result
result = retry!(spec)
assert {:error, %TestError{message: "fuck!"}} == result
end
describe "configuration:" do
test "before_execution recieves execution metadata" do
spec = %Retryable{
mfa: {RetryableTest.Tests, :run, 42},
before_execution: fn %{attempt: attempt, remaining_attempts: remaining} ->
Logger.info("Test attempt #{attempt} (#{remaining} remaining)")
end
}
logger_opts = [colors: [enabled: false], format: "$message|"]
result = capture_log(logger_opts, fn -> retry(spec) end)
assert result == "Test attempt 1 (1 remaining)|Test attempt 2 (0 remaining)|"
end
test "can stop retrying with function result" do
spec = %Retryable{
mfa: {RetryableTest.Tests, :rand, 1..30},
# retry enough times to hit the should_retry condition that will stop the retries
retries: 100,
should_retry: fn
{:error, 15} -> false
_ -> true
end
}
{:error, n} = retry!(spec)
assert n == 15
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment