Packages

CLI to inspect, export, and delete Mastodon posts safely.

Current section

Files

Jump to
mastomation lib client.ex
Raw

lib/client.ex

defmodule Mastomation.Client do
@moduledoc """
Centralized HTTP client for Mastomation API calls.
"""
@doc "GET and decode JSON body."
@spec get_json!(String.t(), String.t()) :: map() | list()
def get_json!(url, token) do
:telemetry.execute([:mastomation, :http, :get, :start], %{}, %{url: url})
body =
Req.get!(url, headers: Mastomation.build_header(token)).body
|> Mastomation.decode!()
:telemetry.execute([:mastomation, :http, :get, :stop], %{}, %{url: url})
body
end
@doc """
Delete a status with one rate-limit aware retry.
Returns :ok on success, {:error, reason} on failure.
"""
@spec delete_status_with_retry(String.t(), String.t(), String.t()) ::
:ok | {:error, term()}
def delete_status_with_retry(instance, token, status_id) do
url = "#{instance}/api/v1/statuses/#{status_id}"
do_delete_with_retry(url, token, 0)
end
# Executes delete and retries once on rate-limit/transient failures.
@spec do_delete_with_retry(String.t(), String.t(), non_neg_integer()) ::
:ok | {:error, term()}
defp do_delete_with_retry(url, token, attempt) do
:telemetry.execute([:mastomation, :http, :delete, :start], %{}, %{url: url, attempt: attempt})
case Req.delete(url, headers: Mastomation.build_header(token)) do
{:ok, %{status: status}} when status in 200..299 ->
:telemetry.execute([:mastomation, :http, :delete, :stop], %{status: status}, %{url: url})
:ok
{:ok, %{status: status} = response} ->
if should_backoff?(status, response) and attempt == 0 do
wait_ms = backoff_ms(response)
Process.sleep(wait_ms)
do_delete_with_retry(url, token, attempt + 1)
else
{:error, {:http_status, status}}
end
{:error, reason} ->
if attempt == 0 do
Process.sleep(2_000)
do_delete_with_retry(url, token, attempt + 1)
else
{:error, reason}
end
end
end
@spec should_backoff?(integer(), map()) :: boolean()
defp should_backoff?(429, _response), do: true
# Detects near-limit responses that should trigger waiting before retry.
@spec should_backoff?(integer(), map()) :: boolean()
defp should_backoff?(_status, response) do
case header_value(response, "x-ratelimit-remaining") do
"0" -> true
_ -> false
end
end
# Computes a conservative wait based on reset header when present.
@spec backoff_ms(map()) :: non_neg_integer()
defp backoff_ms(response) do
case header_value(response, "x-ratelimit-reset") do
nil ->
2_000
ts ->
case DateTime.from_iso8601(ts) do
{:ok, dt, _} ->
diff = DateTime.diff(dt, DateTime.utc_now(), :millisecond)
max(diff, 2_000)
_ ->
2_000
end
end
end
# Reads header values from map/list response header formats.
@spec header_value(map(), String.t()) :: String.t() | nil
defp header_value(response, key) do
headers = Map.get(response, :headers, %{})
downcased_key = String.downcase(key)
case headers do
%{} -> header_value_from_map(headers, key, downcased_key)
list when is_list(list) -> header_value_from_list(list, downcased_key)
_ -> nil
end
end
@spec header_value_from_map(map(), String.t(), String.t()) :: String.t() | nil
defp header_value_from_map(headers, key, downcased_key) do
case Map.get(headers, key) || Map.get(headers, downcased_key) do
[value | _] -> value
value when is_binary(value) -> value
_ -> nil
end
end
@spec header_value_from_list(list(), String.t()) :: String.t() | nil
defp header_value_from_list(headers, downcased_key) do
Enum.find_value(headers, &match_header_value(&1, downcased_key))
end
@spec match_header_value(term(), String.t()) :: String.t() | nil
defp match_header_value({key, [value | _]}, downcased_key) when is_binary(key) do
if String.downcase(key) == downcased_key, do: value
end
defp match_header_value({key, value}, downcased_key) when is_binary(key) and is_binary(value) do
if String.downcase(key) == downcased_key, do: value
end
defp match_header_value(_, _), do: nil
end