Skip to content

Commit ae3ec0b

Browse files
authored
Honor Sentry rate limiting globally (#953)
1 parent ffc7b51 commit ae3ec0b

File tree

14 files changed

+556
-40
lines changed

14 files changed

+556
-40
lines changed

config/config.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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,
1213
dedup_events: false,
1314
test_mode: true,
1415
traces_sample_rate: 1.0

lib/sentry/application.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ 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+
3644
children =
3745
[
3846
{Registry, keys: :unique, name: Sentry.Transport.SenderRegistry},
@@ -47,6 +55,7 @@ defmodule Sentry.Application do
4755
] ++
4856
maybe_http_client_spec ++
4957
maybe_span_storage ++
58+
maybe_rate_limiter ++
5059
[Sentry.Transport.SenderPool]
5160

5261
cache_loaded_applications()

lib/sentry/client_error.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ defmodule Sentry.ClientError do
3333
"""
3434
@type reason() ::
3535
:too_many_retries
36+
| :rate_limited
3637
| :server_error
3738
| {:invalid_json, Exception.t()}
3839
| {:request_failure, reason :: :inet.posix() | term()}
@@ -73,6 +74,10 @@ defmodule Sentry.ClientError do
7374
"Sentry responded with status 429 - Too Many Requests and the SDK exhausted the configured retries"
7475
end
7576

77+
defp format(:rate_limited) do
78+
"the event was dropped because the category is currently rate-limited by Sentry"
79+
end
80+
7681
defp format({:invalid_json, reason}) do
7782
formatted =
7883
if is_exception(reason) do

lib/sentry/client_report/sender.ex

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule Sentry.ClientReport.Sender do
1414

1515
@spec start_link([]) :: GenServer.on_start()
1616
def start_link(opts \\ []) do
17-
GenServer.start_link(__MODULE__, nil, name: Keyword.get(opts, :name, __MODULE__))
17+
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
1818
end
1919

2020
def record_discarded_events(reason, info, genserver \\ __MODULE__)
@@ -51,8 +51,13 @@ defmodule Sentry.ClientReport.Sender do
5151
## Callbacks
5252

5353
@impl true
54-
def init(nil) do
54+
def init(opts) do
5555
schedule_report()
56+
57+
if rate_limiter_table_name = Keyword.get(opts, :rate_limiter_table_name) do
58+
Process.put(:rate_limiter_table_name, rate_limiter_table_name)
59+
end
60+
5661
{:ok, _state = %{}}
5762
end
5863

lib/sentry/transport.ex

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule Sentry.Transport do
44
# This module is exclusively responsible for encoding and POSTing envelopes to Sentry.
55

66
alias Sentry.{ClientError, ClientReport, Config, Envelope, LoggerUtils}
7+
alias Sentry.Transport.RateLimiter
78

89
@default_retries [1000, 2000, 4000, 8000]
910
@sentry_version 5
@@ -47,27 +48,13 @@ defmodule Sentry.Transport do
4748
retries_left,
4849
envelope_items
4950
) do
50-
case request(client, endpoint, headers, payload) do
51+
case request(client, endpoint, headers, payload, envelope_items) do
5152
{:ok, id} ->
5253
{:ok, id}
5354

54-
# If Sentry gives us a Retry-After header, we listen to that instead of our
55-
# own retry.
56-
{:retry_after, delay_ms} when retries_left != [] ->
57-
Process.sleep(delay_ms)
58-
59-
post_envelope_with_retries(
60-
client,
61-
endpoint,
62-
headers,
63-
payload,
64-
tl(retries_left),
65-
envelope_items
66-
)
67-
68-
{:retry_after, _delay_ms} ->
55+
{:error, :rate_limited} ->
6956
ClientReport.Sender.record_discarded_events(:ratelimit_backoff, envelope_items)
70-
{:error, ClientError.new(:too_many_retries)}
57+
{:error, ClientError.new(:rate_limited)}
7158

7259
{:error, _reason} when retries_left != [] ->
7360
[sleep_interval | retries_left] = retries_left
@@ -92,29 +79,32 @@ defmodule Sentry.Transport do
9279
end
9380
end
9481

95-
defp request(client, endpoint, headers, body) do
96-
with {:ok, 200, _headers, body} <-
82+
defp check_rate_limited(envelope_items) do
83+
rate_limited? =
84+
Enum.any?(envelope_items, fn item ->
85+
category = Envelope.get_data_category(item)
86+
RateLimiter.rate_limited?(category)
87+
end)
88+
89+
if rate_limited?, do: {:error, :rate_limited}, else: :ok
90+
end
91+
92+
defp request(client, endpoint, headers, body, envelope_items) do
93+
with :ok <- check_rate_limited(envelope_items),
94+
{:ok, 200, _headers, body} <-
9795
client_post_and_validate_return_value(client, endpoint, headers, body),
9896
{:ok, json} <- Sentry.JSON.decode(body, Config.json_library()) do
9997
{:ok, Map.get(json, "id")}
10098
else
101-
{:ok, 429, headers, _body} ->
102-
delay_ms =
103-
with timeout when is_binary(timeout) <-
104-
:proplists.get_value("Retry-After", headers, nil),
105-
{delay_s, ""} <- Integer.parse(timeout) do
106-
delay_s * 1000
107-
else
108-
_ ->
109-
# https://develop.sentry.dev/sdk/rate-limiting/#stage-1-parse-response-headers
110-
60_000
111-
end
112-
113-
{:retry_after, delay_ms}
99+
{:ok, 429, _headers, _body} ->
100+
{:error, :rate_limited}
114101

115102
{:ok, status, headers, body} ->
116103
{:error, {:http, {status, headers, body}}}
117104

105+
{:error, :rate_limited} ->
106+
{:error, :rate_limited}
107+
118108
{:error, reason} ->
119109
{:error, {:request_failure, reason}}
120110
end
@@ -127,6 +117,7 @@ defmodule Sentry.Transport do
127117
{:ok, status, resp_headers, resp_body}
128118
when is_integer(status) and status in 200..599 and is_list(resp_headers) and
129119
is_binary(resp_body) ->
120+
update_rate_limits(resp_headers, status)
130121
{:ok, status, resp_headers, resp_body}
131122

132123
{:ok, status, resp_headers, resp_body} ->
@@ -137,6 +128,35 @@ defmodule Sentry.Transport do
137128
end
138129
end
139130

131+
defp update_rate_limits(headers, status) do
132+
rate_limits_header = :proplists.get_value("X-Sentry-Rate-Limits", headers, nil)
133+
134+
cond do
135+
is_binary(rate_limits_header) ->
136+
# Use categorized rate limits if present
137+
RateLimiter.update_rate_limits(rate_limits_header)
138+
139+
status == 429 ->
140+
# Use global rate limit from Retry-After if no categorized limits are present
141+
delay_seconds = get_global_delay(headers)
142+
RateLimiter.update_global_rate_limit(delay_seconds)
143+
144+
true ->
145+
:ok
146+
end
147+
end
148+
149+
defp get_global_delay(headers) do
150+
with timeout when is_binary(timeout) <- :proplists.get_value("Retry-After", headers, nil),
151+
{delay, ""} <- Integer.parse(timeout) do
152+
delay
153+
else
154+
# Per the spec, if Retry-After is missing or malformed, default to 60 seconds
155+
# https://develop.sentry.dev/sdk/rate-limiting/#stage-1-parse-response-headers
156+
_ -> 60
157+
end
158+
end
159+
140160
defp get_endpoint_and_headers do
141161
%Sentry.DSN{} = dsn = Config.dsn()
142162

0 commit comments

Comments
 (0)