11defmodule 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
212188end
0 commit comments