Packages
fnord
0.8.90
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
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 success :: {:ok, map}
@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} <- Jason.encode(payload) do
do_post_json(url, headers, body, 1)
else
{:error, _} -> {: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()]
]
HTTPoison.post(url, body, headers, options)
|> case do
{:ok, %{status_code: 200, body: json}} ->
Jason.decode(json)
{: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", """
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()]
]
HTTPoison.post(url, body, headers, options)
|> case do
{:ok, %{status_code: 200, body: json}} ->
Jason.decode(json)
{: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 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)
if delay < 1, do: 1, else: delay
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
end