Coder Social home page Coder Social logo

safwank / elixirretry Goto Github PK

View Code? Open in Web Editor NEW
435.0 6.0 32.0 144 KB

Simple Elixir macros for linear retry, exponential backoff and wait with composable delays

License: Other

Elixir 100.00%
retry retry-strategies linear-retry exponential-backoff wait delay

elixirretry's Introduction

Build Status

ElixirRetry

Simple Elixir macros for linear retry, exponential backoff and wait with composable delays.

Installation

Add retry to your list of dependencies in mix.exs:

  def deps do
    [{:retry, "~> 0.18"}]
  end

Ensure retry is started before your application:

  def application do
    [applications: [:retry]]
  end

Documentation

Check out the API reference for the latest documentation.

Features

Retrying

The retry([with: _,] do: _, after: _, else: _) macro provides a way to retry a block of code on failure with a variety of delay and give up behaviors. By default, the execution of a block is considered a failure if it returns :error, {:error, _} or raises a runtime error.

An optional list of atoms can be specified in :atoms if you need to retry anything other than :error or {:error, _}, e.g. retry([with: _, atoms: [:not_ok]], do: _, after: _, else: _).

Similarly, an optional list of exceptions can be specified in :rescue_only if you need to retry anything other than RuntimeError, e.g. retry([with: _, rescue_only: [CustomError]], do: _, after: _, else: _).

The after block evaluates only when the do block returns a valid value before timeout. On the other hand, the else block evaluates only when the do block remains erroneous after timeout. Both are optional. By default, the else clause will return the last erroneous value or re-raise the last exception. The default after clause will simply return the last successful value.

Example -- constant backoff

result = retry with: constant_backoff(100) |> Stream.take(10) do
  ExternalApi.do_something # fails if other system is down
after
  result -> result
else
  error -> error
end

This example retries every 100 milliseconds and gives up after 10 attempts.

Example -- linear backoff

result = retry with: linear_backoff(10, 2) |> cap(1_000) |> Stream.take(10) do
  ExternalApi.do_something # fails if other system is down
after
  result -> result
else
  error -> error
end

This example increases the delay linearly with each retry, starting with 10 milliseconds, caps the delay at 1 second and gives up after 10 attempts.

Example -- exponential backoff

result = retry with: exponential_backoff() |> randomize |> expiry(10_000), rescue_only: [TimeoutError] do
  ExternalApi.do_something # fails if other system is down
after
  result -> result
else
  error -> error
end

Example -- optional clauses

result = retry with: constant_backoff(100) |> Stream.take(10) do
  ExternalApi.do_something # fails if other system is down
end

This example is equivalent to:

result = retry with: constant_backoff(100) |> Stream.take(10) do
  ExternalApi.do_something # fails if other system is down
after
  result -> result
else
  e when is_exception(e) -> raise e
  e -> e
end

Example -- retry annotation

use Retry.Annotation

@retry with: constant_backoff(100) |> Stream.take(10)
def some_func(arg) do
  ExternalApi.do_something # fails if other system is down
end

This example shows how you can annotate a function to retry every 100 milliseconds and gives up after 10 attempts.

Delay streams

The with: option of retry accepts any Stream that yields integers. These integers will be interpreted as the amount of time to delay before retrying a failed operation. When the stream is exhausted retry will give up, returning the last value of the block.

Example
result = retry with: Stream.cycle([500]) do
  ExternalApi.do_something # fails if other system is down
after
  result -> result
else
  error -> error  
end

This will retry failures forever, waiting 0.5 seconds between attempts.

Retry.DelayStreams provides a set of fully composable helper functions for building useful delay behaviors such as the ones in previous examples. See the Retry.DelayStreams module docs for full details and addition behavior not covered here. For convenience these functions are imported by use Retry so you can, usually, use them without prefixing them with the module name.

Waiting

Similar to retry(with: _, do: _), the wait(delay_stream, do: _, after: _, else: _) macro provides a way to wait for a block of code to be truthy with a variety of delay and give up behaviors. The execution of a block is considered a failure if it returns false or nil.

wait constant_backoff(100) |> expiry(1_000) do
  we_there_yet?
