diff --git a/README.md b/README.md index aa83fd62..f51312e9 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ to use it in production environment as it does not validate SSL certificates ## Middleware Tesla is built around the concept of composable middlewares. -This is very similar to how [Plug Router](https://github.com/elixir-plug/plug#the-plug-router) works. +This is very similar to how [Plug Router](https://github.com/elixir-plug/plug#plugrouter) works. ### Basic @@ -389,7 +389,7 @@ defmodule Tesla.Middleware.SomeMiddleware do ### Examples - ``` + ```elixir defmodule MyClient do use Tesla diff --git a/lib/tesla/builder.ex b/lib/tesla/builder.ex index 6fb42897..98d3a711 100644 --- a/lib/tesla/builder.ex +++ b/lib/tesla/builder.ex @@ -84,7 +84,7 @@ defmodule Tesla.Builder do @doc """ Attach middleware to your API client. - ``` + ```elixir defmodule ExampleApi do use Tesla diff --git a/lib/tesla/middleware.ex b/lib/tesla/middleware.ex index 660c26ae..919053a4 100644 --- a/lib/tesla/middleware.ex +++ b/lib/tesla/middleware.ex @@ -14,13 +14,13 @@ defmodule Tesla.Middleware do or inside tuple in case of dynamic middleware (`Tesla.client/1`): Tesla.client([{Tesla.Middleware.BaseUrl, "https://example.com"}]) - + ## Ordering - + The order in which middleware is defined matters. Note that the order when _sending_ the request matches the order the middleware was defined in, but the order when _receiving_ the response is reversed. - + For example, `Tesla.Middleware.DecompressResponse` must come _after_ `Tesla.Middleware.JSON`, otherwise the response isn't decompressed before it reaches the JSON parser. diff --git a/lib/tesla/middleware/json.ex b/lib/tesla/middleware/json.ex index 61095b93..b0e481f8 100644 --- a/lib/tesla/middleware/json.ex +++ b/lib/tesla/middleware/json.ex @@ -164,6 +164,8 @@ defmodule Tesla.Middleware.DecodeJson do """ @moduledoc since: "1.8.0" + @behaviour Tesla.Middleware + @impl Tesla.Middleware def call(env, next, opts) do opts = opts || [] @@ -180,6 +182,8 @@ defmodule Tesla.Middleware.EncodeJson do """ @moduledoc since: "1.8.0" + @behaviour Tesla.Middleware + @impl Tesla.Middleware def call(env, next, opts) do opts = opts || [] diff --git a/lib/tesla/middleware/logger.ex b/lib/tesla/middleware/logger.ex index 8057b523..72f2360a 100644 --- a/lib/tesla/middleware/logger.ex +++ b/lib/tesla/middleware/logger.ex @@ -60,7 +60,7 @@ defmodule Tesla.Middleware.Logger do ## Examples - ``` + ```elixir defmodule MyClient do use Tesla @@ -86,13 +86,13 @@ defmodule Tesla.Middleware.Logger do It can be changed globally with config: - ``` + ```elixir config :tesla, Tesla.Middleware.Logger, format: "$method $url ====> $status / time=$time" ``` Or you can customize this setting by providing your own `format` function: - ``` + ```elixir defmodule MyClient do use Tesla @@ -114,7 +114,7 @@ defmodule Tesla.Middleware.Logger do You can customize this setting by providing your own `log_level/1` function: - ``` + ```elixir defmodule MyClient do use Tesla diff --git a/lib/tesla/middleware/retry.ex b/lib/tesla/middleware/retry.ex index 2b302c67..aaefed13 100644 --- a/lib/tesla/middleware/retry.ex +++ b/lib/tesla/middleware/retry.ex @@ -34,6 +34,14 @@ defmodule Tesla.Middleware.Retry do {:ok, _} -> false {:error, _} -> true end + # or + plug Tesla.Middleware.Retry, should_retry: fn + {:ok, %{status: status}}, _env, _context when status in [400, 500] -> true + {:ok, _reason}, _env, _context -> false + {:error, _reason}, %Tesla.Env{method: :post}, _context -> false + {:error, _reason}, %Tesla.Env{method: :put}, %{retries: 2} -> false + {:error, _reason}, _env, _context -> true + end end ``` @@ -42,7 +50,9 @@ defmodule Tesla.Middleware.Retry do - `: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 + - `:should_retry` - function with an arity of 1 or 3 used to determine if the request should + be retried the first argument is the result, the second is the env and the third is + the context: options + `:retries` (defaults to a match on `{:error, _reason}`) - `:jitter_factor` - additive noise proportionality constant (float between 0 and 1, defaults to 0.2) """ @@ -65,7 +75,7 @@ defmodule Tesla.Middleware.Retry do 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)), + should_retry: should_retry_opt!(opts), jitter_factor: float_opt!(opts, :jitter_factor, 0, 1) } @@ -84,15 +94,26 @@ defmodule Tesla.Middleware.Retry do 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.jitter_factor) - context = update_in(context, [:retries], &(&1 + 1)) - retry(env, next, context) - else - res + {:arity, should_retry_arity} = :erlang.fun_info(context.should_retry, :arity) + + cond do + should_retry_arity == 1 and context.should_retry.(res) -> + do_retry(env, next, context) + + should_retry_arity == 3 and context.should_retry.(res, env, context) -> + do_retry(env, next, context) + + true -> + res end end + defp do_retry(env, next, context) do + backoff(context.max_delay, context.delay, context.retries, context.jitter_factor) + context = update_in(context, [:retries], &(&1 + 1)) + retry(env, next, context) + end + # Exponential backoff with jitter defp backoff(cap, base, attempt, jitter_factor) do factor = Bitwise.bsl(1, attempt) @@ -124,6 +145,19 @@ defmodule Tesla.Middleware.Retry do end end + defp should_retry_opt!(opts) do + case Keyword.get(opts, :should_retry, &match?({:error, _}, &1)) do + should_retry_fun when is_function(should_retry_fun, 1) -> + should_retry_fun + + should_retry_fun when is_function(should_retry_fun, 3) -> + should_retry_fun + + value -> + invalid_should_retry_fun(value) + end + end + defp invalid_integer(key, value, min) do raise(ArgumentError, "expected :#{key} to be an integer >= #{min}, got #{inspect(value)}") end @@ -134,4 +168,11 @@ defmodule Tesla.Middleware.Retry do "expected :#{key} to be a float >= #{min} and <= #{max}, got #{inspect(value)}" ) end + + defp invalid_should_retry_fun(value) do + raise( + ArgumentError, + "expected :should_retry to be a function with arity of 1 or 3, got #{inspect(value)}" + ) + end end diff --git a/lib/tesla/mock.ex b/lib/tesla/mock.ex index 5aea1371..3dc7bf76 100644 --- a/lib/tesla/mock.ex +++ b/lib/tesla/mock.ex @@ -4,7 +4,7 @@ defmodule Tesla.Mock do ## Setup - ``` + ```elixir # config/test.exs config :tesla, adapter: Tesla.Mock @@ -14,7 +14,7 @@ defmodule Tesla.Mock do ## Examples - ``` + ```elixir defmodule MyAppTest do use ExUnit.Case @@ -37,7 +37,7 @@ defmodule Tesla.Mock do ## Setting up mocks - ``` + ```elixir # Match on method & url and return whole Tesla.Env Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/list"} -> @@ -83,7 +83,7 @@ defmodule Tesla.Mock do To solve this issue it is possible to setup a global mock using `mock_global/1` function. - ``` + ```elixir defmodule MyTest do use ExUnit.Case, async: false # must be false! diff --git a/mix.lock b/mix.lock index f5d69128..62a2e464 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,6 @@ %{ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, - "castore": {:hex, :castore, "1.0.2", "0c6292ecf3e3f20b7c88408f00096337c4bfd99bd46cc2fe63413ddbe45b3573", [:mix], [], "hexpm", "40b2dd2836199203df8500e4a270f10fc006cc95adc8a319e148dc3077391d96"}, + "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "con_cache": {:hex, :con_cache, "0.14.0", "863acb90fa08017be3129074993af944cf7a4b6c3ee7c06c5cd0ed6b94fbc223", [:mix], [], "hexpm", "50887a8949377d0b707a3c6653b7610de06074751b52d0f267f52135f391aece"}, "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, @@ -28,7 +28,7 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"}, "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, diff --git a/test/tesla/middleware/retry_test.exs b/test/tesla/middleware/retry_test.exs index 3a7ab657..4b9be4a8 100644 --- a/test/tesla/middleware/retry_test.exs +++ b/test/tesla/middleware/retry_test.exs @@ -9,6 +9,7 @@ defmodule Tesla.Middleware.RetryTest do response = case env.url do "/ok" -> {:ok, env} + "/maybe" when retries == 2 -> {:error, :nxdomain} "/maybe" when retries < 5 -> {:error, :econnrefused} "/maybe" -> {:ok, env} "/nope" -> {:error, :econnrefused} @@ -39,9 +40,20 @@ defmodule Tesla.Middleware.RetryTest do delay: 10, max_retries: 10, should_retry: fn - {:ok, %{status: status}} when status in [400, 500] -> true - {:ok, _} -> false - {:error, _} -> true + {:ok, %{status: status}}, _env, _context when status in [400, 500] -> + true + + {:ok, _reason}, _env, _context -> + false + + {:error, _reason}, %Tesla.Env{method: :post}, _context -> + false + + {:error, _reason}, %Tesla.Env{method: :put}, %{retries: 2} -> + false + + {:error, _reason}, _env, _context -> + true end adapter LaggyAdapter @@ -74,6 +86,14 @@ defmodule Tesla.Middleware.RetryTest do ClientWithShouldRetryFunction.get("/retry_status") end + test "use custom retry determination function matching on env" do + assert {:error, :econnrefused} = ClientWithShouldRetryFunction.post("/maybe", "payload") + end + + test "use custom retry determination function matching on context" do + assert {:error, :nxdomain} = ClientWithShouldRetryFunction.put("/maybe", "payload") + end + defmodule DefunctClient do use Tesla @@ -171,4 +191,30 @@ defmodule Tesla.Middleware.RetryTest do ClientWithJitterFactorGt1.get("/ok") end end + + test "ensures should_retry option is a function with arity of 1 or 3" do + defmodule ClientWithShouldRetryArity0 do + use Tesla + plug Tesla.Middleware.Retry, should_retry: fn -> true end + adapter LaggyAdapter + end + + defmodule ClientWithShouldRetryArity2 do + use Tesla + plug Tesla.Middleware.Retry, should_retry: fn _res, _env -> true end + adapter LaggyAdapter + end + + assert_raise ArgumentError, + ~r/expected :should_retry to be a function with arity of 1 or 3, got #Function<\d.\d+\/0/, + fn -> + ClientWithShouldRetryArity0.get("/ok") + end + + assert_raise ArgumentError, + ~r/expected :should_retry to be a function with arity of 1 or 3, got #Function<\d.\d+\/2/, + fn -> + ClientWithShouldRetryArity2.get("/ok") + end + end end