defmodule Http do @recv_timeout 5 * 60 * 1000 @max_retries 10 @base_backoff 100 @backoff_cap 10_000 @type url :: String.t() @type header :: {String.t(), String.t()} @type headers :: list(header) @type payload :: map @type options :: keyword @type query :: map | String.t() | nil @type http_status :: integer @type http_error :: {:http_error, {http_status, String.t()}} @type transport_error :: {:transport_error, any} @type json_response :: %{ body: map, headers: list, status: http_status } @type success :: {:ok, json_response} @type post_response :: success | http_error | transport_error @type get_response :: {:ok, String.t()} | http_error | transport_error @doc """ Sends a POST request with a JSON payload to the specified URL with the given headers. Returns a tuple with the response status and body, or an error if the request fails. Retries up to #{@max_retries} times on 5xx HTTP responses and on select transient transport errors using exponential backoff with jitter. """ @spec post_json(url, headers, payload) :: post_response def post_json(url, headers, payload) do with {:ok, body} <- SafeJson.encode(payload) do do_post_json(url, headers, body, 1) else {:error, msg} -> UI.warn("[http] failed to encode JSON payload", msg) {:transport_error, :invalid_json_response} end end @doc """ Sends a GET request to the specified URL with the given headers and query parameters. Returns a tuple with the response status and body, or an error if the request fails. Retries up to #{@max_retries} times on 5xx HTTP responses and on select transient transport errors using exponential backoff with jitter. """ @spec get(url, headers, query) :: get_response def get(url, headers \\ [], query \\ nil) do do_get(url, headers, query, 1) end # --------------------------------------------------------------------------- # Internal # --------------------------------------------------------------------------- defp do_get(url, headers, query, attempt) when attempt <= @max_retries do options = [ recv_timeout: @recv_timeout, hackney_options: [pool: HttpPool.get()], follow_redirect: true ] query = cond do is_map(query) -> URI.encode_query(query) is_binary(query) -> query true -> "" end url |> URI.new!() |> Map.put(:query, query) |> URI.to_string() |> HTTPoison.get(headers, options) |> case do {:ok, %{status_code: 200, body: body}} -> {:ok, body} {:ok, %{status_code: status_code, body: resp_body}} -> if retryable_http_status?(status_code) and attempt < @max_retries do delay = backoff_delay(attempt) UI.warn("[http] 50x response", """ GET: #{url} HTTP: #{status_code} Attempt: #{attempt}/#{@max_retries} Retry in: #{delay} ms """) maybe_sleep(delay) do_get(url, headers, query, attempt + 1) else {:http_error, {status_code, resp_body}} end {:error, %HTTPoison.Error{reason: reason}} -> if retryable_transport_reason?(reason) and attempt < @max_retries do delay = backoff_delay(attempt) UI.warn("[http] transport error", """ GET: #{url} Attempt: #{attempt}/#{@max_retries} Retry in: #{delay} ms Reason: #{inspect(reason, pretty: true)} """) maybe_sleep(delay) do_get(url, headers, query, attempt + 1) else {:transport_error, reason} end end end defp do_get(url, headers, query, attempt) when attempt > @max_retries do # This clause should rarely be hit because the guards above stop at max-1, # but keep it for completeness. Perform one last attempt without retry. options = [ recv_timeout: @recv_timeout, hackney_options: [pool: HttpPool.get()], follow_redirect: true ] query = cond do is_map(query) -> URI.encode_query(query) is_binary(query) -> query true -> "" end url |> URI.new!() |> Map.put(:query, query) |> URI.to_string() |> HTTPoison.get(headers, options) |> case do {:ok, %{status_code: 200, body: body}} -> {:ok, body} {:ok, %{status_code: status_code, body: resp_body}} -> {:http_error, {status_code, resp_body}} {:error, %HTTPoison.Error{reason: reason}} -> {:transport_error, reason} end end defp do_post_json(url, headers, body, attempt) when attempt <= @max_retries do options = [ recv_timeout: @recv_timeout, hackney_options: [pool: HttpPool.get()] ] result = HTTPoison.post(url, body, headers, options) case result do {:ok, %HTTPoison.Response{status_code: 200, body: json, headers: headers}} -> case SafeJson.decode(json) do {:ok, decoded} -> {:ok, %{body: decoded, headers: headers, status: 200}} {:error, _} -> UI.debug("[http] invalid JSON response", safe_debug_body(json)) {:transport_error, :invalid_json_response} end {:ok, %HTTPoison.Response{status_code: status_code, body: resp_body}} -> if retryable_http_status?(status_code) and attempt < @max_retries do delay = backoff_delay(attempt) UI.warn("[http] 50x response", """ POST: #{url} HTTP: #{status_code} Attempt: #{attempt}/#{@max_retries} Retry in: #{delay} ms """) maybe_sleep(delay) do_post_json(url, headers, body, attempt + 1) else {:http_error, {status_code, resp_body}} end {:error, %HTTPoison.Error{reason: reason}} -> if retryable_transport_reason?(reason) and attempt < @max_retries do delay = backoff_delay(attempt) UI.warn("[http] transport error", """ POST: #{url} Attempt: #{attempt}/#{@max_retries} Retry in: #{delay} ms Reason: #{inspect(reason, pretty: true)} """) maybe_sleep(delay) do_post_json(url, headers, body, attempt + 1) else {:transport_error, reason} end end end defp do_post_json(url, headers, body, attempt) when attempt > @max_retries do # This clause should rarely be hit because the guards above stop at max-1, # but keep it for completeness. Perform one last attempt without retry. options = [ recv_timeout: @recv_timeout, hackney_options: [pool: HttpPool.get()] ] result = HTTPoison.post(url, body, headers, options) case result do {:ok, %HTTPoison.Response{status_code: 200, body: json, headers: headers}} -> case SafeJson.decode(json) do {:ok, decoded} -> {:ok, %{body: decoded, headers: headers, status: 200}} {:error, _} -> UI.debug("[http] invalid JSON response", safe_debug_body(json)) {:transport_error, :invalid_json_response} end {:ok, %HTTPoison.Response{status_code: status_code, body: resp_body}} -> {:http_error, {status_code, resp_body}} {:error, %HTTPoison.Error{reason: reason}} -> {:transport_error, reason} end end defp retryable_http_status?(status) when is_integer(status), do: status >= 500 and status < 600 defp retryable_transport_reason?(reason) do case reason do :timeout -> true :closed -> true :connect_timeout -> true :econnrefused -> true :nxdomain -> true :enetdown -> true :ehostunreach -> true {:tls_alert, _} -> true {:closed, _} -> true {:ssl, _} -> true _ -> false end end defp backoff_delay(attempt) when attempt >= 1 do # Exponential backoff with jitter (+/- 20%), capped at @backoff_cap base = trunc(min(@backoff_cap, @base_backoff * :math.pow(2, attempt - 1))) # Jitter factor between 0.8 and 1.2 factor = 0.8 + :rand.uniform() * 0.4 delay = round(base * factor) max(delay, 1) end defp maybe_sleep(ms) do if Services.Globals.get_env(:fnord, :http_retry_skip_sleep, false) do :ok else Process.sleep(ms) end end defp safe_debug_body(body) do body |> IO.iodata_to_binary() |> String.replace_invalid("�") end end