defmodule AWS.Client do
@moduledoc """
Provides credentials and connection details for making requests to AWS services.
You can configure `access_key_id` and `secret_access_key` which are the credentials
needed by [IAM](https://aws.amazon.com/iam), and also the `region` for your services.
The list of regions can be found in the [AWS service endpoints](https://docs.aws.amazon.com/general/latest/gr/rande.html)
documentation. You can also use "local" to make requests to your localhost.
## Custom HTTP client
The option `http_client` accepts a tuple with a module and a list of options.
The module must implement the callback `c:AWS.HTTPClient.request/5`.
## Custom JSON or XML parsers
You can configure a custom JSON parser by using the option `json_module`. This
option accepts a tuple with a module and options. The given module must
implement the callbacks from `AWS.JSON`.
Similarly, there is a `xml_module` option that configures the XML parser. The
XML module must implement the callbacks from `AWS.XML`.
## Additional options
* `:session_token` - an option to set the `X-Amz-Security-Token` when performing
the requests.
* `:port` - is the port to use when making requests. Defaults to `443`.
* `:proto` - is the protocol to use. It can be "http" or "https". Defaults to `"https"`.
* `:endpoint` - the AWS endpoint. Defaults to `"amazonaws.com"`. You can configure this
using `put_endpoint/2` for AWS compatible APIs.
The `service` option is overwritten by each service with its signing name from metadata.
"""
@derive {Inspect, except: [:access_key_id, :secret_access_key, :session_token]}
defstruct access_key_id: nil,
secret_access_key: nil,
session_token: nil,
region: nil,
service: nil,
endpoint: nil,
proto: "https",
port: 443,
http_client: {AWS.HTTPClient.Hackney, []},
json_module: {AWS.JSON, []},
xml_module: {AWS.XML, []}
@typedoc """
The endpoint configuration.
Check `put_endpoint/2` for more details.
"""
@type endpoint_config :: binary() | {:keep_prefixes, binary()} | (map() -> binary()) | nil
@type t :: %__MODULE__{
access_key_id: binary() | nil,
secret_access_key: binary() | nil,
session_token: binary() | nil,
region: binary() | nil,
service: binary() | nil,
endpoint: endpoint_config(),
proto: binary(),
port: non_neg_integer(),
http_client: {module(), keyword()},
json_module: {module(), keyword()},
xml_module: {module(), keyword()}
}
@aws_default_endpoint "amazonaws.com"
@aws_access_key_id "AWS_ACCESS_KEY_ID"
@aws_secret_access_key "AWS_SECRET_ACCESS_KEY"
@aws_session_token "AWS_SESSION_TOKEN"
@aws_default_region "AWS_DEFAULT_REGION"
@doc """
The default endpoint.
Check `put_endpoint/2` for more details on how to configure
a custom endpoint.
"""
def default_endpoint, do: @aws_default_endpoint
def create() do
case System.get_env(@aws_default_region) do
nil -> raise RuntimeError, "missing default region"
region -> create(region)
end
end
def create(region) do
case {System.get_env(@aws_access_key_id), System.get_env(@aws_secret_access_key),
System.get_env(@aws_session_token)} do
{nil, _, _} ->
raise RuntimeError, "missing access key id"
{_, nil, _} ->
raise RuntimeError, "missing secret access key"
{access_key_id, secret_access_key, nil} ->
create(access_key_id, secret_access_key, region)
{access_key_id, secret_access_key, token} ->
create(access_key_id, secret_access_key, token, region)
end
end
def create(access_key_id, secret_access_key, region) do
%AWS.Client{
access_key_id: access_key_id,
secret_access_key: secret_access_key,
region: region
}
end
def create(access_key_id, secret_access_key, token, region) do
%AWS.Client{
access_key_id: access_key_id,
secret_access_key: secret_access_key,
session_token: token,
region: region
}
end
@doc """
Configures the endpoint to a custom one.
This is useful to set a custom endpoint when working with AWS
compatible APIs.
The following configuration is valid:
* `"example.com"`: this will be used as it is, without considering
regions or endpoint prefixes or account id.
* `{:keep_prefixes, "example.com"}`: it will keep the same rules from
AWS to build the prefixes.
For example, if region is "us-east-1" and the service prefix is "s3",
then the final endpoint will be `"s3.us-east-1.example.com"`
* `fn options -> "example.com" end`: a function that will be invoked
with options for that request. `options` is a map with the following
shape:
```
%{
endpoint: endpoint_config(),
region: binary() | nil,
service: binary(),
global?: boolean(),
endpoint_prefix: binary(),
account_id: binary() | nil
}
```
## Examples
iex> put_endpoint(%Client{}, "example.com")
%Client{endpoint: "example.com}
iex> put_endpoint(%Client{}, {:keep_prefixes, "example.com"})
%Client{endpoint: {:keep_prefixes, "example.com}}
iex> put_endpoint(%Client{}, fn opts -> Enum.join(["baz", opts.region, "foo.com"], ".") end)
%Client{endpoint: #Function<>}
"""
def put_endpoint(%__MODULE__{} = client, endpoint_config)
when is_binary(endpoint_config) or is_function(endpoint_config, 1) do
%{client | endpoint: endpoint_config}
end
def put_endpoint(%__MODULE__{} = client, {:keep_prefixes, endpoint} = endpoint_config)
when is_binary(endpoint) do
%{client | endpoint: endpoint_config}
end
@doc """
Configures the HTTP client used by a given client.
Switches to a different HTTP client, defined by a `{module, opts}` tuple. See
`AWS.HTTPClient` behavior for more details.
### Examples
iex> AWS.Client.put_http_client(%AWS.Client{}, {MyHTTPClient, []})
%AWS.Client{http_client: {MyHTTPClient, []}}
iex> AWS.Client.put_http_client(%AWS.Client{}, {AWS.HTTPClient.Finch, finch_name: AWS.Finch})
%AWS.Client{http_client: {AWS.HTTPClient.Finch, finch_name: AWS.Finch}}
"""
def put_http_client(%__MODULE__{} = client, http_client) do
%{client | http_client: http_client}
end
@doc """
Makes a HTTP request using the specified client.
## Retries and options.
The option `:enable_retries?` enables request retries on known errors such as 500s.
* `enable_retries?` - Defaults to `false`.
* `retry_opts` - the options to configure retries in case of errors. This uses exponential backoff with jitter.
* `:max_retries` - the maximum number of retries (plus the initial request). Defaults to `10`.
* `:base_sleep_time` - the base sleep time in milliseconds. Defaults to `5`.
* `:cap_sleep_time` - the maximum sleep time between atttempts. Defaults to `5_000`.
See "FullJitter" at: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
## Examples
iex> AWS.Client.request(client, :post, url, payload, headers, options)
{:ok, %{status_code: 200, body: body}}
iex> AWS.Client.request(client, :post, url, payload, headers, enable_retries?: true)
{:ok, %{status_code: 200, body: body}}
iex> AWS.Client.request(client, :post, url, payload, headers, enable_retries?: true, retry_opts: [max_retries: 3])
{:ok, %{status_code: 200, body: body}}
"""
def request(client, method, url, body, headers, opts \\ []) do
# Pop off all retry-related options from opts, so they aren't passed to the HTTP client.
{enable_retries?, opts} = Keyword.pop(opts, :enable_retries?, false)
{retry_num, opts} = Keyword.pop(opts, :retry_num, 0)
{retry_opts, opts} = Keyword.pop(opts, :retry_opts, [])
# Defaults for retry_opts
retry_opts =
Keyword.merge([max_retries: 10, base_sleep_time: 5, cap_sleep_time: 5_000], retry_opts)
# HTTP Client options
{mod, options} = Map.fetch!(client, :http_client)
options = Keyword.merge(options, opts)
resp = apply(mod, :request, [method, url, body, headers, options])
retriable?(resp)
|> case do
:ok ->
resp
:retry ->
if enable_retries? and should_retry?(retry_num, retry_opts) do
updated_opts =
Keyword.merge(opts,
retry_num: retry_num + 1,
enable_retries?: enable_retries?,
retry_opts: retry_opts
)
request(client, method, url, body, headers, updated_opts)
else
resp
end
:error ->
resp
end
end
def should_retry?(retry_num, retry_opts) do
max_retries = Keyword.fetch!(retry_opts, :max_retries)
if retry_num >= max_retries do
# The max-limit of retries has been reached. Give up.
false
else
# Sleep and retry
base_sleep_time = Keyword.fetch!(retry_opts, :base_sleep_time)
cap_sleep_time = Keyword.fetch!(retry_opts, :cap_sleep_time)
# This equivalent to "FullJitter" in https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
max_sleep_time =
min(cap_sleep_time, base_sleep_time * :math.pow(2, retry_num))
|> round()
Enum.random(0..max_sleep_time)
|> Process.sleep()
true
end
end
# Retry on 500
defp retriable?({:ok, %{status_code: status}}) when status >= 500, do: :retry
# Hackney specific
defp retriable?({:error, :closed}), do: :retry
defp retriable?({:error, :connect_timeout}), do: :retry
defp retriable?({:error, :checkout_timeout}), do: :retry
# Finch/Mint specific
defp retriable?({:error, %{reason: :closed}}), do: :retry
defp retriable?({:error, %{reason: :timeout}}), do: :retry
# Do not retry on other erors
defp retriable?({:error, _}), do: :error
defp retriable?({:ok, _}), do: :ok
defp retriable?({:ok, _, _}), do: :ok
def encode!(_client, payload, :query), do: AWS.Util.encode_query(payload)
def encode!(client, payload, format) do
{mod, opts} = Map.fetch!(client, String.to_existing_atom("#{format}_module"))
apply(mod, :encode_to_iodata!, [payload, opts])
end
def decode!(client, payload, format) do
{mod, opts} = Map.fetch!(client, String.to_existing_atom("#{format}_module"))
apply(mod, :decode!, [payload, opts])
end
end