after
  _ ->
    {:ok, "We have arrived!"}
else
  _ ->
    {:error, "We're still on our way :("}
end

This example retries every 100 milliseconds and expires after 1 second.

The after block evaluates only when the do block returns a truthy value. On the other hand, the else block evaluates only when the do block remains falsy after timeout. Both are optional. By default, a success value will be returned as {:ok, value} and an erroneous value will be returned as {:error, value}.

Pretty nifty for those pesky asynchronous tests and building more reliable systems in general!

elixirretry's People

Contributors

am-kantox avatar bbalser avatar crowdhailer avatar danadaldos avatar dsnipe avatar fredwu avatar fsword avatar jbodah avatar jdenen avatar jtrees avatar jvoegele avatar legoscia avatar mononym avatar nathanalderson avatar neerfri avatar pdgonzalez872 avatar pezra avatar robinkwilson avatar safwank avatar skateinmars avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

elixirretry's Issues

Document why an application must be started

I have a rather simple question, why does this library starts an application? Maybe it'd be nice to explain briefly in the docs.
I see that the library makes the use of :timer.sleep so I'm not sure for which part an application is necessary.

Wrong syntax error

Hi there, I've this syntax:

  defp make_request(url, params) do
    params = Keyword.merge([
      p: "LAST_HOUR-12",
    ], params)
    |> URI.encode_query()
    retry with: exponential_backoff() |> expiry(120_000), rescue_only: [TimeoutError] do
      .....
    end
  end

and I'm getting

== Compilation error in file lib/.../poller.ex ==
** (ArgumentError) invalid syntax, only "retry", "after" and "else" are permitted
    expanding macro: Retry.retry/2
    lib/..../poller.ex:58: ...Poller.make_request/2
    (elixir) lib/kernel/parallel_compiler.ex:229: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7

however I don't see what can be wrong with it, I've tried to wrap the with: between parenthesis and other small changes but nothing changes

Retries are made in the test env

Thanks so much for your work on this library, great stuff!

Is it possible to configure it to only retry in specific environments? I didn't see anything in the readme or the docs.

Thanks!

access to result/error in then/else blocks?

Just yesterday we were replacing some manual retry handling with retry and while we loved the cleanliness of it, it would have been even easier if the then and else blocks would contain the result of the retry block. Like this:

retry with: lin_backoff(500, 1) |> take(5) do
  ...
then
  result -> ...
else
  error -> ...
end

I don't know much about Elixir macros, but is this possible?

What we have done in our case now was to assign the result to a variable, ie response = retry with: ... and then did another match on the response to distinguish the success and error case. That would be unnecessary if we could access those in the then/else blocks.

Arithmetic error when taking many values from a stream

Code that reproduces the issue on retry 0.10:

iex(18)> Retry.DelayStreams.exp_backoff(100) |> Retry.DelayStreams.cap(30_000) |> Enum.take(10_000)
** (ArithmeticError) bad argument in arithmetic expression
    (retry) lib/retry/delay_streams.ex:22: anonymous fn/2 in Retry.DelayStreams.exp_backoff/1
    (elixir) lib/stream.ex:1466: Stream.do_unfold/4
    (elixir) lib/stream.ex:1536: Enumerable.Stream.do_each/4
    (elixir) lib/enum.ex:2423: Enum.take/2

I usually won't be taking 10_000 elements from the stream but it seems possible that with other stream compositions this could happen sooner.

Retry causing dialyzer warnings in application code

We've hit an issue when trying to run dialyxir whilst using Retry.retry. This can be demonstrated with a fairly simple example.

I ran this by creating mix new, then putting the following in my mix.exs:

# mix.exs
...
  defp deps do
    [
      {:dialyxir, ">= 0.0.0", runtime: false},
      {:retry, ">= 0.0.0"}
    ]
  end

With the following sample code:

# lib/retry_poc.ex
defmodule RetryPoc do
  use Retry

  def foo do
    retry with: lin_backoff(1, 1) |> Stream.take(2) do
      {:ok, :bar}
    end
  end
end

When running mix dialyze, I see the following:

