Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Idempotency-Key header and exponential retry mechanism #31

Merged
merged 5 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ end
```
<!-- {x-release-please-end} -->

## Configuration

Some parameters are configurable for use during the execution of requests, typically in `config/config.exs`. The following variables can be configured:

```elixir
config :novu,
api_key: "api_key", # required: your api key
domain: "domain", # required: your domain
wait_min: 1000, # optional: the minimum time to retry a request is milliseconds (default: 1000)
wait_max: 10_000, # optional: the maximum time to retry a request is milliseconds (default: 10_000)
max_retries: 3, # optional: the amount of retries in case of responses 408/429/500/502/503/504 (default: 3)
retry_log_level: :warning # optional: the log level to emit retry logs at. Can be set to false do disable logging (default: :warning)
```

## Documentation

Documentation is generated using `ex_doc` and published to [HexDocs](https://hexdocs.pm/novu/readme.html) on new releases. This is automatic, so our only ask is ensure public functions have proper documentation and examples provided.
Expand Down
24 changes: 22 additions & 2 deletions lib/novu/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,27 @@ defmodule Novu.Http do

defp base_domain, do: Application.fetch_env!(:novu, :domain)

defp wait_min, do: Application.get_env(:novu, :wait_min, 1000)
defp wait_max, do: Application.get_env(:novu, :wait_max, 10_000)
defp max_retries, do: Application.get_env(:novu, :max_retries, 3)
defp retry_log_level, do: Application.get_env(:novu, :retry_log_level, :warning)

defp retry_delay_function(n) do
min(Integer.pow(2, n) * wait_min(), wait_max())
end

defp build_req(url),
do: Req.new(base_url: base_domain(), headers: request_headers(), url: url, user_agent: @user_agent)
do:
Req.new(
base_url: base_domain(),
headers: request_headers(),
url: url,
user_agent: @user_agent,
retry: :transient,
retry_delay: &retry_delay_function/1,
max_retries: max_retries(),
retry_log_level: retry_log_level()
)

defp handle_response(%{body: body, status: status_code}) when status_code in 200..299, do: {:ok, body}
defp handle_response(%{body: %{"errors" => errors}}), do: {:error, errors}
Expand All @@ -91,7 +110,8 @@ defmodule Novu.Http do
[
{"accept", "application/json"},
{"content-type", "application/json"},
{"authorization", "ApiKey #{api_key()}"}
{"authorization", "ApiKey #{api_key()}"},
{"idempotency-key", UUID.uuid4()}
]
end
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ defmodule Novu.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:req, "~> 0.3"},
{:req, "~> 0.4.4"},
{:elixir_uuid, "~> 1.2"},

# Development Dependencies
{:bypass, "~> 2.1", override: true, only: :test},
Expand Down
6 changes: 3 additions & 3 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"},
"doctor": {:hex, :doctor, "0.19.0", "f7974836bc85756b38b99de46cc2c6ba36741f21d8eabcbef78f6806ca6769ed", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "504f17473dc6b39618e693c5198d85e274b056b73eb4a4605431aec0f42f0023"},
"doctor": {:hex, :doctor, "0.21.0", "20ef89355c67778e206225fe74913e96141c4d001cb04efdeba1a2a9704f1ab5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "a227831daa79784eb24cdeedfa403c46a4cb7d0eab0e31232ec654314447e4e0"},
"earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"},
"elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
Expand All @@ -24,11 +25,10 @@
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
"opentelemetry_api": {:hex, :opentelemetry_api, "1.0.3", "77f9644c42340cd8b18c728cde4822ed55ae136f0d07761b78e8c54da46af93a", [:mix, :rebar3], [], "hexpm", "4293e06bd369bc004e6fad5edbb56456d891f14bd3f9f1772b18f1923e0678ea"},
"plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"},
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"req": {:hex, :req, "0.3.12", "f84c2f9e7cc71c81d7cbeacf7c61e763e53ab5f3065703792a4ab264b4f22672", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c91103d4d1c8edeba90c84e0ba223a59865b673eaab217bfd17da3aa54ab136c"},
"req": {:hex, :req, "0.4.4", "a17b6bec956c9af4f08b5d8e8a6fc6e4edf24ccc0ac7bf363a90bba7a0f0138c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2618c0493444fee927d12073afb42e9154e766b3f4448e1011f0d3d551d1a011"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
}
74 changes: 74 additions & 0 deletions test/novu/http_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
defmodule Novu.HttpTest do
use ExUnit.Case

import Novu.ApiTestHelpers

alias Novu.Http

@test_api_key "test-api-key"
@test_retry_log_level false

setup do
bypass = Bypass.open()

current_domain = Application.get_env(:novu, :domain)

Application.put_env(:novu, :domain, "http://localhost:#{bypass.port}")
Application.put_env(:novu, :api_key, @test_api_key)
Application.put_env(:novu, :retry_log_level, @test_retry_log_level)

on_exit(fn ->
Application.put_env(:novu, :domain, current_domain)
end)

{:ok, bypass: bypass}
end

describe "build_req/1" do
test "creates a request with a minimum delay", %{bypass: bypass} do
wait_min = 100
Application.put_env(:novu, :wait_min, wait_min)
Application.put_env(:novu, :max_retries, 1)

Bypass.expect(bypass, "GET", "/", fn conn ->
novu_response(conn, 500, %{data: %{error: "Internal Server Error"}})
end)

start_timer = Time.utc_now()
Http.get("/")
stop_timer = Time.utc_now()

assert_in_delta wait_min, Time.diff(stop_timer, start_timer, :millisecond), 50
end

test "creates a request with a maximum delay", %{bypass: bypass} do
wait_max = 100
Application.put_env(:novu, :wait_min, 100)
Application.put_env(:novu, :wait_max, wait_max)
Application.put_env(:novu, :max_retries, 2)

Bypass.expect(bypass, "GET", "/", fn conn ->
novu_response(conn, 500, %{data: %{error: "Internal Server Error"}})
end)

start_timer = Time.utc_now()
Http.get("/")
stop_timer = Time.utc_now()
assert_in_delta 200, Time.diff(stop_timer, start_timer, :millisecond), 50
end

test "retry based on Retry-After header", %{bypass: bypass} do
Application.put_env(:novu, :max_retries, 1)

Bypass.expect(bypass, "GET", "/", fn conn ->
novu_response(conn, 429, %{data: %{error: "Internal Server Error"}}, headers: %{"Retry-After" => "2"})
end)

start_timer = Time.utc_now()
Http.get("/")
stop_timer = Time.utc_now()

assert_in_delta 2000, Time.diff(stop_timer, start_timer, :millisecond), 50
end
end
end
10 changes: 7 additions & 3 deletions test/support/api_test_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ defmodule Novu.ApiTestHelpers do
Jason.decode!(body)
end

def novu_response(conn, status, body) do
conn
|> Plug.Conn.put_resp_header("content-type", "application/json")
def novu_response(conn, status, body, opts \\ []) do
%{"content-type" => "application/json"}
|> Map.merge(Keyword.get(opts, :headers, %{}))
|> Map.to_list()
|> List.foldl(conn, fn {header, value}, acc_conn ->
Plug.Conn.put_resp_header(acc_conn, header, value)
end)
|> Plug.Conn.resp(status, Jason.encode!(body))
end
end
Loading