Packages

fnord

0.9.35

AI code archaeology

Current section

Files

Jump to
fnord lib http.ex
Raw

lib/http.ex

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