18:05:20@/tmp/retry_poc (master *%?):> mix dialyzer
Compiling 1 file (.ex)
Checking PLT...
[:compiler, :elixir, :kernel, :logger, :retry, :stdlib]
PLT is up to date!
Starting Dialyzer
dialyzer args: [
  check_plt: false,
  init_plt: '/tmp/retry_poc/_build/dev/dialyxir_erlang-20.1_elixir-1.6.3_deps-dev.plt',
  files_rec: ['/tmp/retry_poc/_build/dev/lib/retry_poc/ebin'],
  warnings: [:unknown]
]
done in 0m1.34s
lib/retry_poc.ex:5: The pattern _@1 = {'error', _} can never match the type {'ok','bar'}
lib/retry_poc.ex:5: The pattern _@2 = 'error' can never match the type {'ok','bar'}

This example is fairly trivial, but it caused a load of investigation for us earlier where we had function which returned {:ok, data} | {:error, error} but didn't return :error. Consequently we were getting an the fairly obscure error above. We ultimately tracked it down to this bit of code:

          case unquote(block) do
            {:error, _} = result -> {:cont, result}
            :error = result      -> {:cont, result}
            result               -> {:halt, result}
          end

It looks like dialyxir is essentially parsing the block_runner case statement and then complaining because two of the patterns are never matched. Could this potentially be resolved by moving the case section to a function instead of having it in a quote (though there may be very good reason not to do that)? I think so but unfortunately elixir metaprogramming is not my strong point...

It seems strange that library code should cause a dialyzer error like this. Would it be possible to change the implementation somehow so that it doesn't cause this please? Happy to help with that of course, I'm just not sure where to start 😄

Current jitter implementation is not very practical

It is too extreme IMO - if you include it in the stream pipe, it can completely break the base stream. For example, with exponential backoff, it can produce values that don't grow or even go lower. It should probably work in a percentage margin based on the original value, where the percentage is provided as the function argument.

I understand it's very easy to make own implementation, but maybe this will help future users of this library if fixed.

Elixir compiler warning in application code with Retry 0.9.0

Retry 0.9.0 gives us a new compiler warning in our own application code - notably:

warning: this clause cannot match because a previous clause at line 50 always matches
  lib/eggl/tasks/notify_close_io_on_deal_change.ex:50

The code looks like this:

50  retry with: lin_backoff(@closeio_backoff, 2) |> Stream.take(2) do
51    notify_close_io_about_deal_change(deal, analytics_ids)
52  end

This is a problem for us since we don't allow any application-generated warnings through CI. So we are blocked from upgrading to 0.9.0

Not compatible with Elixir 1.1

==> elixir_retry
warning: the dependency :elixir_retry requires Elixir "~> 0.13.1" but you are running on v1.1.1
Compiled lib/retry.ex

== Compilation error on file lib/elixir_retry.ex ==
** (CompileError) lib/elixir_retry.ex:2: module Application.Behaviour is not loaded and could not be found
    (elixir) expanding macro: Kernel.use/1
    lib/elixir_retry.ex:2: ElixirRetry (module)
    (elixir) lib/kernel/parallel_compiler.ex:100: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/8


== Compilation error on file lib/elixir_retry/supervisor.ex ==
** (CompileError) lib/elixir_retry/supervisor.ex:2: module Supervisor.Behaviour is not loaded and could not be found
    (elixir) expanding macro: Kernel.use/1
    lib/elixir_retry/supervisor.ex:2: ElixirRetry.Supervisor (module)
    (elixir) lib/kernel/parallel_compiler.ex:100: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/8

how to halt the retry's block_runner on custom errors?

Hi there, and thank you very much for this project! I have a question. What would be the best way to exit (:halt) the retry with: lin_backoff...'s block_runner on custom errors? In my case I replaced my retry implementation with yours (for a Neo4j Elixir driver), but I'd like to also be able to stop the retry cycle if I get errors triggered b/c of the user's fault. Basically, for: connection errors, timeouts, etc, I'm continuing with the default retry's behavior, but I'd like to interrupt it on syntax errors, etc., as there is no use in retrying a request containing a syntax error, for example. Thank you!

How to handle 3 element error tuples ?

I'm using a grpc library that returns 3 element tuple {:error, [invalid_argument: nil], nil} on error. I see retry does not handle these kind of errors.
Is it possible to add it ?

