Skip to main content

lib/billdog_eng/transport.ex

defmodule BilldogEng.Transport do
  @moduledoc """
  Thin HTTP client around Erlang's `:httpc` with:

    * per-request timeout
    * optional gzip request bodies (only when the body is large enough to benefit)
    * exponential backoff retry (1s, 2s, 4s …) for 5xx / 429 / network errors
    * unwrapping of the `{success, data, meta}` envelope when present

  Mirrors the Node SDK `transport.ts`. A `:sleep_fn` may be injected (tests) to
  avoid real backoff delays.
  """

  require Logger
  import Bitwise
  alias BilldogEng.Error

  @gzip_threshold_bytes 1024

  @type config :: %{
          host: String.t(),
          request_timeout: non_neg_integer(),
          gzip: boolean(),
          max_retries: non_neg_integer(),
          enable_logging: boolean(),
          sleep_fn: (non_neg_integer() -> any())
        }

  @doc """
  Build a resolved transport config map from options.
  """
  @spec config(keyword()) :: config()
  def config(opts) do
    %{
      host: Keyword.fetch!(opts, :host),
      request_timeout: Keyword.fetch!(opts, :request_timeout),
      gzip: Keyword.fetch!(opts, :gzip),
      max_retries: Keyword.fetch!(opts, :max_retries),
      enable_logging: Keyword.get(opts, :enable_logging, false),
      sleep_fn: Keyword.get(opts, :sleep_fn, &Process.sleep/1)
    }
  end

  @doc """
  Perform a POST request with retry/backoff. Returns `{:ok, parsed}` (envelope
  unwrapped) or `{:error, %BilldogEng.Error{}}`.

  Options:

    * `:path`    — required, path appended to host (e.g. `"/ingest-events"`)
    * `:body`    — the request body (will be JSON-encoded), or `nil` for a GET
    * `:headers` — extra headers (list of `{name, value}` string tuples)
    * `:gzip`    — per-call override; defaults to `true` (honored only if config gzip)
  """
  @spec request(config(), keyword()) :: {:ok, term()} | {:error, Error.t()}
  def request(config, opts) do
    path = Keyword.fetch!(opts, :path)
    url = config.host <> path
    max_attempts = config.max_retries + 1
    do_request(config, url, opts, 0, max_attempts, nil)
  end

  defp do_request(_config, _url, _opts, attempt, max_attempts, last_error)
       when attempt >= max_attempts do
    {:error, last_error || Error.new("request failed", 0)}
  end

  defp do_request(config, url, opts, attempt, max_attempts, _last_error) do
    if attempt > 0 do
      backoff = 1000 * bsl(1, attempt - 1)
      log(config, "retry #{attempt}/#{config.max_retries} after #{backoff}ms -> #{url}")
      config.sleep_fn.(backoff)
    end

    case attempt_once(config, url, opts) do
      {:ok, result} ->
        {:ok, result}

      {:error, %Error{status: status} = err} ->
        if retryable?(status) and attempt < max_attempts - 1 do
          log(config, "request failed (status #{status}), will retry: #{err.message}")
          do_request(config, url, opts, attempt + 1, max_attempts, err)
        else
          {:error, err}
        end
    end
  end

  defp attempt_once(config, url, opts) do
    body = Keyword.get(opts, :body)
    extra_headers = Keyword.get(opts, :headers, [])
    gzip_allowed = Keyword.get(opts, :gzip, true)

    base_headers = [
      {~c"user-agent", ~c"billdogeng-elixir"}
    ]

    headers = base_headers ++ Enum.map(extra_headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end)

    cond do
      is_nil(body) ->
        http_get(config, url, headers)

      true ->
        json = Jason.encode!(body)

        {payload, headers} =
          if config.gzip and gzip_allowed and byte_size(json) >= @gzip_threshold_bytes do
            {:zlib.gzip(json), [{~c"content-encoding", ~c"gzip"} | headers]}
          else
            {json, headers}
          end

        http_post(config, url, headers, payload)
    end
  end

  defp http_post(config, url, headers, payload) do
    request = {to_charlist(url), headers, ~c"application/json", payload}
    http_call(config, :post, request)
  end

  defp http_get(config, url, headers) do
    request = {to_charlist(url), headers}
    http_call(config, :get, request)
  end

  defp http_call(config, method, request) do
    http_opts = [
      timeout: config.request_timeout,
      connect_timeout: config.request_timeout,
      ssl: ssl_opts(url_from_request(request))
    ]

    opts = [body_format: :binary]

    case :httpc.request(method, request, http_opts, opts) do
      {:ok, {{_proto, status, _reason}, _resp_headers, resp_body}} ->
        handle_response(status, resp_body)

      {:error, reason} ->
        {:error, Error.new("network error: #{inspect(reason)}", 0, "NETWORK_ERROR")}
    end
  end

  defp url_from_request({url, _headers}), do: to_string(url)
  defp url_from_request({url, _headers, _ct, _body}), do: to_string(url)

  defp ssl_opts(url) do
    if String.starts_with?(url, "https") do
      [verify: :verify_peer, cacerts: :public_key.cacerts_get(), depth: 3]
    else
      []
    end
  end

  defp handle_response(status, resp_body) do
    parsed = parse_body(resp_body)

    cond do
      status >= 200 and status < 300 ->
        {:ok, unwrap_envelope(parsed)}

      true ->
        {code, message} = error_fields(parsed, status)
        {:error, Error.new(message, status, code, parsed)}
    end
  end

  defp parse_body(body) when body in ["", nil], do: nil

  defp parse_body(body) do
    case Jason.decode(body) do
      {:ok, decoded} -> decoded
      {:error, _} -> to_string(body)
    end
  end

  defp error_fields(%{"error" => %{} = error}, status) do
    code = Map.get(error, "code")
    message = Map.get(error, "message") || "HTTP #{status}"
    {code, message}
  end

  defp error_fields(_parsed, status), do: {nil, "HTTP #{status}"}

  # Unwrap the standard `{success, data, meta}` envelope when present;
  # legacy endpoints return the raw body, which we pass through unchanged.
  defp unwrap_envelope(%{"success" => _success, "data" => data}), do: data
  defp unwrap_envelope(other), do: other

  # Status codes that are safe to retry (transient).
  defp retryable?(0), do: true
  defp retryable?(408), do: true
  defp retryable?(429), do: true
  defp retryable?(status) when status >= 500 and status <= 599, do: true
  defp retryable?(_status), do: false

  defp log(%{enable_logging: true}, msg), do: Logger.debug("[BilldogEng] #{msg}")
  defp log(_config, _msg), do: :ok
end