Skip to content

Commit cf5ad4f

Browse files
authored
chore: shave off some test code around the new rate limiter (#962)
1 parent ae3ec0b commit cf5ad4f

File tree

8 files changed

+80
-113
lines changed

8 files changed

+80
-113
lines changed

.dialyzer_ignore.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
[
2+
{"test/support/case.ex"},
23
{"test/support/example_plug_application.ex"},
34
{"test/support/test_helpers.ex"},
45
{"lib/sentry/opentelemetry/sampler.ex", :pattern_match, 1}

config/config.exs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ if config_env() == :test do
99
hackney_opts: [recv_timeout: 50, pool: :sentry_pool],
1010
send_result: :sync,
1111
send_max_attempts: 1,
12-
start_rate_limiter: false,
1312
dedup_events: false,
1413
test_mode: true,
1514
traces_sample_rate: 1.0

lib/sentry/application.ex

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,6 @@ defmodule Sentry.Application do
3333
[]
3434
end
3535

36-
# Don't start RateLimiter in test environment - tests start their own instances
37-
maybe_rate_limiter =
38-
if Application.get_env(:sentry, :start_rate_limiter, true) == false do
39-
[]
40-
else
41-
[Sentry.Transport.RateLimiter]
42-
end
43-
4436
children =
4537
[
4638
{Registry, keys: :unique, name: Sentry.Transport.SenderRegistry},
@@ -55,7 +47,7 @@ defmodule Sentry.Application do
5547
] ++
5648
maybe_http_client_spec ++
5749
maybe_span_storage ++
58-
maybe_rate_limiter ++
50+
maybe_rate_limiter() ++
5951
[Sentry.Transport.SenderPool]
6052

6153
cache_loaded_applications()
@@ -106,4 +98,12 @@ defmodule Sentry.Application do
10698
end
10799
end)
108100
end
101+
102+
# In tests, we do not run a global rate limiter; tests start their own when
103+
# they need it.
104+
if Mix.env() == :test do
105+
defp maybe_rate_limiter, do: []
106+
else
107+
defp maybe_rate_limiter, do: [Sentry.Transport.RateLimiter]
108+
end
109109
end
Lines changed: 62 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
defmodule Sentry.Transport.RateLimiter do
22
@moduledoc false
3+
34
# Tracks rate limits per category from Sentry API responses.
45
# Uses an ETS table to store expiry timestamps for rate-limited categories.
56
# When Sentry returns a 429 response with rate limit headers, this module
67
# stores the expiry time per category, allowing other parts of the SDK to
78
# check if an event should be dropped before sending.
89
#
10+
# The ETS table stores tuples with these elements:
11+
#
12+
# 1. Category (String.t/0 | :global): the category being rate limited.
13+
# 2. Expiry timestamp (Unix timestamp in seconds): time at which the rate-limit
14+
# entry expires and can be pruned).
15+
#
916
# See https://develop.sentry.dev/sdk/expected-features/rate-limiting/
17+
#
18+
# For testing, we use the trick of determining the name of this GenServer
19+
# and consequently the ETS table it uses) based on the Mix environment (at compile
20+
# time, so no impact on performance). If we're in the :test environment, we require
21+
# that there's a table name for this in the process dictionary. In normal circumstances
22+
# we use __MODULE__ instead.
1023

1124
use GenServer
1225

@@ -22,20 +35,18 @@ defmodule Sentry.Transport.RateLimiter do
2235
## Options
2336
2437
* `:name` - The name to register the GenServer under. Defaults to `__MODULE__`.
25-
* `:table_name` - The name for the ETS table. Defaults to `__MODULE__`.
2638
2739
"""
2840
@spec start_link(keyword()) :: GenServer.on_start()
29-
def start_link(opts \\ []) do
41+
def start_link(opts) when is_list(opts) do
3042
name = Keyword.get(opts, :name, __MODULE__)
31-
GenServer.start_link(__MODULE__, opts, name: name)
43+
GenServer.start_link(__MODULE__, _table_name = name, name: name)
3244
end
3345

3446
## GenServer Callbacks
3547

3648
@impl true
37-
def init(opts) do
38-
table_name = Keyword.get(opts, :table_name, __MODULE__)
49+
def init(table_name) do
3950
_table = :ets.new(table_name, [:named_table, :public, :set, read_concurrency: true])
4051
schedule_sweep()
4152
{:ok, %__MODULE__{table_name: table_name}}
@@ -45,7 +56,8 @@ defmodule Sentry.Transport.RateLimiter do
4556
def handle_info(:sweep, %__MODULE__{table_name: table_name} = state) do
4657
now = System.system_time(:second)
4758

48-
# Match spec: select entries where expiry (position 2) < now
59+
# This match spec elects entries where expiry is in the past.
60+
# Remember, tuples are {category, expiry_time}.
4961
match_spec = [{{:"$1", :"$2"}, [{:<, :"$2", now}], [true]}]
5062

5163
:ets.select_delete(table_name, match_spec)
@@ -62,11 +74,6 @@ defmodule Sentry.Transport.RateLimiter do
6274
Returns `true` if the category is rate-limited (either specifically or via
6375
a global rate limit), `false` otherwise.
6476
65-
## Options
66-
67-
* `:table_name` - The ETS table name. Falls back to the `:rate_limiter_table_name`
68-
value in the process dictionary, then to `__MODULE__`.
69-
7077
## Examples
7178
7279
iex> RateLimiter.rate_limited?("error")
@@ -77,136 +84,105 @@ defmodule Sentry.Transport.RateLimiter do
7784
true
7885
7986
"""
80-
@spec rate_limited?(String.t(), keyword()) :: boolean()
81-
def rate_limited?(category, opts \\ []) do
82-
table_name = get_table_name(opts)
87+
@spec rate_limited?(String.t()) :: boolean()
88+
def rate_limited?(category) when is_binary(category) do
8389
now = System.system_time(:second)
84-
check_rate_limited(table_name, category, now) or check_rate_limited(table_name, :global, now)
90+
rate_limited?(category, now) or rate_limited?(:global, now)
8591
end
8692

8793
@doc """
88-
Updates global rate limit from a Retry-After header value.
89-
90-
This is a fallback for when X-Sentry-Rate-Limits is not present.
91-
Stores a global rate limit (:global key) that affects all categories.
92-
93-
## Options
94+
Updates global rate limit from a `Retry-After` header value.
9495
95-
* `:table_name` - The ETS table name. Falls back to the `:rate_limiter_table_name`
96-
value in the process dictionary, then to `__MODULE__`.
96+
This is a fallback for when `X-Sentry-Rate-Limits` is not present.
97+
Stores a global rate limit (`:global` key) that affects all categories.
98+
The `Retry-After` header is parsed before getting here, so we get a clean
99+
integer value here.
97100
98101
## Examples
99102
100103
iex> RateLimiter.update_global_rate_limit(60)
101104
:ok
102105
103106
"""
104-
@spec update_global_rate_limit(pos_integer(), keyword()) :: :ok
105-
def update_global_rate_limit(retry_after_seconds, opts \\ [])
106-
when is_integer(retry_after_seconds) do
107-
table_name = get_table_name(opts)
108-
now = System.system_time(:second)
109-
expiry = now + retry_after_seconds
110-
:ets.insert(table_name, {:global, expiry})
107+
@spec update_global_rate_limit(pos_integer()) :: :ok
108+
def update_global_rate_limit(retry_after_seconds) when is_integer(retry_after_seconds) do
109+
expiry = System.system_time(:second) + retry_after_seconds
110+
:ets.insert(name(), {:global, expiry})
111111
:ok
112112
end
113113

114114
@doc """
115-
Updates rate limits from the X-Sentry-Rate-Limits header.
115+
Updates rate limits from the `X-Sentry-Rate-Limits` header value.
116116
117117
Parses the header value and stores expiry timestamps for each category.
118118
Returns `:ok` regardless of parsing success.
119119
120-
## Options
121-
122-
* `:table_name` - The ETS table name. Falls back to the `:rate_limiter_table_name`
123-
value in the process dictionary, then to `__MODULE__`.
124-
125120
## Examples
126121
127122
iex> RateLimiter.update_rate_limits("60:error;transaction")
128123
:ok
129124
130125
"""
131-
@spec update_rate_limits(String.t(), keyword()) :: :ok
132-
def update_rate_limits(rate_limits_header, opts \\ []) do
133-
table_name = get_table_name(opts)
126+
@spec update_rate_limits(String.t()) :: :ok
127+
def update_rate_limits(rate_limits_header) when is_binary(rate_limits_header) do
134128
now = System.system_time(:second)
135-
rate_limits = parse_rate_limits_header(rate_limits_header)
136129

137-
Enum.each(rate_limits, fn {category, retry_after_seconds} ->
138-
expiry = now + retry_after_seconds
139-
:ets.insert(table_name, {category, expiry})
140-
end)
130+
rate_limits_header
131+
|> parse_rate_limits_header()
132+
|> Enum.map(fn {category, retry_after_seconds} -> {category, now + retry_after_seconds} end)
133+
|> then(&:ets.insert(name(), &1))
141134

142135
:ok
143136
end
144137

145138
## Private Helpers
146139

147-
# Get the table name with the following hierarchy:
148-
# 1. Value passed in opts[:table_name]
149-
# 2. Value from process dictionary (:rate_limiter_table_name)
150-
# 3. Default module name
151-
@spec get_table_name(keyword()) :: atom()
152-
defp get_table_name(opts) do
153-
case Keyword.fetch(opts, :table_name) do
154-
{:ok, table_name} -> table_name
155-
:error -> Process.get(:rate_limiter_table_name, __MODULE__)
156-
end
157-
end
158-
159-
@spec check_rate_limited(atom(), String.t() | :global, integer()) :: boolean()
160-
defp check_rate_limited(table_name, category, time) do
161-
case :ets.lookup(table_name, category) do
162-
[{^category, expiry}] when expiry > time -> true
163-
_ -> false
140+
defp rate_limited?(category, now) do
141+
case :ets.lookup(name(), category) do
142+
[{^category, expiry}] when expiry > now -> true
143+
_other -> false
164144
end
165145
end
166146

167147
# Parse X-Sentry-Rate-Limits header
168148
# Format: "60:error;transaction:key, 2700:default:organization"
169149
# Returns: [{category, retry_after_seconds}, ...]
170-
@spec parse_rate_limits_header(String.t()) :: [{String.t() | :global, integer()}]
171150
defp parse_rate_limits_header(header_value) do
172151
header_value
173152
|> String.split(",")
174-
|> Enum.flat_map(&parse_quota_limit/1)
153+
|> Enum.flat_map(fn quota_limit -> quota_limit |> String.trim() |> parse_quota_limit() end)
175154
end
176155

177-
@spec parse_quota_limit(String.t()) :: [{String.t() | :global, integer()}]
156+
# Parses a single quota limit, like: "60:error;transaction:key"
178157
defp parse_quota_limit(quota_limit_str) do
179-
{retry_after_str, rest} =
180-
quota_limit_str |> String.trim() |> String.split(":") |> List.pop_at(0)
181-
182-
case parse_retry_after(retry_after_str) do
183-
{:ok, retry_after} -> parse_categories(rest, retry_after)
184-
:error -> []
158+
with [retry_after_str | rest] <- String.split(quota_limit_str, ":"),
159+
{retry_after, ""} <- Integer.parse(retry_after_str) do
160+
rest
161+
|> parse_categories()
162+
|> Enum.map(&{&1, retry_after})
163+
else
164+
_other -> []
185165
end
186166
end
187167

188-
@spec parse_retry_after(String.t() | nil) :: {:ok, integer()} | :error
189-
defp parse_retry_after(nil), do: :error
190-
191-
defp parse_retry_after(retry_after_str) do
192-
case Integer.parse(retry_after_str) do
193-
{retry_after, ""} -> {:ok, retry_after}
194-
_ -> :error
168+
defp parse_categories([categories_str | _rest]) do
169+
case String.split(categories_str, ";", trim: true) do
170+
[] -> [:global]
171+
categories -> categories
195172
end
196173
end
197174

198-
@spec parse_categories([String.t()], integer()) :: [{String.t() | :global, integer()}]
199-
defp parse_categories([categories_str | _rest], retry_after) do
200-
case String.split(categories_str, ";") do
201-
[""] -> [{:global, retry_after}]
202-
categories -> Enum.map(categories, fn cat -> {cat, retry_after} end)
203-
end
175+
defp parse_categories([]) do
176+
[:global]
204177
end
205178

206-
defp parse_categories(_, _), do: []
207-
208-
@spec schedule_sweep() :: reference()
209179
defp schedule_sweep do
210180
Process.send_after(self(), :sweep, @default_sweep_interval_ms)
211181
end
182+
183+
if Mix.env() == :test do
184+
defp name, do: Process.get(:rate_limiter_table_name, __MODULE__)
185+
else
186+
defp name, do: __MODULE__
187+
end
212188
end

test/sentry/client_test.exs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,6 @@ defmodule Sentry.ClientTest do
375375
end) =~ "the Sentry SDK could not encode the event to JSON: :im_just_bad"
376376
end
377377

