diff --git a/changelog.d/http-backoff.add b/changelog.d/http-backoff.add
new file mode 100644
index 000000000..5c9eb988b
--- /dev/null
+++ b/changelog.d/http-backoff.add
@@ -0,0 +1 @@
+Back off from hosts that answer 429 or 503 to object fetches and WebFinger, until Retry-After or X-RateLimit-Reset (default 5 minutes, max 1 hour)
diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex
index aebad6984..080dd1065 100644
--- a/lib/mix/pleroma.ex
+++ b/lib/mix/pleroma.ex
@@ -17,7 +17,7 @@ defmodule Mix.Pleroma do
:oban,
:logger_backends
]
- @cachex_children ["object", "user", "scrubber", "web_resp"]
+ @cachex_children ["object", "user", "scrubber", "web_resp", "http_backoff"]
@doc "Common functions to be reused in mix tasks"
def start_pleroma do
Pleroma.Config.Holder.save_default()
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex
index adc054f3b..2c8eede1c 100644
--- a/lib/pleroma/application.ex
+++ b/lib/pleroma/application.ex
@@ -198,6 +198,7 @@ defmodule Pleroma.Application do
),
build_cachex("rel_me", limit: 2500),
build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5_000),
+ build_cachex("http_backoff", limit: 10_000),
build_cachex("translations", default_ttl: :timer.hours(24), limit: 5_000)
]
end
diff --git a/lib/pleroma/http/backoff.ex b/lib/pleroma/http/backoff.ex
new file mode 100644
index 000000000..0d2b99742
--- /dev/null
+++ b/lib/pleroma/http/backoff.ex
@@ -0,0 +1,101 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.Backoff do
+ @moduledoc """
+ GET wrapper that stops sending requests to a host after it answers
+ 429 or 503, until the time given by `Retry-After` or `X-RateLimit-Reset`.
+
+ Based on Akkoma's `Pleroma.HTTP.Backoff`.
+ """
+
+ alias Pleroma.HTTP
+
+ require Logger
+
+ @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+ @cache :http_backoff_cache
+
+ @default_seconds 5 * 60
+ @max_seconds 60 * 60
+
+ @spec get(String.t(), list(), keyword()) :: {:ok, Tesla.Env.t()} | {:error, any()}
+ def get(url, headers \\ [], options \\ []) when is_binary(url) do
+ host = URI.parse(url).host
+
+ case @cachex.get(@cache, host) do
+ {:ok, true} ->
+ {:error, :ratelimit}
+
+ _ ->
+ url
+ |> HTTP.get(headers, options)
+ |> check_backoff(host)
+ end
+ end
+
+ defp check_backoff({:ok, %{status: status, headers: headers}}, host)
+ when status in [429, 503] do
+ 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))
+ {:error, :ratelimit}
+ end
+
+ defp check_backoff(response, _host), do: response
+
+ @doc """
+ Seconds to back off for, from the first valid `Retry-After` or
+ `X-RateLimit-Reset` header. Missing, invalid or past values give the
+ default of #{@default_seconds} seconds; values are capped at #{@max_seconds}.
+ """
+ @spec backoff_seconds(list(), DateTime.t()) :: pos_integer()
+ def backoff_seconds(headers, now) do
+ [
+ parse_retry_after(header(headers, "retry-after"), now),
+ parse_timestamp(header(headers, "x-ratelimit-reset"), now)
+ ]
+ |> Enum.find(@default_seconds, &is_integer/1)
+ |> min(@max_seconds)
+ end
+
+ defp header(headers, name) do
+ Enum.find_value(headers, fn
+ {key, value} when is_binary(key) and is_binary(value) ->
+ if String.downcase(key) == name, do: String.trim(value)
+
+ _ ->
+ nil
+ end)
+ end
+
+ # Retry-After is either delta-seconds or an HTTP-date (RFC 9110, 10.2.3)
+ defp parse_retry_after(nil, _now), do: nil
+
+ defp parse_retry_after(value, now) do
+ case Integer.parse(value) do
+ {seconds, ""} when seconds > 0 -> seconds
+ {_, ""} -> nil
+ _ -> parse_timestamp(value, now)
+ end
+ end
+
+ defp parse_timestamp(nil, _now), do: nil
+
+ defp parse_timestamp(value, now) do
+ with {:ok, %DateTime{} = datetime} <- parse_datetime(value),
+ seconds when seconds > 0 <- DateTime.diff(datetime, now) do
+ seconds
+ else
+ _ -> nil
+ end
+ end
+
+ defp parse_datetime(value) do
+ with {:error, _} <- Timex.parse(value, "{RFC1123}"),
+ {:ok, datetime, _offset} <- DateTime.from_iso8601(value) do
+ {:ok, datetime}
+ end
+ end
+end
diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex
index 706ee317c..3313583cc 100644
--- a/lib/pleroma/object/fetcher.ex
+++ b/lib/pleroma/object/fetcher.ex
@@ -216,7 +216,7 @@ defmodule Pleroma.Object.Fetcher do
|> maybe_date_fetch(date)
|> sign_fetch(id, date)
- case HTTP.get(id, headers) do
+ case HTTP.Backoff.get(id, headers) do
{:ok, %{body: body, status: code, headers: headers, url: final_url}}
when code in 200..299 ->
remote_host = if final_url, do: URI.parse(final_url).host, else: nil
diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex
index 62a2715f5..cb312405c 100644
--- a/lib/pleroma/web/web_finger.ex
+++ b/lib/pleroma/web/web_finger.ex
@@ -158,7 +158,11 @@ defmodule Pleroma.Web.WebFinger do
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
def find_lrdd_template(domain) do
@cachex.fetch!(:host_meta_cache, domain, fn _ ->
- {:commit, fetch_lrdd_template(domain)}
+ case fetch_lrdd_template(domain) do
+ # retry once the backoff expires instead of caching the miss
+ {:error, :ratelimit} = error -> {:ignore, error}
+ result -> {:commit, result}
+ end
end)
rescue
e -> {:error, "Cachex error: #{inspect(e)}"}
@@ -168,9 +172,13 @@ defmodule Pleroma.Web.WebFinger do
# WebFinger is restricted to HTTPS - https://tools.ietf.org/html/rfc7033#section-9.1
meta_url = "https://#{domain}/.well-known/host-meta"
- with {:ok, %{status: status, body: body}} when status in 200..299 <- HTTP.get(meta_url) do
+ with {:ok, %{status: status, body: body}} when status in 200..299 <-
+ HTTP.Backoff.get(meta_url) do
get_template_from_xml(body)
else
+ {:error, :ratelimit} = error ->
+ error
+
error ->
Logger.warning("Can't find LRDD template in #{inspect(meta_url)}: #{inspect(error)}")
{:error, :lrdd_not_found}
@@ -210,7 +218,7 @@ defmodule Pleroma.Web.WebFinger do
with address when is_binary(address) <- get_address_from_domain(domain, account),
{:ok, %{status: status, body: body, headers: headers}} when status in 200..299 <-
- HTTP.get(
+ HTTP.Backoff.get(
address,
[{"accept", "application/xrd+xml,application/jrd+json"}]
) do
diff --git a/test/pleroma/http/backoff_test.exs b/test/pleroma/http/backoff_test.exs
new file mode 100644
index 000000000..48c8514bd
--- /dev/null
+++ b/test/pleroma/http/backoff_test.exs
@@ -0,0 +1,154 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+# Based on Akkoma's test/pleroma/http/backoff_test.exs
+defmodule Pleroma.HTTP.BackoffTest do
+ use Pleroma.DataCase, async: false
+
+ alias Pleroma.HTTP.Backoff
+
+ @cache :http_backoff_cache
+ @url "https://ratelimited.example.com/users/alice"
+ @host "ratelimited.example.com"
+
+ defp mock_response(status, headers \\ []) do
+ test_pid = self()
+
+ Tesla.Mock.mock(fn %{method: :get} ->
+ send(test_pid, :request)
+ %Tesla.Env{status: status, headers: headers, body: ""}
+ end)
+ end
+
+ defp ttl_seconds do
+ {:ok, ttl} = Cachex.ttl(@cache, @host)
+ div(ttl, 1000)
+ end
+
+ defp assert_ttl_about(expected) do
+ ttl = ttl_seconds()
+ assert ttl in (expected - 15)..expected//1, "expected a TTL near #{expected}, got #{ttl}"
+ end
+
+ describe "get/3" do
+ test "returns the response when the host is not backed off" do
+ mock_response(200)
+
+ assert {:ok, %Tesla.Env{status: 200}} = Backoff.get(@url)
+ assert {:ok, nil} = Cachex.get(@cache, @host)
+ end
+
+ test "does not send a request while the host is backed off" do
+ Cachex.put(@cache, @host, true)
+ mock_response(200)
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ refute_received :request
+ end
+
+ for status <- [429, 503] do
+ test "backs off from the host after a #{status} response" do
+ mock_response(unquote(status))
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_received :request
+ assert {:ok, true} = Cachex.get(@cache, @host)
+
+ assert {:error, :ratelimit} = Backoff.get(@url <> "/other")
+ refute_received :request
+ end
+ end
+
+ test "uses a default backoff without headers" do
+ mock_response(429)
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(300)
+ end
+
+ test "only backs off from the host that answered" do
+ mock_response(429)
+ assert {:error, :ratelimit} = Backoff.get(@url)
+
+ mock_response(200)
+ assert {:ok, _} = Backoff.get("https://other.example.com/users/bob")
+ end
+ end
+
+ describe "backoff headers" do
+ test "parses Retry-After in seconds" do
+ mock_response(429, [{"retry-after", "600"}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(600)
+ end
+
+ test "parses Retry-After as an HTTP-date" do
+ date =
+ DateTime.utc_now()
+ |> DateTime.add(600)
+ |> Timex.format!("{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT")
+
+ mock_response(503, [{"retry-after", date}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(600)
+ end
+
+ test "parses Retry-After as an ISO 8601 timestamp" do
+ date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601()
+ mock_response(429, [{"retry-after", date}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(600)
+ end
+
+ test "parses X-RateLimit-Reset as an ISO 8601 timestamp" do
+ date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601()
+ mock_response(429, [{"x-ratelimit-reset", date}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(600)
+ end
+
+ test "matches header names case-insensitively" do
+ mock_response(429, [{"Retry-After", "600"}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(600)
+ end
+
+ test "prefers Retry-After over X-RateLimit-Reset" do
+ date = DateTime.utc_now() |> DateTime.add(1200) |> DateTime.to_iso8601()
+ mock_response(429, [{"x-ratelimit-reset", date}, {"retry-after", "600"}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(600)
+ end
+
+ test "falls back to the next header when one is invalid" do
+ date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601()
+ mock_response(429, [{"retry-after", "soon"}, {"x-ratelimit-reset", date}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(600)
+ end
+
+ for value <- ["soon", "-5", "0", "12.5", "", "Wed, 21 Oct 2015 07:28:00 GMT"] do
+ test "ignores the invalid or past Retry-After value #{inspect(value)}" do
+ mock_response(429, [{"retry-after", unquote(value)}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(300)
+ end
+ end
+
+ test "caps absurd values at one hour" do
+ mock_response(429, [{"retry-after", "99999999999"}])
+
+ assert {:error, :ratelimit} = Backoff.get(@url)
+ assert_ttl_about(3600)
+ end
+ end
+end
diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs
index 61a6fb517..80b172a36 100644
--- a/test/pleroma/object/fetcher_test.exs
+++ b/test/pleroma/object/fetcher_test.exs
@@ -94,6 +94,27 @@ defmodule Pleroma.Object.FetcherTest do
end
end
+ describe "rate limiting" do
+ test "a backed-off host gets no further requests" do
+ test_pid = self()
+
+ mock(fn %{method: :get, url: url} ->
+ send(test_pid, {:request, url})
+ %Tesla.Env{status: 429, headers: [{"retry-after", "600"}]}
+ end)
+
+ first = "https://ratelimited.example.com/objects/1"
+ second = "https://ratelimited.example.com/objects/2"
+
+ assert {:fetch, {:error, :ratelimit}} = Fetcher.fetch_object_from_id(first)
+ assert_received {:request, ^first}
+
+ assert {:error, :ratelimit} = Fetcher.fetch_and_contain_remote_object_from_id(second)
+ assert {:fetch, {:error, :ratelimit}} = Fetcher.fetch_object_from_id(second)
+ refute_received {:request, _}
+ end
+ end
+
describe "max thread distance restriction" do
@ap_id "http://mastodon.example.org/@admin/99541947525187367"
setup do: clear_config([:instance, :federation_incoming_replies_max_depth])
diff --git a/test/pleroma/web/web_finger_test.exs b/test/pleroma/web/web_finger_test.exs
index da10abdd2..fc99bee41 100644
--- a/test/pleroma/web/web_finger_test.exs
+++ b/test/pleroma/web/web_finger_test.exs
@@ -305,4 +305,29 @@ defmodule Pleroma.Web.WebFingerTest do
{:ok, _data} = WebFinger.finger("a@sub.mastodon.example")
end
end
+
+ describe "rate limiting" do
+ setup do
+ Mox.stub_with(Pleroma.CachexMock, Pleroma.CachexProxy)
+ test_pid = self()
+
+ Tesla.Mock.mock_global(fn %{url: url} ->
+ send(test_pid, {:request, url})
+ %Tesla.Env{status: 429, headers: [{"retry-after", "600"}]}
+ end)
+ end
+
+ test "backs off from a host that rate limits host-meta" do
+ assert {:error, :ratelimit} = WebFinger.finger("alice@ratelimited.example")
+ assert_received {:request, "https://ratelimited.example/.well-known/host-meta"}
+ refute_received {:request, _}
+
+ # a rate-limited host-meta lookup must not be cached as "no LRDD template"
+ assert {:ok, nil} = Cachex.get(:host_meta_cache, "ratelimited.example")
+
+ assert {:error, :ratelimit} = WebFinger.finger("bob@ratelimited.example")
+ assert {:error, :ratelimit} = WebFinger.find_lrdd_template("ratelimited.example")
+ refute_received {:request, _}
+ end
+ end
end
diff --git a/test/pleroma/workers/remote_fetcher_worker_test.exs b/test/pleroma/workers/remote_fetcher_worker_test.exs
index a3b900a9b..9945ca143 100644
--- a/test/pleroma/workers/remote_fetcher_worker_test.exs
+++ b/test/pleroma/workers/remote_fetcher_worker_test.exs
@@ -13,6 +13,7 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do
@unauthorized_object "https://unauthorized.example.com/"
@depth_object "https://depth.example.com/"
@content_type_object "https://bad_content_type.example.com/"
+ @ratelimited_object "https://ratelimited.example.com/"
describe "RemoteFetcherWorker" do
setup do
@@ -37,6 +38,11 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do
status: 200
}
+ %{method: :get, url: @ratelimited_object} ->
+ %Tesla.Env{
+ status: 429
+ }
+
%{method: :get, url: @content_type_object} ->
%Tesla.Env{
status: 200,
@@ -73,6 +79,14 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do
})
end
+ test "retries jobs for a rate-limited host" do
+ assert {:error, :ratelimit} =
+ perform_job(RemoteFetcherWorker, %{
+ "op" => "fetch_remote",
+ "id" => @ratelimited_object
+ })
+ end
+
test "does not retry jobs for when object returns wrong content type" do
assert {:cancel, _} =
perform_job(RemoteFetcherWorker, %{