From 4070b7be21a28fa2f994483a5111922a9b363f73 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 1 Nov 2026 17:03:11 +0400 Subject: [PATCH] fix(http): back off from the host that answered, and read Unix resets After a redirect the 429/503 came from the final host, but the backoff was recorded for the requested one. X-RateLimit-Reset is also accepted as a Unix timestamp or as seconds. --- lib/pleroma/http/backoff.ex | 35 ++++++++++++++++++++++++++++-- test/pleroma/http/backoff_test.exs | 32 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/http/backoff.ex b/lib/pleroma/http/backoff.ex index 44b66fbdd..5e432b285 100644 --- a/lib/pleroma/http/backoff.ex +++ b/lib/pleroma/http/backoff.ex @@ -39,8 +39,9 @@ defmodule Pleroma.HTTP.Backoff do defp downcase(nil), do: nil defp downcase(host), do: String.downcase(host) - defp check_backoff({:ok, %{status: status, headers: headers}}, host) + defp check_backoff({:ok, %{status: status, headers: headers} = env}, host) when status in [429, 503] do + host = response_host(env, host) seconds = backoff_seconds(headers, DateTime.utc_now()) Logger.warning("#{host} answered #{status}, backing off for #{seconds} seconds") @cachex.put(@cache, host, true, ttl: :timer.seconds(seconds)) @@ -49,6 +50,13 @@ defmodule Pleroma.HTTP.Backoff do defp check_backoff(response, _host), do: response + # After a redirect, the 429/503 came from the last host, not the requested one. + defp response_host(%{url: url}, host) when is_binary(url) do + URI.parse(url).host |> downcase() || host + end + + defp response_host(_env, host), do: host + @doc """ Turns an Oban job's `{:error, :ratelimit}` result into a snooze for the default backoff, so the job's attempts aren't all spent while the host is @@ -75,7 +83,7 @@ defmodule Pleroma.HTTP.Backoff do def backoff_seconds(headers, now) do [ parse_retry_after(header(headers, "retry-after"), now), - parse_timestamp(header(headers, "x-ratelimit-reset"), now) + parse_ratelimit_reset(header(headers, "x-ratelimit-reset"), now) ] |> Enum.find(@default_seconds, &is_integer/1) |> min(@max_seconds) @@ -102,6 +110,29 @@ defmodule Pleroma.HTTP.Backoff do end end + # Servers send X-RateLimit-Reset as a timestamp (ISO 8601 like Mastodon, or + # Unix seconds) or as seconds until the reset. + @unix_timestamp_min 1_000_000_000 + + defp parse_ratelimit_reset(nil, _now), do: nil + + defp parse_ratelimit_reset(value, now) do + case Integer.parse(value) do + {timestamp, ""} when timestamp >= @unix_timestamp_min -> + timestamp - DateTime.to_unix(now) + + {seconds, ""} -> + seconds + + _ -> + parse_timestamp(value, now) + end + |> positive() + end + + defp positive(seconds) when is_integer(seconds) and seconds > 0, do: seconds + defp positive(_), do: nil + defp parse_timestamp(nil, _now), do: nil defp parse_timestamp(value, now) do diff --git a/test/pleroma/http/backoff_test.exs b/test/pleroma/http/backoff_test.exs index f893eef2e..b650391dd 100644 --- a/test/pleroma/http/backoff_test.exs +++ b/test/pleroma/http/backoff_test.exs @@ -83,6 +83,23 @@ defmodule Pleroma.HTTP.BackoffTest do mock_response(200) assert {:ok, _} = Backoff.get("https://other.example.com/users/bob") end + + test "backs off from the host that answered after a redirect" do + test_pid = self() + + Tesla.Mock.mock(fn + %{url: "https://redirector.example.com/users/alice"} = env -> + %{env | status: 302, headers: [{"location", @url}], body: ""} + + %{url: @url} = env -> + send(test_pid, :request) + %{env | status: 429, headers: [], body: ""} + end) + + assert {:error, :ratelimit} = Backoff.get("https://redirector.example.com/users/alice") + assert {:ok, true} = Cachex.get(@cache, @host) + assert {:ok, nil} = Cachex.get(@cache, "redirector.example.com") + end end describe "backoff headers" do @@ -121,6 +138,21 @@ defmodule Pleroma.HTTP.BackoffTest do assert_ttl_about(600) end + test "parses X-RateLimit-Reset as a Unix timestamp" do + reset = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_unix() |> to_string() + mock_response(429, [{"x-ratelimit-reset", reset}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "parses X-RateLimit-Reset in seconds" do + mock_response(429, [{"x-ratelimit-reset", "600"}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + test "matches header names case-insensitively" do mock_response(429, [{"Retry-After", "600"}])