378-
@tag :manual_rate_limiting
379378
test "uses the async sender pool when :result is :none", %{bypass: bypass} do
380379
start_supervised!(Sentry.Transport.RateLimiter)
381380
test_pid = self()

test/sentry/logger_handler_test.exs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -646,8 +646,7 @@ defmodule Sentry.LoggerHandlerTest do
646646
sync_threshold: nil,
647647
capture_log_messages: true
648648
},
649-
send_request: true,
650-
manual_rate_limiting: true
649+
send_request: true
651650
test "discards logged messages", %{sender_ref: ref} do
652651
# manually starting the rate limiter with the default name since we don't
653652
# have the ability to inject it into the logger backend

test/sentry/transport/rate_limiter_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ defmodule Sentry.Transport.RateLimiterTest do
33

44
alias Sentry.Transport.RateLimiter
55

6-
defp table_name, do: Process.get(:rate_limiter_table_name)
7-
86
describe "parse_rate_limits_header/1" do
97
test "parses single category limit" do
108
# X-Sentry-Rate-Limits: 60:error
@@ -135,4 +133,6 @@ defmodule Sentry.Transport.RateLimiterTest do
135133
assert RateLimiter.rate_limited?("error") == true
136134
end
137135
end
136+
137+
defp table_name, do: Process.get(:rate_limiter_table_name)
138138
end

