Skip to main content

lib/rapyd/http/client.ex

defmodule Rapyd.HTTP.Client do
  @moduledoc """
  Default HTTP transport for the Rapyd SDK.

  Responsibilities:
  * Attaches Rapyd HMAC-SHA256 auth headers to every request
  * Encodes request bodies as JSON
  * Decodes and validates Rapyd response envelopes
  * Implements full-jitter exponential backoff on retryable failures
  * Enforces a configurable per-request timeout

  This module implements the `Rapyd.HTTP.Behaviour` contract, making it
  trivially swappable for test doubles via `Mox`.
  """

  @behaviour Rapyd.HTTP.Behaviour

  alias Rapyd.{Client, Error, Signing}

  # Base delay for backoff in milliseconds. Full-jitter means the actual
  # sleep is random in [0, base * 2^attempt].
  @backoff_base_ms 200
  @backoff_cap_ms 30_000
  @max_body_bytes 10 * 1024 * 1024

  @doc """
  Execute a signed API request with automatic retry.

  `path` must start with `/`, e.g. `"/v1/payments"`.
  `body` is any JSON-serialisable term, or `nil` for requests without a body.

  Returns `{:ok, decoded_data}` where `decoded_data` is the `"data"` field
  from the Rapyd response envelope, or `{:error, %Rapyd.Error{}}`.
  """
  @impl Rapyd.HTTP.Behaviour
  @spec request(Client.t(), atom(), String.t(), map() | nil, keyword()) ::
          {:ok, term()} | {:error, Error.t()}
  def request(%Client{} = client, method, path, body \\ nil, _opts \\ []) do
    do_request(client, method, path, body, 0)
  end

  # ---------------------------------------------------------------------------
  # Internal retry loop
  # ---------------------------------------------------------------------------

  defp do_request(%Client{} = client, method, path, body, attempt) do
    case execute(client, method, path, body) do
      {:ok, _} = success ->
        success

      {:error, %Error{retryable?: true} = _err} when attempt < client.max_retries - 1 ->
        sleep_ms = jitter(attempt)
        Process.sleep(sleep_ms)
        do_request(client, method, path, body, attempt + 1)

      {:error, _} = error ->
        error
    end
  end

  defp execute(%Client{} = client, method, path, body) do
    {body_str, content_type} = encode_body(body)
    url = client.base_url <> path

    {salt, timestamp, signature} =
      Signing.sign_request(method, path, body_str, client.access_key, client.secret_key)

    headers = build_headers(client.access_key, salt, timestamp, signature, content_type)

    req_opts =
      [method: method, url: url, headers: headers, receive_timeout: client.timeout]
      |> maybe_put_body(method, body_str)

    case Req.request(req_opts) do
      {:ok, %Req.Response{status: status, body: raw}} when is_binary(raw) ->
        handle_response(status, raw)

      {:ok, %Req.Response{status: status, body: decoded}} when is_map(decoded) ->
        # Req may pre-decode JSON; re-serialise for uniform handling
        handle_response(status, Jason.encode!(decoded))

      {:error, exc} ->
        {:error, Error.from_exception(exc)}
    end
  end

  # ---------------------------------------------------------------------------
  # Response handling
  # ---------------------------------------------------------------------------

  defp handle_response(status, raw_body) when byte_size(raw_body) > @max_body_bytes do
    {:error,
     %Error{
       type: :api_error,
       message: "response body exceeded #{@max_body_bytes} bytes",
       status_code: status,
       retryable?: false
     }}
  end

  defp handle_response(status, raw_body) do
    with {:ok, decoded} <- Jason.decode(raw_body),
         :ok <- check_envelope_status(decoded, status) do
      {:ok, Map.get(decoded, "data")}
    else
      {:error, %Error{} = err} ->
        {:error, err}

      {:error, %Jason.DecodeError{} = e} ->
        {:error,
         %Error{
           type: :api_error,
           message: "failed to decode response: #{Exception.message(e)}",
           status_code: status,
           retryable?: false
         }}
    end
  end

  defp check_envelope_status(decoded, http_status) do
    status_obj = Map.get(decoded, "status", %{})
    success? = http_status in 200..299 and Map.get(status_obj, "status") != "ERROR"

    if success? do
      :ok
    else
      {:error, Error.from_api_response(decoded, http_status)}
    end
  end

  # ---------------------------------------------------------------------------
  # Request helpers
  # ---------------------------------------------------------------------------

  defp encode_body(nil), do: {"", "application/json"}
  defp encode_body(""), do: {"", "application/json"}

  defp encode_body(body) when is_map(body) or is_list(body) do
    {Jason.encode!(body), "application/json"}
  end

  defp build_headers(access_key, salt, timestamp, signature, content_type) do
    [
      {"Content-Type", content_type},
      {"access_key", access_key},
      {"salt", salt},
      {"timestamp", timestamp},
      {"signature", signature},
      {"User-Agent", "rapyd/#{Rapyd.version()} elixir/#{System.version()}"}
    ]
  end

  defp maybe_put_body(opts, method, body_str)
       when method in [:post, :put, :patch, :delete] and body_str != "" do
    Keyword.put(opts, :body, body_str)
  end

  defp maybe_put_body(opts, _method, _body_str), do: opts

  # ---------------------------------------------------------------------------
  # Full-jitter backoff
  # ---------------------------------------------------------------------------

  # Full-jitter: sleep = random_in(0, min(cap, base * 2^attempt))
  # This avoids thundering-herd on mass retries.
  defp jitter(attempt) do
    cap = min(@backoff_cap_ms, @backoff_base_ms * Integer.pow(2, attempt))
    :rand.uniform(cap)
  end
end