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(), 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