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