test/support/case.ex

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ defmodule Sentry.Case do
1414
assert config_before == all_config()
1515
end)
1616

17-
# Start a fresh RateLimiter for each test with unique names for isolation
18-
setup_rate_limiter(context)
17+
# Start a fresh RateLimiter for each test with unique names for isolation.
18+
setup_rate_limiter()
1919

2020
case context[:span_storage] do
2121
nil -> :ok
@@ -24,17 +24,10 @@ defmodule Sentry.Case do
2424
end
2525
end
2626

27-
defp setup_rate_limiter(context) do
28-
if context[:manual_rate_limiting] != true do
29-
uid = System.unique_integer([:positive])
30-
server_name = :"test_rate_limiter_#{uid}"
31-
table_name = :"test_rate_limiter_table_#{uid}"
32-
33-
opts = [name: server_name, table_name: table_name]
34-
start_supervised!({Sentry.Transport.RateLimiter, opts})
35-
27+
defp setup_rate_limiter do
28+
table_name = :"test_rate_limiter_#{System.unique_integer([:positive])}"
3629
Process.put(:rate_limiter_table_name, table_name)
37-
end
30+
start_supervised!({Sentry.Transport.RateLimiter, name: table_name}, id: table_name)
3831
end
3932

4033
defp setup_span_storage(opts) do

0 commit comments

Comments
 (0)