Packages
monzo_client
1.0.0
A complete, production-grade Elixir client for the Monzo API: OAuth2, Accounts, Balance, Pots, Transactions, Feed Items, Attachments, Transaction Receipts, and Webhooks. Zero required dependencies.
Current section
Files
Jump to
Current section
Files
lib/monzo/http.ex
defmodule Monzo.HTTP do
@moduledoc """
Core HTTP request engine shared by every resource module: URL/query/body
encoding, bearer auth, jittered exponential-backoff retries on transient
(429/5xx/network) failures, and a single automatic refresh-and-retry on
401s when the client has a token store configured.
This module is considered internal; resource modules like `Monzo.Accounts`
are the intended public API.
"""
alias Monzo.Client
alias Monzo.Error.{APIError, NetworkError, TimeoutError}
alias Monzo.HTTP.Adapter
alias Monzo.JSON
alias Monzo.TokenStore
@type method :: :get | :post | :put | :patch | :delete
@type body_encoding :: :none | :form | :json
@type request_opts :: [
method: method(),
path: String.t(),
query: %{optional(String.t()) => term()} | keyword(),
encoding: body_encoding(),
form: %{optional(String.t()) => term()},
json: term(),
access_token: String.t() | nil,
idempotent: boolean() | nil
]
@default_timeout_ms 15_000
@default_max_retries 2
@default_base_delay_ms 250
@default_max_delay_ms 4_000
@doc """
Executes a request against the Monzo API described by `opts`, using the
given `Monzo.Client` for configuration (base URL, adapter, token store,
retry policy, etc), and returns the JSON-decoded response body.
"""
@spec request(Client.t(), request_opts()) :: {:ok, term()} | {:error, Exception.t()}
def request(%Client{} = client, opts) do
do_request(client, normalize_opts(opts), 0, false)
end
defp normalize_opts(opts) do
%{
method: Keyword.fetch!(opts, :method),
path: Keyword.fetch!(opts, :path),
query: Keyword.get(opts, :query, %{}),
encoding: Keyword.get(opts, :encoding, :none),
form: Keyword.get(opts, :form, %{}),
json: Keyword.get(opts, :json),
access_token: Keyword.get(opts, :access_token),
idempotent: Keyword.get(opts, :idempotent)
}
end
defp do_request(client, opts, attempt, already_refreshed?) do
case perform(client, opts) do
{:ok, body} ->
{:ok, body}
{:error, %APIError{} = error} ->
handle_api_error(client, opts, error, attempt, already_refreshed?)
{:error, %NetworkError{}} = error ->
handle_network_error(client, opts, error, attempt, already_refreshed?)
other_error ->
other_error
end
end
defp handle_api_error(client, opts, error, attempt, already_refreshed?) do
cond do
should_refresh?(client, error, already_refreshed?) ->
refresh_and_retry(client, opts, error, attempt)
should_retry?(client, opts, error, attempt) ->
retry_request(client, opts, attempt, already_refreshed?)
true ->
{:error, error}
end
end
defp should_refresh?(client, error, already_refreshed?) do
APIError.invalid_token?(error) and
not already_refreshed? and
client.token_store != nil
end
defp refresh_and_retry(client, opts, error, attempt) do
case TokenStore.refresh(client.token_store) do
{:ok, new_token} ->
do_request(client, %{opts | access_token: new_token}, attempt, true)
{:error, _reason} ->
{:error, error}
end
end
defp should_retry?(client, opts, error, attempt) do
idempotent?(opts) and
APIError.retryable?(error) and
attempt < max_retries(client)
end
defp retry_request(client, opts, attempt, already_refreshed?) do
Process.sleep(backoff_delay(client, attempt))
do_request(client, opts, attempt + 1, already_refreshed?)
end
defp handle_network_error(client, opts, error, attempt, already_refreshed?) do
if idempotent?(opts) and attempt < max_retries(client) do
delay = backoff_delay(client, attempt)
Process.sleep(delay)
do_request(client, opts, attempt + 1, already_refreshed?)
else
error
end
end
defp idempotent?(%{idempotent: nil, method: method}), do: method in [:get, :put, :delete]
defp idempotent?(%{idempotent: idempotent?}), do: idempotent?
defp max_retries(%Client{retry: retry}), do: retry.max_retries
defp backoff_delay(%Client{retry: retry}, attempt) do
exp = min(retry.base_delay_ms * Integer.pow(2, attempt), retry.max_delay_ms)
:rand.uniform(exp + 1) - 1
end
defp perform(client, opts) do
url = build_url(client, opts)
{content_type, body} = encode_body(opts)
token =
opts.access_token ||
(client.token_store && TokenStore.access_token(client.token_store))
headers =
[{"accept", "application/json"}, {"user-agent", client.user_agent}]
|> maybe_add_header("content-type", content_type)
|> maybe_add_header("authorization", token && "Bearer #{token}")
timeout_ms = client.timeout_ms || @default_timeout_ms
request = %{
method: opts.method,
url: url,
headers: headers,
body: body,
timeout_ms: timeout_ms
}
client.logger.(
:debug,
"Monzo #{opts.method |> Atom.to_string() |> String.upcase()} #{opts.path}"
)
case Adapter.dispatch(client.adapter, request) do
{:ok, %{status: status} = response} when status in 200..299 ->
decode_response_body(response)
{:ok, response} ->
{:error, build_api_error(opts, response)}
{:error, {:timeout, _reason}} ->
{:error, %TimeoutError{path: opts.path, timeout_ms: timeout_ms}}
{:error, reason} ->
{:error, %NetworkError{path: opts.path, reason: reason}}
end
end
defp maybe_add_header(headers, _name, nil), do: headers
defp maybe_add_header(headers, name, value), do: [{name, value} | headers]
defp build_url(client, opts) do
query = encode_query(opts.query)
base = client.base_url <> opts.path
if query == "", do: base, else: base <> "?" <> query
end
defp encode_query(query) when query == %{} or query == [], do: ""
defp encode_query(query) do
query
|> Enum.flat_map(fn
{_key, nil} -> []
{key, values} when is_list(values) -> Enum.map(values, &{query_key(key), &1})
{key, value} -> [{query_key(key), value}]
end)
|> Enum.map_join("&", fn {key, value} ->
"#{uri_encode(key)}=#{uri_encode(to_string(value))}"
end)
end
defp query_key(key) when is_atom(key), do: Atom.to_string(key)
defp query_key(key), do: to_string(key)
defp uri_encode(value), do: URI.encode_www_form(value)
defp encode_body(%{encoding: :none}), do: {nil, nil}
defp encode_body(%{encoding: :form, form: form}) do
body =
form
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.map_join("&", fn {k, v} ->
"#{uri_encode(to_string(k))}=#{uri_encode(to_string(v))}"
end)
{"application/x-www-form-urlencoded", body}
end
defp encode_body(%{encoding: :json, json: json}) do
{:ok, body} = JSON.encode(json)
{"application/json", body}
end
defp decode_response_body(%{body: ""}), do: {:ok, %{}}
defp decode_response_body(%{body: body}) do
case JSON.decode(body) do
{:ok, decoded} -> {:ok, decoded}
{:error, _reason} -> {:ok, %{}}
end
end
defp build_api_error(opts, response) do
parsed =
case JSON.decode(response.body) do
{:ok, decoded} when is_map(decoded) -> decoded
_ -> %{}
end
%APIError{
status: response.status,
method: opts.method |> Atom.to_string() |> String.upcase(),
path: opts.path,
code: parsed["code"] || parsed["error"],
message: parsed["error_description"] || parsed["message"],
request_id:
find_header(response.headers, "x-request-id") ||
find_header(response.headers, "x-monzo-request-id"),
params: parsed["params"]
}
end
defp find_header(headers, name) do
Enum.find_value(headers, fn {k, v} -> if String.downcase(k) == name, do: v end)
end
@doc false
@spec default_timeout_ms() :: pos_integer()
def default_timeout_ms, do: @default_timeout_ms
@doc false
@spec default_retry_policy() :: %{
max_retries: non_neg_integer(),
base_delay_ms: pos_integer(),
max_delay_ms: pos_integer()
}
def default_retry_policy do
%{
max_retries: @default_max_retries,
base_delay_ms: @default_base_delay_ms,
max_delay_ms: @default_max_delay_ms
}
end
end