From 4a56a7bba018f64d4628aef79231bdcb9c059e7f Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sun, 26 Nov 2023 15:53:09 -0800 Subject: [PATCH 1/7] add config for rate limit --- lib/inngest/function/config.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/inngest/function/config.ex b/lib/inngest/function/config.ex index 54168a5..cd909ca 100644 --- a/lib/inngest/function/config.ex +++ b/lib/inngest/function/config.ex @@ -6,6 +6,7 @@ defmodule Inngest.FnOpts do :name, :debounce, :batch_events, + :rate_limit, retries: 3 ] @@ -16,11 +17,12 @@ defmodule Inngest.FnOpts do name: binary(), retries: number() | nil, debounce: debounce() | nil, - batch_events: batch_events() | nil + batch_events: batch_events() | nil, + rate_limit: rate_limit() | nil } @type debounce() :: %{ - key: nil | binary(), + key: binary() | nil, period: binary() } @@ -29,6 +31,12 @@ defmodule Inngest.FnOpts do timeout: binary() } + @type rate_limit() :: %{ + limit: number(), + period: binary(), + key: binary() | nil + } + @doc """ Validate the debounce configuration """ From 9cede86bea895e0d4a403e296ccd279150635e1c Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sun, 26 Nov 2023 16:00:05 -0800 Subject: [PATCH 2/7] make fn opts available in docs --- lib/inngest/function/config.ex | 4 +++- mix.exs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/inngest/function/config.ex b/lib/inngest/function/config.ex index cd909ca..1e5fadd 100644 --- a/lib/inngest/function/config.ex +++ b/lib/inngest/function/config.ex @@ -1,5 +1,7 @@ defmodule Inngest.FnOpts do - @moduledoc false + @moduledoc """ + Function configuration options + """ defstruct [ :id, diff --git a/mix.exs b/mix.exs index c5a839a..f1b693e 100644 --- a/mix.exs +++ b/mix.exs @@ -49,8 +49,10 @@ defmodule Inngest.MixProject do Function: [ Inngest.Event, Inngest.Function, + Inngest.FnOpts, Inngest.Trigger, Inngest.Function.Step, + Inngest.Function.Context, Inngest.Function.Input ], Router: [ From 0739d4b811771318e4a41d63ce79b08f1b6a992e Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sun, 26 Nov 2023 16:07:52 -0800 Subject: [PATCH 3/7] add rate limit config validation for success cases --- lib/inngest/function.ex | 5 +++++ lib/inngest/function/config.ex | 16 +++++++++++++++- test/inngest/function/config_test.exs | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/inngest/function.ex b/lib/inngest/function.ex index 5accd17..761ed6c 100644 --- a/lib/inngest/function.ex +++ b/lib/inngest/function.ex @@ -182,6 +182,11 @@ defmodule Inngest.Function do |> Inngest.FnOpts.validate_batch_events(config) end + defp maybe_rate_limit(config) do + fn_opts() + |> Inngest.FnOpts.validate_rate_limit(config) + end + defp fn_opts() do case __MODULE__.__info__(:attributes) |> Keyword.get(:func) |> List.first() do nil -> %Inngest.FnOpts{} diff --git a/lib/inngest/function/config.ex b/lib/inngest/function/config.ex index 1e5fadd..90dd948 100644 --- a/lib/inngest/function/config.ex +++ b/lib/inngest/function/config.ex @@ -106,8 +106,22 @@ defmodule Inngest.FnOpts do end end - batch = batch |> Map.put(:maxSize, max_size) |> Map.drop([:max_size]) + batch = %{maxSize: max_size, timeout: timeout} Map.put(config, :batchEvents, batch) end end + + @doc """ + Validate the rate limit config + """ + @spec validate_rate_limit(t(), map()) :: map() + def validate_rate_limit(fnopts, config) do + case fnopts |> Map.get(:rate_limit) do + nil -> + config + + rate_limit -> + Map.put(config, :rateLimit, rate_limit) + end + end end diff --git a/test/inngest/function/config_test.exs b/test/inngest/function/config_test.exs index 91cd447..83c9213 100644 --- a/test/inngest/function/config_test.exs +++ b/test/inngest/function/config_test.exs @@ -114,4 +114,24 @@ defmodule Inngest.FnOptsTest do end end end + + describe "validate_rate_limit/2" do + @fn_opts %FnOpts{ + id: "foobar", + name: "Foobar", + rate_limit: %{ + limit: 10, + period: "5s" + } + } + + test "should succeed with valid config" do + assert %{ + rateLimit: %{ + limit: 10, + period: "5s" + } + } = FnOpts.validate_rate_limit(@fn_opts, @config) + end + end end From 790ec5375aa96254ec3c66835baea15d0864f9d3 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sun, 26 Nov 2023 16:11:20 -0800 Subject: [PATCH 4/7] update error names and add rate limit config error --- lib/inngest/error.ex | 10 +++++++--- lib/inngest/function/config.ex | 17 ++++++++++------- test/inngest/function/config_test.exs | 22 ++++++++++++---------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/inngest/error.ex b/lib/inngest/error.ex index 36ccf81..4e06831 100644 --- a/lib/inngest/error.ex +++ b/lib/inngest/error.ex @@ -6,10 +6,14 @@ defmodule Inngest.RetryAfterError do defexception [:message, seconds: 3] end -defmodule Inngest.InvalidDebounceConfigError do - defexception message: "a 'period' must be set for debounce" +defmodule Inngest.DebounceConfigError do + defexception [:message] +end + +defmodule Inngest.BatchEventConfigError do + defexception [:message] end -defmodule Inngest.InvalidBatchEventConfigError do +defmodule Inngest.RateLimitConfigError do defexception [:message] end diff --git a/lib/inngest/function/config.ex b/lib/inngest/function/config.ex index 90dd948..d6339f0 100644 --- a/lib/inngest/function/config.ex +++ b/lib/inngest/function/config.ex @@ -52,17 +52,17 @@ defmodule Inngest.FnOpts do period = Map.get(debounce, :period) if is_nil(period) do - raise Inngest.InvalidDebounceConfigError + raise Inngest.DebounceConfigError, message: "a 'period' must be set for debounce" end case Util.parse_duration(period) do {:error, error} -> - raise Inngest.InvalidDebounceConfigError, message: error + raise Inngest.DebounceConfigError, message: error {:ok, seconds} -> # credo:disable-for-next-line if seconds > 7 * Util.day_in_seconds() do - raise Inngest.InvalidDebounceConfigError, + raise Inngest.DebounceConfigError, message: "cannot specify period for more than 7 days" end end @@ -85,23 +85,23 @@ defmodule Inngest.FnOpts do timeout = Map.get(batch, :timeout) if is_nil(max_size) do - raise Inngest.InvalidBatchEventConfigError, + raise Inngest.BatchEventConfigError, message: "'max_size' must be set for batch_events" end if is_nil(timeout) do - raise Inngest.InvalidBatchEventConfigError, + raise Inngest.BatchEventConfigError, message: "'timeout' must be set for batch_events" end case Util.parse_duration(timeout) do {:error, error} -> - raise Inngest.InvalidBatchEventConfigError, message: error + raise Inngest.BatchEventConfigError, message: error {:ok, seconds} -> # credo:disable-for-next-line if seconds < 1 || seconds > 60 do - raise Inngest.InvalidBatchEventConfigError, + raise Inngest.BatchEventConfigError, message: "'timeout' duration set to '#{timeout}', needs to be 1s - 60s" end end @@ -121,6 +121,9 @@ defmodule Inngest.FnOpts do config rate_limit -> + limit = Map.get(rate_limit, :limit) + period = Map.get(rate_limit, :period) + Map.put(config, :rateLimit, rate_limit) end end diff --git a/test/inngest/function/config_test.exs b/test/inngest/function/config_test.exs index 83c9213..11900ad 100644 --- a/test/inngest/function/config_test.exs +++ b/test/inngest/function/config_test.exs @@ -29,19 +29,21 @@ defmodule Inngest.FnOptsTest do assert %{debounce: %{period: "5s"}} = FnOpts.validate_debounce(@fn_opts, @config) end - ## Invalid configs + ## configs test "should raise when period is missing" do opts = drop_at(@fn_opts, [:debounce, :period]) - assert_raise Inngest.InvalidDebounceConfigError, fn -> - FnOpts.validate_debounce(opts, @config) - end + assert_raise Inngest.DebounceConfigError, + "a 'period' must be set for debounce", + fn -> + FnOpts.validate_debounce(opts, @config) + end end test "should raise with invalid period" do opts = update_at(@fn_opts, [:debounce, :period], "yolo") - assert_raise Inngest.InvalidDebounceConfigError, fn -> + assert_raise Inngest.DebounceConfigError, "invalid duration: 'yolo'", fn -> FnOpts.validate_debounce(opts, @config) end end @@ -49,7 +51,7 @@ defmodule Inngest.FnOptsTest do test "should raise with period longer than 7 days" do opts = update_at(@fn_opts, [:debounce, :period], "8d") - assert_raise Inngest.InvalidDebounceConfigError, fn -> + assert_raise Inngest.DebounceConfigError, fn -> FnOpts.validate_debounce(opts, @config) end end @@ -77,7 +79,7 @@ defmodule Inngest.FnOptsTest do test "should raise if max_size is missing" do opts = drop_at(@fn_opts, [:batch_events, :max_size]) - assert_raise Inngest.InvalidBatchEventConfigError, + assert_raise Inngest.BatchEventConfigError, "'max_size' must be set for batch_events", fn -> FnOpts.validate_batch_events(opts, @config) @@ -87,7 +89,7 @@ defmodule Inngest.FnOptsTest do test "should raise if timeout is missing" do opts = drop_at(@fn_opts, [:batch_events, :timeout]) - assert_raise Inngest.InvalidBatchEventConfigError, + assert_raise Inngest.BatchEventConfigError, "'timeout' must be set for batch_events", fn -> FnOpts.validate_batch_events(opts, @config) @@ -97,7 +99,7 @@ defmodule Inngest.FnOptsTest do test "should raise if timeout is invalid" do opts = update_at(@fn_opts, [:batch_events, :timeout], "hello") - assert_raise Inngest.InvalidBatchEventConfigError, + assert_raise Inngest.BatchEventConfigError, "invalid duration: 'hello'", fn -> FnOpts.validate_batch_events(opts, @config) @@ -107,7 +109,7 @@ defmodule Inngest.FnOptsTest do test "should raise if timeout is out of range" do opts = update_at(@fn_opts, [:batch_events, :timeout], "2m") - assert_raise Inngest.InvalidBatchEventConfigError, + assert_raise Inngest.BatchEventConfigError, "'timeout' duration set to '2m', needs to be 1s - 60s", fn -> FnOpts.validate_batch_events(opts, @config) From fd73a970974c3175b86f8dd1087da72db1129c37 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sun, 26 Nov 2023 16:15:28 -0800 Subject: [PATCH 5/7] add error raising for missing required attributes --- lib/inngest/function/config.ex | 16 ++++++++-------- test/inngest/function/config_test.exs | 26 +++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/inngest/function/config.ex b/lib/inngest/function/config.ex index d6339f0..64152c4 100644 --- a/lib/inngest/function/config.ex +++ b/lib/inngest/function/config.ex @@ -52,7 +52,7 @@ defmodule Inngest.FnOpts do period = Map.get(debounce, :period) if is_nil(period) do - raise Inngest.DebounceConfigError, message: "a 'period' must be set for debounce" + raise Inngest.DebounceConfigError, message: "'period' must be set for debounce" end case Util.parse_duration(period) do @@ -84,14 +84,9 @@ defmodule Inngest.FnOpts do max_size = Map.get(batch, :max_size) timeout = Map.get(batch, :timeout) - if is_nil(max_size) do + if is_nil(max_size) || is_nil(timeout) do raise Inngest.BatchEventConfigError, - message: "'max_size' must be set for batch_events" - end - - if is_nil(timeout) do - raise Inngest.BatchEventConfigError, - message: "'timeout' must be set for batch_events" + message: "'max_size' and 'timeout' must be set for batch_events" end case Util.parse_duration(timeout) do @@ -124,6 +119,11 @@ defmodule Inngest.FnOpts do limit = Map.get(rate_limit, :limit) period = Map.get(rate_limit, :period) + if is_nil(limit) || is_nil(period) do + raise Inngest.RateLimitConfigError, + message: "'limit' and 'period' must be set for rate_limit" + end + Map.put(config, :rateLimit, rate_limit) end end diff --git a/test/inngest/function/config_test.exs b/test/inngest/function/config_test.exs index 11900ad..8ccb64a 100644 --- a/test/inngest/function/config_test.exs +++ b/test/inngest/function/config_test.exs @@ -34,7 +34,7 @@ defmodule Inngest.FnOptsTest do opts = drop_at(@fn_opts, [:debounce, :period]) assert_raise Inngest.DebounceConfigError, - "a 'period' must be set for debounce", + "'period' must be set for debounce", fn -> FnOpts.validate_debounce(opts, @config) end @@ -80,7 +80,7 @@ defmodule Inngest.FnOptsTest do opts = drop_at(@fn_opts, [:batch_events, :max_size]) assert_raise Inngest.BatchEventConfigError, - "'max_size' must be set for batch_events", + "'max_size' and 'timeout' must be set for batch_events", fn -> FnOpts.validate_batch_events(opts, @config) end @@ -90,7 +90,7 @@ defmodule Inngest.FnOptsTest do opts = drop_at(@fn_opts, [:batch_events, :timeout]) assert_raise Inngest.BatchEventConfigError, - "'timeout' must be set for batch_events", + "'max_size' and 'timeout' must be set for batch_events", fn -> FnOpts.validate_batch_events(opts, @config) end @@ -135,5 +135,25 @@ defmodule Inngest.FnOptsTest do } } = FnOpts.validate_rate_limit(@fn_opts, @config) end + + test "should raise when limit is missing" do + opts = drop_at(@fn_opts, [:rate_limit, :limit]) + + assert_raise Inngest.RateLimitConfigError, + "'limit' and 'period' must be set for rate_limit", + fn -> + FnOpts.validate_rate_limit(opts, @config) + end + end + + test "should raise when period is missing" do + opts = drop_at(@fn_opts, [:rate_limit, :period]) + + assert_raise Inngest.RateLimitConfigError, + "'limit' and 'period' must be set for rate_limit", + fn -> + FnOpts.validate_rate_limit(opts, @config) + end + end end end From d785a66e5d971af1d99a7fde1295dc259245526c Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sun, 26 Nov 2023 16:17:43 -0800 Subject: [PATCH 6/7] add checking for out of range period --- lib/inngest/function/config.ex | 12 ++++++++++++ test/inngest/function/config_test.exs | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/inngest/function/config.ex b/lib/inngest/function/config.ex index 64152c4..fedf0e0 100644 --- a/lib/inngest/function/config.ex +++ b/lib/inngest/function/config.ex @@ -124,6 +124,18 @@ defmodule Inngest.FnOpts do message: "'limit' and 'period' must be set for rate_limit" end + case Util.parse_duration(period) do + {:error, error} -> + raise Inngest.RateLimitConfigError, message: error + + {:ok, seconds} -> + # credo:disable-for-next-line + if seconds < 1 || seconds > 60 do + raise Inngest.RateLimitConfigError, + message: "'period' duration set to '#{period}', needs to be 1s - 60s" + end + end + Map.put(config, :rateLimit, rate_limit) end end diff --git a/test/inngest/function/config_test.exs b/test/inngest/function/config_test.exs index 8ccb64a..628ba80 100644 --- a/test/inngest/function/config_test.exs +++ b/test/inngest/function/config_test.exs @@ -155,5 +155,15 @@ defmodule Inngest.FnOptsTest do FnOpts.validate_rate_limit(opts, @config) end end + + test "should raise if timeout is out of range" do + opts = update_at(@fn_opts, [:rate_limit, :period], "2m") + + assert_raise Inngest.RateLimitConfigError, + "'period' duration set to '2m', needs to be 1s - 60s", + fn -> + FnOpts.validate_rate_limit(opts, @config) + end + end end end From efda5dd48fba73ba54911da81b5a0e89f50c7a1d Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Sun, 26 Nov 2023 16:41:20 -0800 Subject: [PATCH 7/7] add integration test for rate limit --- lib/inngest/function.ex | 1 + .../function/cases/batch_events_test.exs | 5 ++- .../function/cases/rate_limit_test.exs | 36 +++++++++++++++++++ test/support/cases/rate_limit_fn.ex | 21 +++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 test/inngest/function/cases/rate_limit_test.exs create mode 100644 test/support/cases/rate_limit_fn.ex diff --git a/lib/inngest/function.ex b/lib/inngest/function.ex index 761ed6c..5b40e24 100644 --- a/lib/inngest/function.ex +++ b/lib/inngest/function.ex @@ -167,6 +167,7 @@ defmodule Inngest.Function do } |> maybe_debounce() |> maybe_batch_events() + |> maybe_rate_limit() ] ++ handler end diff --git a/test/inngest/function/cases/batch_events_test.exs b/test/inngest/function/cases/batch_events_test.exs index 99d3020..ad3954e 100644 --- a/test/inngest/function/cases/batch_events_test.exs +++ b/test/inngest/function/cases/batch_events_test.exs @@ -1,8 +1,7 @@ defmodule Inngest.Function.Cases.BatchEventsTest do use ExUnit.Case, async: true - alias Inngest.Test.DevServer - import Inngest.Test.Helper - # TODO: Add test after moving batching logic to OSS + # alias Inngest.Test.DevServer + # import Inngest.Test.Helper end diff --git a/test/inngest/function/cases/rate_limit_test.exs b/test/inngest/function/cases/rate_limit_test.exs new file mode 100644 index 0000000..f409223 --- /dev/null +++ b/test/inngest/function/cases/rate_limit_test.exs @@ -0,0 +1,36 @@ +defmodule Inngest.Function.Cases.RateLimitTest do + use ExUnit.Case, async: true + + alias Inngest.Test.DevServer + import Inngest.Test.Helper + + @default_sleep 10_000 + + @tag :integration + test "should only run 2 out of 10" do + event_ids = Enum.map(1..10, fn _ -> send_test_event("test/plug.ratelimit") end) + + Process.sleep(@default_sleep) + + fn_runs = + event_ids + |> Enum.map(fn id -> + {:ok, %{"data" => data}} = DevServer.run_ids(id) + + if Enum.count(data) == 1 do + assert [ + %{ + "output" => "Rate Limited", + "status" => "Completed", + "run_id" => run_id + } + ] = data + else + nil + end + end) + |> Enum.filter(&(!is_nil(&1))) + + assert Enum.count(fn_runs) <= 2 + end +end diff --git a/test/support/cases/rate_limit_fn.ex b/test/support/cases/rate_limit_fn.ex new file mode 100644 index 0000000..77b7165 --- /dev/null +++ b/test/support/cases/rate_limit_fn.ex @@ -0,0 +1,21 @@ +defmodule Inngest.Test.Case.RateLimitFn do + @moduledoc false + + use Inngest.Function + alias Inngest.{FnOpts, Trigger} + + @func %FnOpts{ + id: "ratelimit-fn", + name: "RateLimit Function", + rate_limit: %{ + limit: 2, + period: "5s" + } + } + @trigger %Trigger{event: "test/plug.ratelimit"} + + @impl true + def exec(_ctx, _args) do + {:ok, "Rate Limited"} + end +end