From de5a8208370a992b5cf9ed23f5ee24b01dd7756d Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 15 Jul 2026 14:18:11 +0000 Subject: [PATCH 1/3] fix: handle lower-cased rate-limit headers --- lib/sentry/transport.ex | 16 +++++++- .../telemetry_processor_integration_test.exs | 4 +- test/sentry/transport_test.exs | 21 ++++++++++ test/sentry_test.exs | 41 +++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/lib/sentry/transport.ex b/lib/sentry/transport.ex index f7f104409..07ad282f7 100644 --- a/lib/sentry/transport.ex +++ b/lib/sentry/transport.ex @@ -136,7 +136,7 @@ defmodule Sentry.Transport do end defp update_rate_limits(headers, status) do - rate_limits_header = :proplists.get_value("X-Sentry-Rate-Limits", headers, nil) + rate_limits_header = get_header(headers, "X-Sentry-Rate-Limits") cond do is_binary(rate_limits_header) -> @@ -154,7 +154,7 @@ defmodule Sentry.Transport do end defp get_global_delay(headers) do - with timeout when is_binary(timeout) <- :proplists.get_value("Retry-After", headers, nil), + with timeout when is_binary(timeout) <- get_header(headers, "Retry-After"), {delay, ""} <- Integer.parse(timeout) do delay else @@ -164,6 +164,18 @@ defmodule Sentry.Transport do end end + defp get_header(headers, name) do + downcased_name = String.downcase(name) + + Enum.find_value(headers, fn + {header_name, value} when is_binary(header_name) -> + if String.downcase(header_name) == downcased_name, do: value + + _other -> + nil + end) + end + defp get_endpoint_and_headers do %Sentry.DSN{} = dsn = Config.dsn() diff --git a/test/sentry/telemetry_processor_integration_test.exs b/test/sentry/telemetry_processor_integration_test.exs index 6cab2394b..61be3b53d 100644 --- a/test/sentry/telemetry_processor_integration_test.exs +++ b/test/sentry/telemetry_processor_integration_test.exs @@ -311,9 +311,7 @@ defmodule Sentry.TelemetryProcessorIntegrationTest do ref = make_ref() request_count = :counters.new(1, []) - # Use HackneyClient because FinchClient (Mint) lowercases response headers, - # which prevents the transport from matching "X-Sentry-Rate-Limits". - put_test_config(client: Sentry.HackneyClient) + put_test_config(client: Sentry.FinchClient) Bypass.expect(ctx.bypass, "POST", "/api/1/envelope/", fn conn -> count = :counters.get(request_count, 1) diff --git a/test/sentry/transport_test.exs b/test/sentry/transport_test.exs index 9b222449b..064bf2b14 100644 --- a/test/sentry/transport_test.exs +++ b/test/sentry/transport_test.exs @@ -267,6 +267,27 @@ defmodule Sentry.TransportTest do assert_received {:request, ^ref} end + test "reads the Retry-After response header case-insensitively", %{bypass: bypass} do + envelope = Envelope.from_event(Event.create_event(message: "Hello")) + + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> + conn + |> Plug.Conn.put_resp_header("Retry-After", "0") + |> Plug.Conn.resp(429, ~s<{}>) + end) + + assert :rate_limited = + error(fn -> + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = []) + end) + + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> + Plug.Conn.resp(conn, 200, ~s<{"id":"123"}>) + end) + + assert {:ok, "123"} = Transport.encode_and_post_envelope(envelope, FinchClient) + end + test "fails immediately when Sentry replies with 413 (envelope too large)", %{bypass: bypass} do envelope = Envelope.from_event(Event.create_event(message: "Hello")) test_pid = self() diff --git a/test/sentry_test.exs b/test/sentry_test.exs index 964ad8bff..e2179006a 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -248,6 +248,47 @@ defmodule SentryTest do assert_sentry_report(:transaction, transaction: "test-transaction") end + test "does not apply categorized error rate limits to transactions", %{ + bypass: bypass, + transaction: transaction + } do + test_pid = self() + ref = make_ref() + request_count = :counters.new(1, []) + put_test_config(client: Sentry.FinchClient) + + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> + request_number = :counters.get(request_count, 1) + :counters.add(request_count, 1, 1) + + if request_number == 0 do + conn + |> Plug.Conn.put_resp_header("X-Sentry-Rate-Limits", "60:error:organization") + |> Plug.Conn.resp(429, ~s<{"error": "Rate limited"}>) + else + {:ok, body, conn} = Plug.Conn.read_body(conn) + + if body =~ ~s("type":"transaction") do + send(test_pid, {:bypass_envelope, ref, body}) + end + + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end + end) + + assert {:error, %Sentry.ClientError{reason: :rate_limited}} = + Sentry.capture_message( + "rate-limited-error", + result: :sync, + request_retries: [] + ) + + assert {:ok, _event_id} = + Sentry.send_transaction(transaction, result: :sync, request_retries: []) + + assert_sentry_transaction(ref, transaction: "test-transaction") + end + test "validates options", %{transaction: transaction} do assert_raise NimbleOptions.ValidationError, fn -> Sentry.send_transaction(transaction, client: "oops") From 7c96c06cce2fd08bde9aeb64cedecad95fc31cfe Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 21 Jul 2026 13:52:19 +0000 Subject: [PATCH 2/3] fixup: pass already downcased headers for the lookup --- lib/sentry/transport.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/sentry/transport.ex b/lib/sentry/transport.ex index 07ad282f7..36ae4fb0f 100644 --- a/lib/sentry/transport.ex +++ b/lib/sentry/transport.ex @@ -136,7 +136,7 @@ defmodule Sentry.Transport do end defp update_rate_limits(headers, status) do - rate_limits_header = get_header(headers, "X-Sentry-Rate-Limits") + rate_limits_header = get_header(headers, "x-sentry-rate-limits") cond do is_binary(rate_limits_header) -> @@ -154,7 +154,7 @@ defmodule Sentry.Transport do end defp get_global_delay(headers) do - with timeout when is_binary(timeout) <- get_header(headers, "Retry-After"), + with timeout when is_binary(timeout) <- get_header(headers, "retry-after"), {delay, ""} <- Integer.parse(timeout) do delay else @@ -164,12 +164,10 @@ defmodule Sentry.Transport do end end - defp get_header(headers, name) do - downcased_name = String.downcase(name) - + defp get_header(headers, lowercase_name) do Enum.find_value(headers, fn {header_name, value} when is_binary(header_name) -> - if String.downcase(header_name) == downcased_name, do: value + if String.downcase(header_name) == lowercase_name, do: value _other -> nil From 1483dd4e67d7e03aec54fb51a2c859fca92c5426 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 21 Jul 2026 13:58:32 +0000 Subject: [PATCH 3/3] fixup: better high-level coverage for Retry-After --- test/sentry/transport_test.exs | 21 --------------------- test/sentry_test.exs | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/test/sentry/transport_test.exs b/test/sentry/transport_test.exs index 064bf2b14..9b222449b 100644 --- a/test/sentry/transport_test.exs +++ b/test/sentry/transport_test.exs @@ -267,27 +267,6 @@ defmodule Sentry.TransportTest do assert_received {:request, ^ref} end - test "reads the Retry-After response header case-insensitively", %{bypass: bypass} do - envelope = Envelope.from_event(Event.create_event(message: "Hello")) - - Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> - conn - |> Plug.Conn.put_resp_header("Retry-After", "0") - |> Plug.Conn.resp(429, ~s<{}>) - end) - - assert :rate_limited = - error(fn -> - Transport.encode_and_post_envelope(envelope, FinchClient, _retries = []) - end) - - Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> - Plug.Conn.resp(conn, 200, ~s<{"id":"123"}>) - end) - - assert {:ok, "123"} = Transport.encode_and_post_envelope(envelope, FinchClient) - end - test "fails immediately when Sentry replies with 413 (envelope too large)", %{bypass: bypass} do envelope = Envelope.from_event(Event.create_event(message: "Hello")) test_pid = self() diff --git a/test/sentry_test.exs b/test/sentry_test.exs index e2179006a..7ea4ea43e 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -135,6 +135,32 @@ defmodule SentryTest do assert :ignored = Sentry.send_event(event) end + test "reads Retry-After response headers case-insensitively", %{bypass: bypass} do + request_count = :counters.new(1, []) + put_test_config(client: Sentry.FinchClient) + + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> + request_number = :counters.get(request_count, 1) + :counters.add(request_count, 1, 1) + + if request_number == 0 do + conn + |> Plug.Conn.put_resp_header("Retry-After", "0") + |> Plug.Conn.resp(429, ~s<{}>) + else + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end + end) + + assert {:error, %Sentry.ClientError{reason: :rate_limited}} = + Sentry.capture_message("rate-limited", result: :sync, request_retries: []) + + assert {:ok, _event_id} = + Sentry.capture_message("accepted", result: :sync, request_retries: []) + + assert :counters.get(request_count, 1) == 2 + end + describe "send_check_in/1" do test "posts a check-in with all the explicit arguments", %{bypass: bypass} do put_test_config(environment_name: "test", release: "1.3.2")