exception stacktrace in else block

Hi

I was wondering about proper stacktrace inside else block,
calling System.stacktrace() is producing deprecation warning.
I'm using sentry.io for error reporting.
I can craft some PR but I would like to consult first what would you suggest?

Getting the retry attempt number in the body of retry

Hi,

we are using ElixirRetry in our project and we were wondering if it is possible to retrieve the current retry attempt count in the body of the retry macro.
We are interested in such feature because we would like to increase HTTPotion timeout value in successive retries.

Thanks

Different Atoms for Retry

Hi,

I'm working on some middleware system and I'd like to use this library for retries.

The middleware already has an API that expects {:ok, _}, {:skip, _}, or {:error, _}

I'd like to use retry, but configure it only to retry on {:retry, _}

Having looked at the code, I don't believe this is possible. Would you accept a PR that made this configurable?

Bug: expiry/3 evaluates the expiry time at time of stream definition

expiry/3 can lead to some very confusing behaviour if you don't immediately run the stream.

Here's an example test (that currently fails) to demonstrate:

test "evaluates expiry at runtime of the stream, rather than execution time of expiry/3" do
  delay_stream =
    [50]
    |> Stream.cycle()
    |> expiry(75, 50)

  assert Enum.count(delay_stream, fn delay -> :timer.sleep(delay) end) == 2

  # If the expiry time is determined when `expiry/3` is executed, we will
  # already be past it (due to all the sleeps performed previously) and jump
  # out after the first run.
  assert Enum.count(delay_stream, fn delay -> :timer.sleep(delay) end) == 2
end

It would be much nicer if the stream could be re-used (kind of like a config value) instead.

define total retry timeout

In our code we are communicating with an external system that has "good and bad days". When it has a bad day, it will take a very long time to reply (timeout is 45s). Our default strategy is 5 retries with linear backoff, but we know that we won't get a response at all if we already had 2 retries with a total wait time of 90s - there is no need to retry 3 times more.

Would it be possible to add a total retry limit? Something like this maybe:

result = retry with: lin_backoff(500, 2) |> cap(5_000) |> take(5), max_retry: 90_000 do
   ...
end

To solve it ourselves we added a bunch of timings (started_time and elapsed_time) and then need to fake a return :ok when we passed the maximum retry time. Very awkward.

Specify only _some_ errors to be retried.

Let's assume that we have a function like that:

 @spec foo() ::
         :ok
         | {:error, :some_retryable_reason}
         | {:error, :other_retryable_reason}
         | {:error, :some_non_retryable_reason}
         | {:error, :other_non_retryable_reason}
 def foo() do
   ...
 end

What I'd like to express is roughly this:

retry with: linear_backoff(50, 1) |> take(5),
      rescue_only: [
        {:error, :some_retryable_reason},
        {:error, :other_retryable_reason}
      ] do
  foo()
after
  _ -> :ok
else
  error -> error
end

So - for some subset of errors attempt the retry, while for others give up instantly.

