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