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(), keyword()) :: map() | list() def get_json!(url, token, req_opts \\ []) do :telemetry.execute([:mastomation, :http, :get, :start], %{}, %{url: url}) headers = Mastomation.build_header(token) options = Keyword.merge(req_opts, headers: headers) body = Req.get!(url, options).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(), keyword()) :: :ok | {:error, term()} def delete_status_with_retry(instance, token, status_id, req_opts \\ []) do url = "#{instance}/api/v1/statuses/#{status_id}" do_delete_with_retry(url, token, 0, req_opts) end @doc """ Executes delete and retries once on rate-limit/transient failures. ## Parameters - url: The URL to delete. - token: The access token for authentication. - attempt: The current attempt number (starts at 0). - req_opts: Additional request options. ## Returns - `:ok` on success. - `{:error, reason}` on failure. """ @spec do_delete_with_retry(String.t(), String.t(), non_neg_integer(), keyword()) :: :ok | {:error, term()} def do_delete_with_retry(url, token, attempt, req_opts \\ []) do :telemetry.execute([:mastomation, :http, :delete, :start], %{}, %{url: url, attempt: attempt}) headers = Mastomation.build_header(token) options = Keyword.merge(req_opts, headers: headers) case Req.delete(url, options) 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) do wait_ms = backoff_ms(response, attempt) Process.sleep(wait_ms) do_delete_with_retry(url, token, attempt + 1, req_opts) else {:error, {:http_status, status}} end {:error, reason} -> if attempt < 3 do wait_ms = 2_000 * :math.pow(2, attempt) Process.sleep(wait_ms) do_delete_with_retry(url, token, attempt + 1, req_opts) else {:error, reason} end end end @doc """ Checks if a retry should be attempted based on the HTTP status code. ## Parameters - status: The HTTP status code. - response: The HTTP response map. ## Returns - true if a retry should be attempted. - false otherwise. """ @spec should_backoff?(integer(), map()) :: boolean() def should_backoff?(429, _response), do: true def should_backoff?(_status, response) do case header_value(response, "x-ratelimit-remaining") do "0" -> true _ -> false end end @doc """ Computes a conservative wait based on reset header when present. Uses exponential backoff for retries. ## Parameters - response: The HTTP response map. - attempt: The current attempt number (starts at 0). ## Returns - The wait time in milliseconds. """ @spec backoff_ms(map(), non_neg_integer()) :: non_neg_integer() def backoff_ms(response, attempt) do base_wait = 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 # Apply exponential backoff base_wait * :math.pow(2, attempt) end @doc """ Reads header values from map/list response header formats. ## Parameters - response: The HTTP response map. - key: The header key to retrieve. ## Returns - The header value as a string, or nil if not found. """ @spec header_value(map(), String.t()) :: String.t() | nil def 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 @doc """ Extracts a header value from a map-based headers structure. ## Parameters - headers: The headers as a map. - key: The original header key. - downcased_key: The lowercase version of the header key. ## Returns - The header value as a string, or nil if not found. """ @spec header_value_from_map(map(), String.t(), String.t()) :: String.t() | nil def header_value_from_map(headers, key, downcased_key) do case Map.get(headers, key) do nil -> find_case_insensitive_header(headers, downcased_key) value -> extract_header_value(value) end end @doc """ Finds a header value by case-insensitive key search. ## Parameters - headers: The headers as a map. - downcased_key: The lowercase version of the header key. ## Returns - The header value as a string, or nil if not found. """ @spec find_case_insensitive_header(map(), String.t()) :: String.t() | nil def find_case_insensitive_header(headers, downcased_key) do Enum.find_value(headers, fn {header_key, value} -> if String.downcase(header_key) == downcased_key do extract_header_value(value) end end) end @doc """ Extracts the header value from a raw header value structure. ## Parameters - value: The raw header value (can be a list or binary). ## Returns - The header value as a string, or nil if not found. """ @spec extract_header_value(term()) :: String.t() | nil def extract_header_value(value) do case value do [v | _] -> v v when is_binary(v) -> v _ -> nil end end @doc """ Extracts a header value from a list-based headers structure. ## Parameters - headers: The headers as a list of tuples. - downcased_key: The lowercase version of the header key. ## Returns - The header value as a string, or nil if not found. """ @spec header_value_from_list(list(), String.t()) :: String.t() | nil def header_value_from_list(headers, downcased_key) do Enum.find_value(headers, &match_header_value(&1, downcased_key)) end @doc """ Matches a header value from a list-based header tuple. ## Parameters - header: A tuple representing a header (key-value pair). - downcased_key: The lowercase version of the header key. ## Returns - The header value as a string, or nil if not found. """ @spec match_header_value(term(), String.t()) :: String.t() | nil def match_header_value({key, [value | _]}, downcased_key) when is_binary(key) do if String.downcase(key) == downcased_key, do: value end def 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 def match_header_value(_, _), do: nil end