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