Page MenuHomePhorge

No OneTemporary

Size
9 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/tesla/middleware/retry.ex b/lib/tesla/middleware/retry.ex
index a46601d..0038ca7 100644
--- a/lib/tesla/middleware/retry.ex
+++ b/lib/tesla/middleware/retry.ex
@@ -1,114 +1,115 @@
defmodule Tesla.Middleware.Retry do
@moduledoc """
Retry using exponential backoff and full jitter. This middleware only retries in the
case of connection errors (`nxdomain`, `connrefused` etc). Application error
checking for retry can be customized through `:should_retry` option by
providing a function in returning a boolean.
## Backoff algorithm
The backoff algorithm optimizes for tight bounds on completing a request successfully.
It does this by first calculating an exponential backoff factor based on the
number of retries that have been performed. It then multiplies this factor against the
base delay. The total maximum delay is found by taking the minimum of either the calculated delay
or the maximum delay specified. This creates an upper bound on the maximum delay
we can see.
In order to find the actual delay value we take a random number between 0 and
the maximum delay based on a uniform distribution. This randomness ensures that
our retried requests don't "harmonize" making it harder for the downstream
service to heal.
## Example
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Retry,
delay: 500,
max_retries: 10,
max_delay: 4_000,
should_retry: fn
{:ok, %{status: status}} when status in [400, 500] -> true
{:ok, _} -> false
{:error, _} -> true
end
end
```
## Options
- `:delay` - The base delay in milliseconds (positive integer, defaults to 50)
- `:max_retries` - maximum number of retries (non-negative integer, defaults to 5)
- `:max_delay` - maximum delay in milliseconds (positive integer, defaults to 5000)
- `:should_retry` - function to determine if request should be retried
"""
# Not necessary in Elixir 1.10+
use Bitwise, skip_operators: true
@behaviour Tesla.Middleware
@defaults [
delay: 50,
max_retries: 5,
max_delay: 5_000
]
@impl Tesla.Middleware
def call(env, next, opts) do
opts = opts || []
- max_delay =
- opts
- |> Keyword.get(:max_delay, @defaults[:delay])
- |> max(1)
-
- delay =
- opts
- |> Keyword.get(:max_delay, @defaults[:delay])
- |> max(1)
- |> min(max_delay)
-
context = %{
retries: 0,
- delay: delay,
- max_retries: Keyword.get(opts, :max_retries, @defaults[:max_retries]),
- max_delay: max_delay,
+ delay: integer_opt!(opts, :delay, 1),
+ max_retries: integer_opt!(opts, :max_retries, 0),
+ max_delay: integer_opt!(opts, :max_delay, 1),
should_retry: Keyword.get(opts, :should_retry, &match?({:error, _}, &1))
}
retry(env, next, context)
end
# If we have max retries set to 0 don't retry
defp retry(env, next, %{max_retries: 0}), do: Tesla.run(env, next)
# If we're on our last retry then just run and don't handle the error
defp retry(env, next, %{max_retries: max, retries: max}) do
Tesla.run(env, next)
end
# Otherwise we retry if we get a retriable error
defp retry(env, next, context) do
res = Tesla.run(env, next)
if context.should_retry.(res) do
backoff(context.max_delay, context.delay, context.retries)
context = update_in(context, [:retries], &(&1 + 1))
retry(env, next, context)
else
res
end
end
# Exponential backoff with jitter
defp backoff(cap, base, attempt) do
factor = Bitwise.bsl(1, attempt)
max_sleep = min(cap, base * factor)
delay = :rand.uniform(max_sleep)
:timer.sleep(delay)
end
+
+ defp integer_opt!(opts, key, min) do
+ case Keyword.fetch(opts, key) do
+ {:ok, value} when is_integer(value) and value >= min -> value
+ {:ok, invalid} -> invalid_integer(key, invalid, min)
+ :error -> @defaults[key]
+ end
+ end
+
+ defp invalid_integer(key, value, min) do
+ raise(ArgumentError, "expected :#{key} to be an integer >= #{min}, got #{inspect(value)}")
+ end
end
diff --git a/test/tesla/middleware/retry_test.exs b/test/tesla/middleware/retry_test.exs
index bebce10..ae32273 100644
--- a/test/tesla/middleware/retry_test.exs
+++ b/test/tesla/middleware/retry_test.exs
@@ -1,135 +1,147 @@
defmodule Tesla.Middleware.RetryTest do
use ExUnit.Case, async: false
defmodule LaggyAdapter do
def start_link, do: Agent.start_link(fn -> 0 end, name: __MODULE__)
def call(env, _opts) do
Agent.get_and_update(__MODULE__, fn retries ->
response =
case env.url do
"/ok" -> {:ok, env}
"/maybe" when retries < 5 -> {:error, :econnrefused}
"/maybe" -> {:ok, env}
"/nope" -> {:error, :econnrefused}
"/retry_status" when retries < 5 -> {:ok, %{env | status: 500}}
"/retry_status" -> {:ok, %{env | status: 200}}
end
{response, retries + 1}
end)
end
end
defmodule Client do
use Tesla
plug Tesla.Middleware.Retry,
delay: 10,
max_retries: 10
adapter LaggyAdapter
end
defmodule ClientWithShouldRetryFunction do
use Tesla
plug Tesla.Middleware.Retry,
delay: 10,
max_retries: 10,
should_retry: fn
{:ok, %{status: status}} when status in [400, 500] -> true
{:ok, _} -> false
{:error, _} -> true
end
adapter LaggyAdapter
end
setup do
{:ok, _} = LaggyAdapter.start_link()
:ok
end
test "pass on successful request" do
assert {:ok, %Tesla.Env{url: "/ok", method: :get}} = Client.get("/ok")
end
test "finally pass on laggy request" do
assert {:ok, %Tesla.Env{url: "/maybe", method: :get}} = Client.get("/maybe")
end
test "raise if max_retries is exceeded" do
assert {:error, :econnrefused} = Client.get("/nope")
end
test "use default retry determination function" do
assert {:ok, %Tesla.Env{url: "/retry_status", method: :get, status: 500}} =
Client.get("/retry_status")
end
test "use custom retry determination function" do
assert {:ok, %Tesla.Env{url: "/retry_status", method: :get, status: 200}} =
ClientWithShouldRetryFunction.get("/retry_status")
end
- test "pass when max_delay option is zero" do
- defmodule ClientWithZeroMaxDelay do
- use Tesla
+ defmodule DefunctClient do
+ use Tesla
- plug Tesla.Middleware.Retry, max_delay: 0
+ plug Tesla.Middleware.Retry
- adapter LaggyAdapter
- end
+ adapter fn _ -> raise "runtime-error" end
+ end
- assert {:ok, %Tesla.Env{}} = ClientWithZeroMaxDelay.get("/maybe")
+ test "raise in case or unexpected error" do
+ assert_raise RuntimeError, fn -> DefunctClient.get("/blow") end
end
- test "pass when max_delay option is negative" do
- defmodule ClientWithNegativeMaxDelay do
+ test "ensures delay option is positive" do
+ defmodule ClientWithZeroDelay do
use Tesla
-
- plug Tesla.Middleware.Retry, max_delay: -1
-
+ plug Tesla.Middleware.Retry, delay: 0
adapter LaggyAdapter
end
- assert {:ok, %Tesla.Env{}} = ClientWithNegativeMaxDelay.get("/maybe")
+ assert_raise ArgumentError, "expected :delay to be an integer >= 1, got 0", fn ->
+ ClientWithZeroDelay.get("/ok")
+ end
end
- test "pass when delay option is zero" do
- defmodule ClientWithZeroDelay do
+ test "ensures delay option is an integer" do
+ defmodule ClientWithFloatDelay do
use Tesla
-
- plug Tesla.Middleware.Retry, delay: 0
-
+ plug Tesla.Middleware.Retry, delay: 0.25
adapter LaggyAdapter
end
- assert {:ok, %Tesla.Env{}} = ClientWithZeroDelay.get("/maybe")
+ assert_raise ArgumentError, "expected :delay to be an integer >= 1, got 0.25", fn ->
+ ClientWithFloatDelay.get("/ok")
+ end
end
- test "pass when delay option is negative" do
- defmodule ClientWithNegativeDelay do
+ test "ensures max_delay option is positive" do
+ defmodule ClientWithNegativeMaxDelay do
use Tesla
-
- plug Tesla.Middleware.Retry, delay: -1
-
+ plug Tesla.Middleware.Retry, max_delay: -1
adapter LaggyAdapter
end
- assert {:ok, %Tesla.Env{}} = ClientWithNegativeDelay.get("/maybe")
+ assert_raise ArgumentError, "expected :max_delay to be an integer >= 1, got -1", fn ->
+ ClientWithNegativeMaxDelay.get("/ok")
+ end
end
- defmodule DefunctClient do
- use Tesla
-
- plug Tesla.Middleware.Retry
+ test "ensures max_delay option is an integer" do
+ defmodule ClientWithStringMaxDelay do
+ use Tesla
+ plug Tesla.Middleware.Retry, max_delay: "500"
+ adapter LaggyAdapter
+ end
- adapter fn _ -> raise "runtime-error" end
+ assert_raise ArgumentError, "expected :max_delay to be an integer >= 1, got \"500\"", fn ->
+ ClientWithStringMaxDelay.get("/ok")
+ end
end
- test "raise in case or unexpected error" do
- assert_raise RuntimeError, fn -> DefunctClient.get("/blow") end
+ test "ensures max_retries option is not negative" do
+ defmodule ClientWithNegativeMaxRetries do
+ use Tesla
+ plug Tesla.Middleware.Retry, max_retries: -1
+ adapter LaggyAdapter
+ end
+
+ assert_raise ArgumentError, "expected :max_retries to be an integer >= 0, got -1", fn ->
+ ClientWithNegativeMaxRetries.get("/ok")
+ end
end
end

File Metadata

Mime Type
text/x-diff
Expires
Tue, Nov 26, 4:01 PM (1 d, 11 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40411
Default Alt Text
(9 KB)

Event Timeline