Currently it is impossible:

  • rescue_only works only for exceptions (foo would need to raise instead of returning {:error, reason})
  • atoms is for, well... atoms, not tuples (although with this interesting exception (if the first element of 2–elements tuple is a given atom the retry will kick in)

I can't see clear way of this situation. Some ideas:

  • third option, tuples, which would match any tuples specified by the user (any length, not necessarily (:error, reason}
  • allowing rescue_only to accept all sort of things, mixed - exceptions, atoms, tuples

What are your thoughts on that?

retrying on custom exception

Thanks for the work on this library, I love the API! The use of streams is extremely clever.
I was wondering how do you see using this library with exceptions that are not RuntimeError

For example I need to wrap an Ecto save with optimistic lock that may raise a Ecto. StaleEntryError. I could not find a simple way to achieve this with the current code.
I ended up doing something quite complex (code below).

I wrap every call to Retry.retry so the code inside will transform a raise of whitelisted exceptions (in the calling code) to a {:error, {:wrapped_exception, e}} return value, and then transform this value back to a raised exception if the last retry returned this tuple.

defmodule MyApp.Utils.Retry do
  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)
      import Retry.DelayStreams
    end
  end

  defmacro retry(exceptions, opts, do: block) do
    quote do
      require Retry
      Retry.retry(unquote(opts)) do
        unquote(__MODULE__).wrap_exceptions(unquote(exceptions)) do
          unquote(block)
        end
      end
      |> unquote(__MODULE__).unwrap_exceptions()
    end
  end

  defmacro wrap_exceptions(exceptions, do: block) do
    quote do
      try do
        unquote(block)
      rescue
        e in unquote(exceptions) ->
          {:error, {:wrapped_exception, e}}
      end
    end
  end

  def unwrap_exceptions(result) do
    case result do
      {:error, {:wrapped_exception, e}} -> raise(e)
      _ -> result
    end
  end
end

Using this codes stays more or less similar to the original API with the addition of the exceptions parameter:

retry [Ecto.StaleEntryError], with: exp_backoff() |> randomize() |> expiry(5_000)  do
  # Some code that may raise Ecto.StaleEntryError
end

Do you think there's a better way to achieve this?
Was there a reason to only rescue from RuntimeError?
Would you be interested in a pull request that makes this a feature of the lib? (maybe using exceptions: [...] instead of a separate parameter)

Thanks again!

Is there a way to retry on any error?

Hey,

First of all, thanks for great library!
Today I faced a problem when I needed to retry on any error. It appears that I have to list all possibilities. Is that intentional or missing because no one needed this before?

lin_backoff is actually exponential

Sorry to ask that question, but your lin_backoff is actually exponential. Actually, it yields the same numbers as exp_backoff if factor is 2.

iex(5)> Retry.DelayStreams.exp_backoff(10) |> Enum.take(10)
[20, 40, 80, 160, 320, 640, 1280, 2560, 5120, 10240]
iex(6)> Retry.DelayStreams.lin_backoff(10, 2) |> Enum.take(10)
[20, 40, 80, 160, 320, 640, 1280, 2560, 5120, 10240]

I can open a PR for this, but what I'd suggest is to just have the lin_backoff named exp_backoff and set the factor default to 2.

Then we can implement a lin_backoff like that:

  def lin_backoff(initial_delay, factor) do
    Stream.unfold(1, fn failures ->
      next_d = initial_delay + failures * factor
      {next_d, failures + 1}
    end)
  end

What do you think?

EDIT: fixed typo (last_delay -> initial_delay)

Unexepected behavior in expire/2 & crash when combining with randomize/2

There's a bug in randomize/2 and an unexpected behavior in expire/2.

import Retry.DelayStreams

linear_backoff(200, 200)
|> expiry(1_000)
|> randomize()
# simulate a job between retries
|> Stream.map(fn d -> Process.sleep 500; d end)
|> Enum.take(50)

#[

Will crash with: (FunctionClauseError) no function clause matching in :rand.uniform_s/2 because :rand.uniform_s/2 was expecting an integer of at least 1 but got 0, because expire/2 will return a delay of 0 in some cases. Without randomize/2 this will return [200, 400, 0].

Modifying randomize/2 function to forward 0 is a solution, but I think the more important thing here is the unexpected behavior of expire/2 which can return 0 that basically means "no delay - retry immediately!".

Maybe there should be a minimal delay here (instead of 0)

remaining_t = Enum.max([end_t - now_t, 0])

Or dropping the "one last try" and ending the stream before the time budget expires
# one last try
preferred_delay > remaining_t ->
{[remaining_t], :at_end}

Unable to specify "all errors or exceptions"

This library only catching RuntimeError by default bit me. I lost a few days on this. I added a PR to improve the README.

However, my expectation was that since there was a rescue_only, that the default was everything. I don't think that assumption is unreasonable.

Would you accept a PR to change the default to be capture everything? If not, how about some way to specify everything?

In my use case, an :ets table is being accessed at startup before it has been populated. The error thrown is ArgumentError.

This does not work, but I expected it would:

defmodule Foo do
  use Retry

  def go do
  	retry with: lin_backoff(10, 2) |> cap(1_000) |> Stream.take(10) do
  	  IO.puts "once"
  	  raise ArgumentError.exception("test")
    end
  end
end

Foo.go()

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.