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