├── .formatter.exs ├── .gitignore ├── .tidy.exs ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── with_retry.ex └── with_retry │ └── back_off.ex ├── mix.exs ├── mix.lock └── test ├── test_helper.exs ├── with_retry └── back_off_test.exs ├── with_retry_back_off_test.exs └── with_retry_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | with_retry-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.tidy.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | checks: [ 3 | {Tidy.Checks.DescribeOptions, [level: :warning, args: [:opts, :options]]}, 4 | # {Tidy.Checks.FunctionArgumentDocumentation, 5 | # [level: :warning, exceptions: ["opts", "options"]]}, 6 | {Tidy.Checks.FunctionDoc, [level: :error]}, 7 | {Tidy.Checks.FunctionExamples, [level: :suggest]}, 8 | {Tidy.Checks.FunctionSpec, [level: :error]}, 9 | {Tidy.Checks.ImplementationMentionBehavior, [level: :warning, args: [:opts, :options]]}, 10 | {Tidy.Checks.ModuleDoc, [level: :error]} 11 | ], 12 | ignore: %{ 13 | functions: [__struct__: 0, __struct__: 1, __changeset__: 0, __schema__: 1, __schema__: 2], 14 | modules: [] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ian Luites 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WithRetry 2 | [![Hex.pm](https://img.shields.io/hexpm/v/with_retry.svg "Hex")](https://hex.pm/with_retry/with_retry) 3 | [![Hex.pm](https://img.shields.io/hexpm/l/with_retry.svg "License")](LICENSE.md) 4 | 5 | `with_retry` is an additional code block used for writing 6 | with statements that have retry logic. 7 | 8 | [API Reference](https://hexdocs.pm/with_retry/) 9 | 10 | ## Getting started 11 | 12 | ### 1. Check requirements 13 | 14 | - Elixir 1.7+ 15 | 16 | ### 2. Install WithRetry 17 | 18 | Edit `mix.exs` and add `with_retry` to your list of dependencies and applications: 19 | 20 | ```elixir 21 | def deps do 22 | [{:with_retry, "~> 1.0"}] 23 | end 24 | ``` 25 | 26 | Then run `mix deps.get`. 27 | 28 | ### 3. Use 29 | 30 | Configure hosts and queues: 31 | 32 | ```elixir 33 | defmodule Example do 34 | use WithRetry 35 | 36 | 37 | def download(url, file) do 38 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 39 | :ok <- File.write(file, data) do 40 | data 41 | end 42 | end 43 | end 44 | ``` 45 | 46 | ## Capturing failures 47 | 48 | The `with_retry` captures many possible failures including: 49 | - Pattern mismatch. 50 | - Raise 51 | - Throw 52 | - Exit 53 | 54 | All none captured failures will either return in the case of no `else` or 55 | bubble up. 56 | 57 | ### Pattern Mismatch (else) 58 | 59 | ```elixir 60 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 61 | :ok <- File.write(file, data) do 62 | data 63 | else 64 | _error -> nil 65 | end 66 | ``` 67 | 68 | ### Raise (rescue) 69 | 70 | ```elixir 71 | with_retry %{body: data} <- HTTPX.get(url), 72 | :ok <- File.write!(file, data) do 73 | data 74 | rescue 75 | _ -> nil 76 | end 77 | ``` 78 | 79 | ### Throw (catch) 80 | 81 | ```elixir 82 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 83 | :ok <- might_throw(data), 84 | :ok <- File.write(file, data) do 85 | data 86 | catch 87 | _thrown -> nil 88 | end 89 | ``` 90 | 91 | ### Exit (catch) 92 | 93 | ```elixir 94 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 95 | :ok <- might_exit(data), 96 | :ok <- File.write(file, data) do 97 | data 98 | catch 99 | {:exit, _code} -> nil 100 | end 101 | ``` 102 | 103 | ### Combined 104 | 105 | ```elixir 106 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 107 | :ok <- might_throw(data), 108 | :ok <- might_exit(data), 109 | :ok <- File.write!(file, data) do 110 | data 111 | else 112 | _error -> nil 113 | rescue 114 | _ -> nil 115 | catch 116 | {:exit, _code} -> nil 117 | _thrown -> nil 118 | end 119 | ``` 120 | 121 | ## Back Off 122 | 123 | The back off (timeouts) can be configured on the last last of the `with_retry`. 124 | 125 | The default back off is 5 total tries with 1s in between attempts. 126 | To update the configuration see the following examples. 127 | 128 | ### No Retry 129 | 130 | Setting the `back_off` to `false` will disable retries and 131 | function like a normal `with`. 132 | 133 | ```elixir 134 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 135 | :ok <- File.write(file, data), 136 | back_off: false do 137 | data 138 | end 139 | ``` 140 | 141 | ### Passing An Enumerable 142 | 143 | The `back_off` accepts any enumerable that returns timeouts in milliseconds. 144 | 145 | In this example we wait `250`ms after the first attempt, 146 | `1_000` after the second, and `5_000` after the third. 147 | ```elixir 148 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 149 | :ok <- File.write(file, data), 150 | back_off: [250, 1_000, 5_000] do 151 | data 152 | end 153 | ``` 154 | 155 | ### Build In Back Off Strategies 156 | #### Constant 157 | 158 | To retry with a constant timeout use: `constant/1` passing the timeout in `ms`. 159 | 160 | Retry endlessly every `5_000`ms. 161 | ```elixir 162 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 163 | :ok <- File.write(file, data), 164 | back_off: constant(5_000) do 165 | data 166 | end 167 | ``` 168 | 169 | #### Linear 170 | 171 | To retry with a linearly increasing timeout use: `linear/2` passing 172 | the base timeout in `ms` and the increase in `ms`. 173 | 174 | Retry endlessly starting with `1_000`ms and increasing the timeout with 175 | `1_500` every wait. (e.g. `1_000`, `2_500`, `4_000`, ...) 176 | ```elixir 177 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 178 | :ok <- File.write(file, data), 179 | back_off: linear(1_000, 1_500) do 180 | data 181 | end 182 | ``` 183 | 184 | #### Exponential 185 | 186 | To retry with an exponentially increasing timeout use: `exponential/2` passing 187 | the base timeout in `ms` and the factor. 188 | 189 | Retry endlessly starting with `250`ms and doubling after every wait. 190 | (e.g. `250`, `500`, `1_000`, `2_000`, ...) 191 | ```elixir 192 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 193 | :ok <- File.write(file, data), 194 | back_off: exponential(250, 2) do 195 | data 196 | end 197 | ``` 198 | 199 | ### Build In Back Off Modifiers 200 | #### Cap 201 | 202 | To cap the retry to a maximum timeout one can use `cap/2` and give a 203 | `back_off` and a cap in ms. 204 | 205 | Retry endlessly starting with `250`ms and doubling after every wait, 206 | but capping at `1_500`. 207 | (e.g. `250`, `500`, `1_000`, `1_500`, `1_500`, ...) 208 | ```elixir 209 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 210 | :ok <- File.write(file, data), 211 | back_off: cap(exponential(250, 2), 1_500) do 212 | data 213 | end 214 | ``` 215 | 216 | #### Max Try 217 | 218 | Limit the back off to a given amount of tries using `max_try/2` and give a 219 | `back_off` and a count of tries. 220 | (Including the first attempt.) 221 | 222 | Try 4 times with exponential back off. 223 | (e.g. `#1`, wait `250`, `#2`, wait `500`, `#3`, wait `1_000`, `#4`) 224 | ```elixir 225 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 226 | :ok <- File.write(file, data), 227 | back_off: max_try(exponential(250, 2), 4) do 228 | data 229 | end 230 | ``` 231 | 232 | #### Max Retry 233 | 234 | Limit the back off to a given amount of retries using `max_retry/2` and give a 235 | `back_off` and a count of retries. 236 | (Excluding the first attempt.) 237 | 238 | Retry 3 times with exponential back off. 239 | (e.g. `#1`, wait `250`, `#2`, wait `500`, `#3`, wait `1_000`, `#4`) 240 | ```elixir 241 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 242 | :ok <- File.write(file, data), 243 | back_off: max_try(exponential(250, 2), 3) do 244 | data 245 | end 246 | ``` 247 | 248 | #### Limit 249 | 250 | Limit execution of the `with_retry` to a given time limit in `ms` 251 | using `limit/2`. 252 | 253 | This includes the time spend doing processing the actually `with`. 254 | See: `limit_wait/2` to limit the time spend waiting, excluding execution time. 255 | 256 | The prediction is a best effort limitation and a long execution time might 257 | bring the total time spend on executing the `with_try` over the set limit. 258 | 259 | Retry as many times as fit within `4_000`ms with exponential back off. 260 | ```elixir 261 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 262 | :ok <- File.write(file, data), 263 | back_off: limit(exponential(250, 2), 8_000) do 264 | data 265 | end 266 | ``` 267 | 268 | #### Limit Wait 269 | 270 | Limit the waiting time of the `with_retry` to a given time limit in `ms` 271 | using `limit_wait/2`. 272 | 273 | This excludes the time spend doing processing the actually `with`. 274 | See: `limit/2` to limit the total time, including execution time. 275 | 276 | Retry as many times as fit within `8_000`ms with exponential back off. 277 | (e.g. `250`, `500`, `1_000`, `2_000`, `4_000` for a total of `7_750`.) 278 | ```elixir 279 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 280 | :ok <- File.write(file, data), 281 | back_off: limit_wait(exponential(250, 2), 8_000) do 282 | data 283 | end 284 | ``` 285 | 286 | ## Changelog 287 | 288 | ### v1.0 289 | 290 | - Fix dialyzer issue. 291 | 292 | ## Copyright and License 293 | 294 | Copyright (c) 2018, Ian Luites. 295 | 296 | WithRetry code is licensed under the [MIT License](LICENSE.md). 297 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /lib/with_retry.ex: -------------------------------------------------------------------------------- 1 | defmodule WithRetry do 2 | @moduledoc ~S""" 3 | Adds a with_retry block for writing with statements that are automatically retried. 4 | 5 | ## Example 6 | 7 | ```elixir 8 | defmodule Example do 9 | use WithRetry 10 | 11 | 12 | def download(url, file) do 13 | with_retry {:ok, %{body: data}} <- HTTPX.get(url), 14 | :ok <- File.write(file, data) do 15 | data 16 | end 17 | end 18 | end 19 | ``` 20 | """ 21 | 22 | @doc false 23 | defmacro __using__(_opts \\ []) do 24 | quote do 25 | require WithRetry 26 | import WithRetry 27 | import WithRetry.BackOff 28 | end 29 | end 30 | 31 | ### Generate `with_try` macro. 32 | ## Elixir has not variable arity macros. 33 | # 34 | 35 | Enum.each(0..10, fn count -> 36 | arg = Enum.map(0..count, &Macro.var(:"c#{&1}", nil)) 37 | 38 | @doc ~S""" 39 | """ 40 | defmacro with_retry(unquote_splicing(arg), opts), do: create_with_retry(unquote(arg), opts) 41 | end) 42 | 43 | ### `with_try` execution. 44 | 45 | @doc false 46 | @spec do_with_retry(function, Enumerable.t(), Keyword.t()) :: any 47 | def do_with_retry(exec, back_off, opts \\ []) do 48 | back_off 49 | |> Enum.reduce_while( 50 | nil, 51 | fn sleep, acc -> 52 | acc = acc || attempt(exec) 53 | 54 | if success?(acc) do 55 | {:halt, acc} 56 | else 57 | :timer.sleep(sleep) 58 | {:cont, attempt(exec)} 59 | end 60 | end 61 | ) 62 | |> Kernel.||(attempt(exec)) 63 | |> process(opts) 64 | end 65 | 66 | ### Generation Helpers ### 67 | 68 | defp create_with_retry(args, opts) do 69 | maybe_opts = List.last(args) 70 | 71 | {args, opts} = 72 | if Keyword.keyword?(maybe_opts), 73 | do: {List.delete_at(args, -1), Keyword.merge(opts, maybe_opts)}, 74 | else: {args, opts} 75 | 76 | back_off = 77 | if Keyword.has_key?(opts, :back_off), 78 | do: opts[:back_off] || [], 79 | else: quote(do: max_try(5)) 80 | 81 | quote do 82 | do_with_retry( 83 | fn -> 84 | with unquote_splicing(args) do 85 | {:success, unquote(opts[:do])} 86 | else 87 | failed -> {:failed, failed} 88 | end 89 | end, 90 | unquote(back_off), 91 | else: unquote(create_case_call(opts[:else])), 92 | rescue: unquote(create_case_call(opts[:rescue])), 93 | catch: unquote(create_case_call(opts[:catch])) 94 | ) 95 | end 96 | end 97 | 98 | defp create_case_call(nil), do: nil 99 | 100 | defp create_case_call(code) do 101 | quote do 102 | fn input -> 103 | case input do 104 | unquote(code) 105 | end 106 | end 107 | end 108 | end 109 | 110 | ### Execution Helpers ### 111 | 112 | @spec success?(any) :: boolean 113 | defp success?({:success, _}), do: true 114 | defp success?(_), do: false 115 | 116 | @spec process({:success | :failed | :rescue | :catch | :exit, any}, Keyword.t()) :: 117 | any | no_return 118 | defp process({:success, result}, _opts), do: result 119 | 120 | defp process({:failed, result}, opts) do 121 | if opts[:else], do: opts[:else].(result), else: result 122 | end 123 | 124 | defp process({:rescue, result}, opts) do 125 | if opts[:rescue], do: opts[:rescue].(result), else: raise(result) 126 | end 127 | 128 | defp process({:catch, result}, opts) do 129 | if opts[:catch], do: opts[:catch].(result), else: throw(result) 130 | end 131 | 132 | defp process({:exit, result}, opts) do 133 | if opts[:catch], do: opts[:catch].({:exit, result}), else: exit(result) 134 | end 135 | 136 | @spec attempt(function) :: {:success | :failed | :rescue | :catch | :exit, any} 137 | defp attempt(exec) do 138 | exec.() 139 | rescue 140 | e -> {:rescue, e} 141 | catch 142 | cat -> {:catch, cat} 143 | :exit, cat -> {:exit, cat} 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/with_retry/back_off.ex: -------------------------------------------------------------------------------- 1 | defmodule WithRetry.BackOff do 2 | @moduledoc ~S""" 3 | Helper module with different back off strategies and functionality. 4 | """ 5 | 6 | @doc ~S""" 7 | A constant stream of timeouts. 8 | 9 | ## Example 10 | 11 | ``` 12 | iex> constant(2_000) |> Enum.to_list() 13 | [2_000, 2_000, ...] 14 | ``` 15 | """ 16 | @spec constant(pos_integer) :: Enumerable.t() 17 | def constant(timeout \\ 1_000), do: Stream.unfold(0, &{timeout, &1 + 1}) 18 | 19 | @doc ~S""" 20 | A linearly increasing stream of timeouts. 21 | 22 | ## Example 23 | 24 | ``` 25 | iex> linear(1_000, 1_500) |> Enum.to_list() 26 | [1_000, 2_500, 4_000, ...] 27 | ``` 28 | """ 29 | @spec linear(pos_integer, pos_integer) :: Enumerable.t() 30 | def linear(base \\ 1_000, addition \\ 1_000), do: Stream.unfold(base, &{&1, &1 + addition}) 31 | 32 | @doc ~S""" 33 | A exponentially increasing stream of timeouts. 34 | 35 | ## Example 36 | 37 | ``` 38 | iex> exponential(1_000, 2) |> Enum.to_list() 39 | [1_000, 2_000, 4_000, ...] 40 | ``` 41 | """ 42 | @spec exponential(pos_integer, pos_integer | float) :: Enumerable.t() 43 | def exponential(base \\ 1_000, factor \\ 2), do: Stream.unfold(base, &{round(&1), &1 * factor}) 44 | 45 | @doc ~S""" 46 | Caps a stream of timeouts to the given value. 47 | 48 | ## Example 49 | 50 | ``` 51 | iex> exponential(1_000, 2) |> cap(3_500) |> Enum.to_list() 52 | [1_000, 2_000, 3_500, ...] 53 | ``` 54 | """ 55 | @spec cap(Enumerable.t(), pos_integer) :: Enumerable.t() 56 | def cap(back_off, cap), do: Stream.map(back_off, &if(&1 < cap, do: &1, else: cap)) 57 | 58 | @doc ~S""" 59 | Caps a stream to the given maximum number of tries. 60 | 61 | (Including the first attempt.) 62 | 63 | See: `max_retry/2` to cap to any number of retries. 64 | 65 | ## Example 66 | 67 | ``` 68 | iex> exponential(1_000, 2) |> max_try(3) |> Enum.to_list() 69 | [1_000, 2_000] 70 | ``` 71 | """ 72 | @spec max_try(Enumerable.t(), pos_integer) :: Enumerable.t() 73 | def max_try(back_off \\ constant(), max), do: max_retry(back_off, max - 1) 74 | 75 | @doc ~S""" 76 | Caps a stream to the given maximum number of retries. 77 | 78 | (Excluding the first attempt.) 79 | 80 | See: `max_try/2` to cap to any number of tries. 81 | 82 | ## Example 83 | 84 | ``` 85 | iex> exponential(1_000, 2) |> max_try(3) |> Enum.to_list() 86 | [1_000, 2_000, 4_000] 87 | ``` 88 | """ 89 | @spec max_retry(Enumerable.t(), pos_integer) :: Enumerable.t() 90 | def max_retry(back_off \\ constant(), max), 91 | do: Stream.transform(back_off, 0, &if(&2 < max, do: {[&1], &2 + 1}, else: {:halt, &2})) 92 | 93 | @doc ~S""" 94 | Limits a stream of timeouts to a maximum duration. 95 | 96 | This includes the time spend doing processing the actually `with`. 97 | See: `limit_wait/2` to limit the time spend waiting, excluding execution time. 98 | 99 | The prediction is a best effort limitation and a long execution time might 100 | bring the total time spend on executing the `with_try` over the set limit. 101 | 102 | ## Example 103 | 104 | ``` 105 | iex> exponential(1_000, 2) |> limit(7_000) |> Enum.to_list() 106 | [1_000, 2_000] 107 | ``` 108 | *Note:* 109 | You would expect `[1_000, 2_000, 4_000]` (sum: `7_000`), 110 | but assuming a non zero execution time the `3_000 + execution time + 4_000` 111 | would bring the total over the set `7_000` limit. 112 | """ 113 | @spec limit(Enumerable.t(), pos_integer) :: Enumerable.t() 114 | def limit(back_off \\ constant(), limit) do 115 | Stream.transform( 116 | back_off, 117 | :os.system_time(:milli_seconds), 118 | &if(:os.system_time(:milli_seconds) - &2 + &1 <= limit, do: {[&1], &2}, else: {:halt, &2}) 119 | ) 120 | end 121 | 122 | @doc ~S""" 123 | Limits a stream of timeouts to a maximum duration. 124 | 125 | This excludes the time spend doing processing the actually `with`. 126 | See: `limit/2` to limit the total time, including execution time. 127 | 128 | ## Example 129 | 130 | ``` 131 | iex> exponential(1_000, 2) |> limit_wait(7_000) |> Enum.to_list() 132 | [1_000, 2_000, 4_000] 133 | ``` 134 | """ 135 | @spec limit_wait(Enumerable.t(), pos_integer) :: Enumerable.t() 136 | def limit_wait(back_off \\ constant(), limit) do 137 | Stream.transform( 138 | back_off, 139 | 0, 140 | &if(&2 + &1 <= limit, do: {[&1], &2 + &1}, else: {:halt, &2}) 141 | ) 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule WithRetry.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :with_retry, 7 | description: 8 | "Additional `with_retry` code block used for writing with statements that have retry logic.", 9 | version: "1.0.0", 10 | elixir: "~> 1.7", 11 | build_embedded: Mix.env() == :prod, 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | package: package(), 15 | 16 | # Testing 17 | test_coverage: [tool: ExCoveralls], 18 | preferred_cli_env: [ 19 | coveralls: :test, 20 | "coveralls.detail": :test, 21 | "coveralls.post": :test, 22 | "coveralls.html": :test 23 | ], 24 | # dialyzer: [ignore_warnings: "dialyzer.ignore-warnings", plt_add_deps: true], 25 | 26 | # Docs 27 | name: "with_retry", 28 | source_url: "https://github.com/IanLuites/with_retry", 29 | homepage_url: "https://github.com/IanLuites/with_retry", 30 | docs: [ 31 | main: "readme", 32 | extras: ["README.md", "LICENSE.md"] 33 | ] 34 | ] 35 | end 36 | 37 | def package do 38 | [ 39 | name: :with_retry, 40 | maintainers: ["Ian Luites"], 41 | licenses: ["MIT"], 42 | files: [ 43 | # Elixir 44 | "lib/with_retry", 45 | "lib/with_retry.ex", 46 | "mix.exs", 47 | "README*", 48 | "LICENSE*" 49 | ], 50 | links: %{ 51 | "GitHub" => "https://github.com/IanLuites/with_retry" 52 | } 53 | ] 54 | end 55 | 56 | def application do 57 | [ 58 | extra_applications: [:logger] 59 | ] 60 | end 61 | 62 | defp deps do 63 | [ 64 | # Dev / Test 65 | {:analyze, "~> 0.1.4", only: [:dev, :test], runtime: false}, 66 | {:dialyxir, "~> 1.0.0-rc.6", optional: true, runtime: false, only: :dev} 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "analyze": {:hex, :analyze, "0.1.4", "f75681ee5434da048616b78e17df7d43c8343a062ea5dac52b967d4414e8ab25", [:mix], [{:credo, ">= 1.0.5", [hex: :credo, repo: "hexpm", optional: false]}, {:ex_doc, ">= 0.20.2", [hex: :ex_doc, repo: "hexpm", optional: false]}, {:excoveralls, ">= 0.11.1", [hex: :excoveralls, repo: "hexpm", optional: false]}, {:hackney, "~> 1.15", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:tidy, ">= 0.0.6", [hex: :tidy, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 4 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "credo": {:hex, :credo, "1.0.5", "fdea745579f8845315fe6a3b43e2f9f8866839cfbc8562bb72778e9fdaa94214", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 8 | "erlex": {:hex, :erlex, "0.2.1", "cee02918660807cbba9a7229cae9b42d1c6143b768c781fa6cee1eaf03ad860b", [:mix], [], "hexpm"}, 9 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 14 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 17 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 18 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 19 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 20 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 21 | "tidy": {:hex, :tidy, "0.0.6", "029dbf5b47c8980b661c96ae56285e27af801c4c29a82e55d1e7e4396ebfaca2", [:mix], [], "hexpm"}, 22 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 23 | } 24 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/with_retry/back_off_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WithRetry.BackOffTest do 2 | use ExUnit.Case, async: true 3 | use WithRetry 4 | 5 | describe "constant/1" do 6 | test "returns 1_000 endlessly (default)" do 7 | check = Enum.random(1..100) 8 | 9 | assert Enum.take(constant(), check) == Enum.map(0..(check - 1), fn _ -> 1_000 end) 10 | end 11 | 12 | test "returns give endlessly" do 13 | given = Enum.random(1..100) 14 | check = Enum.random(1..100) 15 | 16 | assert Enum.take(constant(given), check) == Enum.map(0..(check - 1), fn _ -> given end) 17 | end 18 | end 19 | 20 | describe "linear/2" do 21 | test "returns u(n) = u(n-1) + 1_000 (u(0) = 1_000) endlessly (default)" do 22 | check = Enum.random(1..100) 23 | 24 | assert Enum.take(linear(), check) == Enum.map(1..check, fn x -> x * 1_000 end) 25 | end 26 | 27 | test "returns u(n) = u(n-1) + 1_000 (n(0) = given) endlessly" do 28 | given = Enum.random(1..100) 29 | check = Enum.random(1..100) 30 | 31 | assert Enum.take(linear(given), check) == 32 | Enum.map(0..(check - 1), fn x -> given + x * 1_000 end) 33 | end 34 | 35 | test "returns u(n) = u(n-1) + increase (n(0) = given) endlessly" do 36 | given = Enum.random(1..100) 37 | increase = Enum.random(1..100) 38 | check = Enum.random(1..100) 39 | 40 | assert Enum.take(linear(given, increase), check) == 41 | Enum.map(0..(check - 1), fn x -> given + x * increase end) 42 | end 43 | end 44 | 45 | describe "exponential/2" do 46 | test "returns u(n) = u(n-1) * 2 (u(0) = 1_000) endlessly (default)" do 47 | check = Enum.random(1..100) 48 | 49 | assert Enum.take(exponential(), check) == 50 | Enum.map(0..(check - 1), fn x -> 1_000 * :math.pow(2, x) end) 51 | end 52 | 53 | test "returns u(n) = u(n-1) * 2 (n(0) = given) endlessly" do 54 | given = Enum.random(1..100) 55 | check = Enum.random(1..100) 56 | 57 | assert Enum.take(exponential(given), check) == 58 | Enum.map(0..(check - 1), fn x -> round(given * :math.pow(2, x)) end) 59 | end 60 | 61 | test "returns u(n) = u(n-1) * factor (n(0) = given) endlessly" do 62 | given = Enum.random(1..10) 63 | factor = Enum.random(2..3) 64 | check = Enum.random(1..20) 65 | 66 | assert Enum.take(exponential(given, factor), check) == 67 | Enum.map(0..(check - 1), fn x -> round(given * :math.pow(factor, x)) end) 68 | end 69 | end 70 | 71 | describe "cap/2" do 72 | test "caps wait to given value" do 73 | check = Enum.random(1..50) 74 | cap = Enum.random(0..999) 75 | 76 | assert Enum.take(cap(constant(), cap), check) == Enum.map(1..check, fn _ -> cap end) 77 | end 78 | end 79 | 80 | describe "max_try/2" do 81 | test "returns constant with one less then given" do 82 | check = Enum.random(2..10) 83 | 84 | assert Enum.to_list(max_try(0)) == [] 85 | assert Enum.to_list(max_try(check)) == Enum.map(0..(check - 2), fn _ -> 1_000 end) 86 | end 87 | 88 | test "limits given back off" do 89 | check = Enum.random(2..10) 90 | 91 | assert Enum.to_list(max_try(linear(1, 1), 0)) == [] 92 | assert Enum.to_list(max_try(linear(1, 1), check)) == Enum.to_list(1..(check - 1)) 93 | end 94 | end 95 | 96 | describe "max_retry/2" do 97 | test "returns constant with give attempts" do 98 | check = Enum.random(1..10) 99 | 100 | assert Enum.to_list(max_retry(check)) == Enum.map(1..check, fn _ -> 1_000 end) 101 | end 102 | 103 | test "limits given back off" do 104 | check = Enum.random(1..10) 105 | 106 | assert Enum.to_list(max_retry(linear(1, 1), check)) == Enum.to_list(1..check) 107 | end 108 | end 109 | 110 | describe "limit/2" do 111 | test "returns timeouts until time runs out" do 112 | back_off = limit(1_000) 113 | assert Enum.take(back_off, 1) == [1_000] 114 | 115 | :timer.sleep(1_000) 116 | assert Enum.take(back_off, 1) == [] 117 | end 118 | 119 | test "limits given back off" do 120 | back_off = limit(linear(1, 1), 1_000) 121 | assert Enum.take(back_off, 3) == Enum.to_list(1..3) 122 | 123 | :timer.sleep(1_000) 124 | assert Enum.take(back_off, 3) == [] 125 | end 126 | end 127 | 128 | describe "limit_wait/2" do 129 | test "returns constant with max waiting for given" do 130 | check = Enum.random(1..10) 131 | 132 | assert Enum.to_list(limit_wait(check * 1_000)) == Enum.map(1..check, fn _ -> 1_000 end) 133 | end 134 | 135 | test "limits given back off" do 136 | check = Enum.random(3..10) 137 | 138 | assert Enum.sum(limit_wait(linear(1, 1), check)) <= check 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /test/with_retry_back_off_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WithRetryBackOffTest do 2 | use ExUnit.Case, async: false 3 | use WithRetry 4 | 5 | setup do 6 | {:ok, agent} = Agent.start(fn -> 0 end) 7 | 8 | [agent: agent] 9 | end 10 | 11 | defp attempt(agent), do: Agent.get_and_update(agent, &{&1 + 1, &1 + 1}) 12 | defp tries(agent), do: Agent.get(agent, & &1) 13 | 14 | test "retries till success", %{agent: agent} do 15 | result = 16 | with_retry 3 <- attempt(agent) do 17 | :success 18 | end 19 | 20 | assert result == :success 21 | assert tries(agent) == 3 22 | end 23 | 24 | # I might want to change this. 25 | test "retries whole if ", %{agent: agent} do 26 | result = 27 | with_retry x <- attempt(agent), 28 | 4 <- attempt(agent) do 29 | x 30 | end 31 | 32 | assert result == 3 33 | assert tries(agent) == 4 34 | end 35 | 36 | describe "back off tries the set amount of times" do 37 | test "false => only one try", %{agent: agent} do 38 | with_retry {:ok, x} <- attempt(agent), 39 | back_off: false do 40 | x 41 | end 42 | 43 | assert tries(agent) == 1 44 | end 45 | 46 | test "[...] => length of list + initial", %{agent: agent} do 47 | with_retry {:ok, x} <- attempt(agent), 48 | back_off: [1, 1] do 49 | x 50 | end 51 | 52 | assert tries(agent) == 3 53 | end 54 | 55 | test "Stream => length of stream + initial", %{agent: agent} do 56 | with_retry {:ok, x} <- attempt(agent), 57 | back_off: max_retry(constant(1), 4) do 58 | x 59 | end 60 | 61 | assert tries(agent) == 5 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/with_retry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WithRetryTest do 2 | use ExUnit.Case 3 | use WithRetry 4 | 5 | defp raise?(true), do: raise("Must raise 🧟") 6 | defp raise?(false), do: :no_raise 7 | 8 | defp throw?(true), do: throw("Must throw 🏀") 9 | defp throw?(false), do: :no_throw 10 | 11 | defp throw_exit(true), do: exit(1) 12 | 13 | describe "with_retry (pattern match)" do 14 | test "returns success" do 15 | result = 16 | with_retry {:ok, x} <- {:ok, 5}, 17 | back_off: false do 18 | x 19 | end 20 | 21 | assert result == 5 22 | end 23 | 24 | test "returns failure without else" do 25 | result = 26 | with_retry {:ok, x} <- {:error, :failure}, 27 | back_off: false do 28 | x 29 | end 30 | 31 | assert result == {:error, :failure} 32 | end 33 | 34 | test "pattern matches failure with else" do 35 | result = 36 | with_retry {:ok, x} <- {:error, :failure}, 37 | back_off: false do 38 | x 39 | else 40 | {:error, :failure} -> :caught 41 | end 42 | 43 | assert result == :caught 44 | end 45 | 46 | test "raise if no matches on else" do 47 | assert_raise( 48 | CaseClauseError, 49 | fn -> 50 | with_retry {:ok, x} <- {:error, :failure}, 51 | back_off: false do 52 | x 53 | else 54 | {:error, :different} -> :caught 55 | end 56 | end 57 | ) 58 | end 59 | end 60 | 61 | describe "with_retry (potentially raising)" do 62 | test "returns success" do 63 | result = 64 | with_retry x <- raise?(false), 65 | back_off: false do 66 | x 67 | end 68 | 69 | assert result == :no_raise 70 | end 71 | 72 | test "bubbles raise without rescue" do 73 | assert_raise( 74 | RuntimeError, 75 | fn -> 76 | with_retry x <- raise?(true), 77 | back_off: false do 78 | x 79 | end 80 | end 81 | ) 82 | end 83 | 84 | test "pattern matches failure with resceu" do 85 | result = 86 | with_retry x <- raise?(true), 87 | back_off: false do 88 | x 89 | rescue 90 | %RuntimeError{message: "Must raise 🧟"} -> :caught 91 | end 92 | 93 | assert result == :caught 94 | end 95 | 96 | test "raise if no matches on rescue" do 97 | assert_raise( 98 | CaseClauseError, 99 | fn -> 100 | with_retry x <- raise?(true), 101 | back_off: false do 102 | x 103 | rescue 104 | {:error, :different} -> :caught 105 | end 106 | end 107 | ) 108 | end 109 | end 110 | 111 | describe "with_retry (potentially throwing)" do 112 | test "returns success" do 113 | result = 114 | with_retry x <- throw?(false), 115 | back_off: false do 116 | x 117 | end 118 | 119 | assert result == :no_throw 120 | end 121 | 122 | test "bubbles throw without catch" do 123 | assert catch_throw( 124 | with_retry x <- throw?(true), 125 | back_off: false do 126 | x 127 | end 128 | ) == "Must throw 🏀" 129 | end 130 | 131 | test "pattern matches failure with catch" do 132 | result = 133 | with_retry x <- throw?(true), 134 | back_off: false do 135 | x 136 | catch 137 | "Must throw 🏀" -> :caught 138 | end 139 | 140 | assert result == :caught 141 | end 142 | 143 | test "raise if no matches on catch" do 144 | assert_raise( 145 | CaseClauseError, 146 | fn -> 147 | with_retry x <- throw?(true), 148 | back_off: false do 149 | x 150 | catch 151 | :different -> :caught 152 | end 153 | end 154 | ) 155 | end 156 | 157 | test "catches exits" do 158 | result = 159 | with_retry x <- throw_exit(true), 160 | back_off: false do 161 | x 162 | catch 163 | {:exit, _} -> :caught_exit 164 | end 165 | 166 | assert result == :caught_exit 167 | end 168 | end 169 | end 170 | --------------------------------------------------------------------------------