Last active
December 20, 2018 22:18
-
-
Save trbngr/051684fc2b302becdf46b3e68b3b113e to your computer and use it in GitHub Desktop.
Elixir Retryable
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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