diff --git a/README.md b/README.md index a97f48f..625043d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,20 @@ 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. diff --git a/lib/novu/http.ex b/lib/novu/http.ex index a9c2ad4..134a8f3 100644 --- a/lib/novu/http.ex +++ b/lib/novu/http.ex @@ -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} @@ -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 diff --git a/mix.exs b/mix.exs index 8430556..1eecc95 100644 --- a/mix.exs +++ b/mix.exs @@ -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}, diff --git a/mix.lock b/mix.lock index fcf624f..cbd4ec6 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, @@ -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"}, } diff --git a/test/novu/http_test.exs b/test/novu/http_test.exs new file mode 100644 index 0000000..6a47214 --- /dev/null +++ b/test/novu/http_test.exs @@ -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 diff --git a/test/support/api_test_helpers.ex b/test/support/api_test_helpers.ex index db4f0ee..1914bce 100644 --- a/test/support/api_test_helpers.ex +++ b/test/support/api_test_helpers.ex @@ -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