This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

View File

@@ -0,0 +1,754 @@
defmodule Finch do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
alias Finch.{PoolManager, Request, Response}
require Finch.Pool
use Supervisor
@default_pool_size 50
@default_pool_count 1
@default_connect_timeout 5_000
@pool_config_schema [
protocol: [
type: {:in, [:http2, :http1]},
deprecated: "Use `:protocols` instead."
],
protocols: [
type: {:list, {:in, [:http1, :http2]}},
doc: """
The type of connections to support.
If using `:http1` only, an HTTP1 pool without multiplexing is used. \
If using `:http2` only, an HTTP2 pool with multiplexing is used. \
If both are listed, then both HTTP1/HTTP2 connections are \
supported (via ALPN), but there is no multiplexing.
""",
default: [:http1]
],
size: [
type: :pos_integer,
doc: """
Number of connections to maintain in each pool. Used only by HTTP1 pools \
since HTTP2 is able to multiplex requests through a single connection. In \
other words, for HTTP2, the size is always 1 and the `:count` should be \
configured in order to increase capacity.
""",
default: @default_pool_size
],
count: [
type: :pos_integer,
doc: """
Number of pools to start. HTTP1 pools are able to re-use connections in the \
same pool and establish new ones only when necessary. However, if there is a \
high pool count and few requests are made, these requests will be scattered \
across pools, reducing connection reuse. It is recommended to increase the pool \
count for HTTP1 only if you are experiencing high checkout times.
""",
default: @default_pool_count
],
max_idle_time: [
type: :timeout,
doc: """
The maximum number of milliseconds an HTTP1 connection is allowed to be idle \
before being closed during a checkout attempt.
""",
deprecated: "Use :conn_max_idle_time instead."
],
conn_opts: [
type: :keyword_list,
doc: """
These options are passed to `Mint.HTTP.connect/4` whenever a new connection is established. \
`:mode` is not configurable as Finch must control this setting. Typically these options are \
used to configure proxying, https settings, or connect timeouts.
""",
default: []
],
pool_max_idle_time: [
type: :timeout,
doc: """
The maximum number of milliseconds that a pool can be idle before being terminated, used only by HTTP1 pools. \
This options is forwarded to NimblePool and it starts and idle verification cycle that may impact \
performance if misused. For instance setting a very low timeout may lead to pool restarts. \
For more information see NimblePool's `handle_ping/2` documentation.
""",
default: :infinity
],
conn_max_idle_time: [
type: :timeout,
doc: """
The maximum number of milliseconds an HTTP1 connection is allowed to be idle \
before being closed during a checkout attempt.
""",
default: :infinity
],
start_pool_metrics?: [
type: :boolean,
doc: "When true, pool metrics will be collected and available through `get_pool_status/2`",
default: false
]
]
@typedoc """
The `:name` provided to Finch in `start_link/1`.
"""
@type name() :: atom()
@type scheme() :: :http | :https
@type scheme_host_port() :: {scheme(), host :: String.t(), port :: :inet.port_number()}
@typedoc """
Pool metrics returned by `get_pool_status/2` for a single pool.
"""
@type pool_metrics() ::
[Finch.HTTP1.PoolMetrics.t()]
| [Finch.HTTP2.PoolMetrics.t()]
@typedoc """
Pool metrics grouped by SHP when querying the `:default` configuration.
"""
@type default_pool_metrics() :: %{required(scheme_host_port()) => pool_metrics()}
@type request_opt() ::
{:pool_timeout, timeout()}
| {:receive_timeout, timeout()}
| {:request_timeout, timeout()}
@typedoc """
Options used by request functions.
"""
@type request_opts() :: [request_opt()]
@typedoc """
The reference used to identify a request sent using `async_request/3`.
"""
@opaque request_ref() :: Finch.Pool.request_ref()
@typedoc """
The stream function given to `stream/5`.
"""
@type stream(acc) ::
({:status, integer}
| {:headers, Mint.Types.headers()}
| {:data, binary}
| {:trailers, Mint.Types.headers()},
acc ->
acc)
@typedoc """
The stream function given to `stream_while/5`.
"""
@type stream_while(acc) ::
({:status, integer}
| {:headers, Mint.Types.headers()}
| {:data, binary}
| {:trailers, Mint.Types.headers()},
acc ->
{:cont, acc} | {:halt, acc})
@doc """
Start an instance of Finch.
## Options
* `:name` - The name of your Finch instance. This field is required.
* `:pools` - A map specifying the configuration for your pools. The keys should be URLs
provided as binaries, a tuple `{scheme, {:local, unix_socket}}` where `unix_socket` is the path for
the socket, or the atom `:default` to provide a catch-all configuration to be used for any
unspecified URLs - meaning that new pools for unspecified URLs will be started using the `:default`
configuration. See "Pool Configuration Options" below for details on the possible map
values. Default value is `%{default: [size: #{@default_pool_size}, count: #{@default_pool_count}]}`.
### Pool Configuration Options
#{NimbleOptions.docs(@pool_config_schema)}
"""
def start_link(opts) do
name = finch_name!(opts)
pools = Keyword.get(opts, :pools, []) |> pool_options!()
{default_pool_config, pools} = Map.pop(pools, :default)
config = %{
registry_name: name,
manager_name: manager_name(name),
supervisor_name: pool_supervisor_name(name),
default_pool_config: default_pool_config,
pools: pools
}
Supervisor.start_link(__MODULE__, config, name: supervisor_name(name))
end
def child_spec(opts) do
%{
id: finch_name!(opts),
start: {__MODULE__, :start_link, [opts]}
}
end
@impl true
def init(config) do
children = [
{Registry, [keys: :duplicate, name: config.registry_name, meta: [config: config]]},
{DynamicSupervisor, name: config.supervisor_name, strategy: :one_for_one},
{PoolManager, config}
]
Supervisor.init(children, strategy: :one_for_all)
end
defp finch_name!(opts) do
Keyword.get(opts, :name) || raise(ArgumentError, "must supply a name")
end
defp pool_options!(pools) do
{:ok, default} = NimbleOptions.validate([], @pool_config_schema)
Enum.reduce(pools, %{default: valid_opts_to_map(default)}, fn {destination, opts}, acc ->
with {:ok, valid_destination} <- cast_destination(destination),
{:ok, valid_pool_opts} <- cast_pool_opts(opts) do
Map.put(acc, valid_destination, valid_pool_opts)
else
{:error, reason} ->
raise reason
end
end)
end
defp cast_destination(destination) do
case destination do
:default ->
{:ok, destination}
{scheme, {:local, path}} when is_atom(scheme) and is_binary(path) ->
{:ok, {scheme, {:local, path}, 0}}
url when is_binary(url) ->
cast_binary_destination(url)
_ ->
{:error, %ArgumentError{message: "invalid destination: #{inspect(destination)}"}}
end
end
defp cast_binary_destination(url) when is_binary(url) do
{scheme, host, port, _path, _query} = Finch.Request.parse_url(url)
{:ok, {scheme, host, port}}
end
defp cast_pool_opts(opts) do
with {:ok, valid} <- NimbleOptions.validate(opts, @pool_config_schema) do
{:ok, valid_opts_to_map(valid)}
end
end
defp valid_opts_to_map(valid) do
# We need to enable keepalive and set the nodelay flag to true by default.
transport_opts =
valid
|> get_in([:conn_opts, :transport_opts])
|> List.wrap()
|> Keyword.put_new(:timeout, @default_connect_timeout)
|> Keyword.put_new(:nodelay, true)
|> Keyword.put(:keepalive, true)
conn_opts = valid[:conn_opts] |> List.wrap()
# Only relevant to HTTP2, but just gracefully ignored in HTTP1.
# Since we cannot handle server push responses, we need to disable the feature.
client_settings =
conn_opts
|> Keyword.get(:client_settings, [])
|> Keyword.put(:enable_push, false)
ssl_key_log_file =
Keyword.get(conn_opts, :ssl_key_log_file) || System.get_env("SSLKEYLOGFILE")
ssl_key_log_file_device = ssl_key_log_file && File.open!(ssl_key_log_file, [:append])
conn_opts =
conn_opts
|> Keyword.put(:ssl_key_log_file_device, ssl_key_log_file_device)
|> Keyword.put(:transport_opts, transport_opts)
|> Keyword.put(:protocols, valid[:protocols])
|> Keyword.put(:client_settings, client_settings)
# TODO: Remove :protocol on v0.18
mod =
case valid[:protocol] do
:http1 ->
Finch.HTTP1.Pool
:http2 ->
Finch.HTTP2.Pool
nil ->
if :http1 in valid[:protocols] do
Finch.HTTP1.Pool
else
Finch.HTTP2.Pool
end
end
%{
mod: mod,
size: valid[:size],
count: valid[:count],
conn_opts: conn_opts,
conn_max_idle_time: to_native(valid[:max_idle_time] || valid[:conn_max_idle_time]),
pool_max_idle_time: valid[:pool_max_idle_time],
start_pool_metrics?: valid[:start_pool_metrics?]
}
end
defp to_native(:infinity), do: :infinity
defp to_native(time), do: System.convert_time_unit(time, :millisecond, :native)
defp supervisor_name(name), do: :"#{name}.Supervisor"
defp manager_name(name), do: :"#{name}.PoolManager"
defp pool_supervisor_name(name), do: :"#{name}.PoolSupervisor"
defmacrop request_span(request, name, do: block) do
quote do
start_meta = %{request: unquote(request), name: unquote(name)}
Finch.Telemetry.span(:request, start_meta, fn ->
result = unquote(block)
end_meta = Map.put(start_meta, :result, result)
{result, end_meta}
end)
end
end
@doc """
Builds an HTTP request to be sent with `request/3` or `stream/4`.
It is possible to send the request body in a streaming fashion. In order to do so, the
`body` parameter needs to take form of a tuple `{:stream, body_stream}`, where `body_stream`
is a `Stream`.
"""
@spec build(Request.method(), Request.url(), Request.headers(), Request.body(), Keyword.t()) ::
Request.t()
defdelegate build(method, url, headers \\ [], body \\ nil, opts \\ []), to: Request
@doc """
Streams an HTTP request and returns the accumulator.
A function of arity 2 is expected as argument. The first argument
is a tuple, as listed below, and the second argument is the
accumulator. The function must return a potentially updated
accumulator.
See also `stream_while/5`.
> ### HTTP2 streaming and back-pressure {: .warning}
>
> At the moment, streaming over HTTP2 connections do not provide
> any back-pressure mechanism: this means the response will be
> sent to the client as quickly as possible. Therefore, you must
> not use streaming over HTTP2 for non-terminating responses or
> when streaming large responses which you do not intend to keep
> in memory.
## Stream commands
* `{:status, status}` - the http response status
* `{:headers, headers}` - the http response headers
* `{:data, data}` - a streaming section of the http response body
* `{:trailers, trailers}` - the http response trailers
## Options
Shares options with `request/3`.
## Examples
path = "/tmp/archive.zip"
file = File.open!(path, [:write, :exclusive])
url = "https://example.com/archive.zip"
request = Finch.build(:get, url)
Finch.stream(request, MyFinch, nil, fn
{:status, status}, _acc ->
IO.inspect(status)
{:headers, headers}, _acc ->
IO.inspect(headers)
{:data, data}, _acc ->
IO.binwrite(file, data)
end)
File.close(file)
"""
@spec stream(Request.t(), name(), acc, stream(acc), request_opts()) ::
{:ok, acc} | {:error, Exception.t(), acc}
when acc: term()
def stream(%Request{} = req, name, acc, fun, opts \\ []) when is_function(fun, 2) do
fun = fn entry, acc ->
{:cont, fun.(entry, acc)}
end
stream_while(req, name, acc, fun, opts)
end
@doc """
Streams an HTTP request until it finishes or `fun` returns `{:halt, acc}`.
A function of arity 2 is expected as argument. The first argument
is a tuple, as listed below, and the second argument is the
accumulator.
The function must return:
* `{:cont, acc}` to continue streaming
* `{:halt, acc}` to halt streaming
See also `stream/5`.
> ### HTTP2 streaming and back-pressure {: .warning}
>
> At the moment, streaming over HTTP2 connections do not provide
> any back-pressure mechanism: this means the response will be
> sent to the client as quickly as possible. Therefore, you must
> not use streaming over HTTP2 for non-terminating responses or
> when streaming large responses which you do not intend to keep
> in memory.
## Stream commands
* `{:status, status}` - the http response status
* `{:headers, headers}` - the http response headers
* `{:data, data}` - a streaming section of the http response body
* `{:trailers, trailers}` - the http response trailers
## Options
Shares options with `request/3`.
## Examples
path = "/tmp/archive.zip"
file = File.open!(path, [:write, :exclusive])
url = "https://example.com/archive.zip"
request = Finch.build(:get, url)
Finch.stream_while(request, MyFinch, nil, fn
{:status, status}, acc ->
IO.inspect(status)
{:cont, acc}
{:headers, headers}, acc ->
IO.inspect(headers)
{:cont, acc}
{:data, data}, acc ->
IO.binwrite(file, data)
{:cont, acc}
end)
File.close(file)
"""
@spec stream_while(Request.t(), name(), acc, stream_while(acc), request_opts()) ::
{:ok, acc} | {:error, Exception.t(), acc}
when acc: term()
def stream_while(%Request{} = req, name, acc, fun, opts \\ []) when is_function(fun, 2) do
request_span req, name do
__stream__(req, name, acc, fun, opts)
end
end
defp __stream__(%Request{} = req, name, acc, fun, opts) do
{pool, pool_mod} = get_pool(req, name)
pool_mod.request(pool, req, acc, fun, name, opts)
end
@doc """
Sends an HTTP request and returns a `Finch.Response` struct.
It can still raise exceptions if it was not possible to check out a connection in the given `:pool_timeout`.
## Options
* `:pool_timeout` - This timeout is applied when we check out a connection from the pool.
Default value is `5_000`.
* `:receive_timeout` - The maximum time to wait for each chunk to be received before returning an error.
Default value is `15_000`.
* `:request_timeout` - The amount of time to wait for a complete response before returning an error.
This timeout only applies to HTTP/1, and its current implementation is a best effort timeout,
it does not guarantee the call will return precisely when the time has elapsed.
Default value is `:infinity`.
"""
@spec request(Request.t(), name(), request_opts()) ::
{:ok, Response.t()}
| {:error, Exception.t()}
def request(req, name, opts \\ [])
def request(%Request{} = req, name, opts) do
request_span req, name do
acc = {nil, [], [], []}
fun = fn
{:status, value}, {_, headers, body, trailers} ->
{:cont, {value, headers, body, trailers}}
{:headers, value}, {status, headers, body, trailers} ->
{:cont, {status, headers ++ value, body, trailers}}
{:data, value}, {status, headers, body, trailers} ->
{:cont, {status, headers, [body | value], trailers}}
{:trailers, value}, {status, headers, body, trailers} ->
{:cont, {status, headers, body, trailers ++ value}}
end
case __stream__(req, name, acc, fun, opts) do
{:ok, {status, headers, body, trailers}} ->
{:ok,
%Response{
status: status,
headers: headers,
body: IO.iodata_to_binary(body),
trailers: trailers
}}
{:error, error, _acc} ->
{:error, error}
end
end
end
# Catch-all for backwards compatibility below
def request(name, method, url) do
request(name, method, url, [])
end
@doc false
def request(name, method, url, headers, body \\ nil, opts \\ []) do
IO.warn("Finch.request/6 is deprecated, use Finch.build/5 + Finch.request/3 instead")
build(method, url, headers, body)
|> request(name, opts)
end
@doc """
Sends an HTTP request and returns a `Finch.Response` struct
or raises an exception in case of failure.
See `request/3` for more detailed information.
"""
@spec request!(Request.t(), name(), request_opts()) ::
Response.t()
def request!(%Request{} = req, name, opts \\ []) do
case request(req, name, opts) do
{:ok, resp} -> resp
{:error, exception} -> raise exception
end
end
@doc """
Sends an HTTP request asynchronously, returning a request reference.
If the request is sent using HTTP1, an extra process is spawned to
consume messages from the underlying socket. The messages are sent
to the current process as soon as they arrive, as a firehose. If
you wish to maximize request rate or have more control over how
messages are streamed, a strategy using `request/3` or `stream/5`
should be used instead.
## Receiving the response
Response information is sent to the calling process as it is received
in `{ref, response}` tuples.
If the calling process exits before the request has completed, the
request will be canceled.
Responses include:
* `{:status, status}` - HTTP response status
* `{:headers, headers}` - HTTP response headers
* `{:data, data}` - section of the HTTP response body
* `{:error, exception}` - an error occurred during the request
* `:done` - request has completed successfully
On a successful request, a single `:status` message will be followed
by a single `:headers` message, after which more than one `:data`
messages may be sent. If trailing headers are present, a final
`:headers` message may be sent. Any `:done` or `:error` message
indicates that the request has succeeded or failed and no further
messages are expected.
## Example
iex> req = Finch.build(:get, "https://httpbin.org/stream/5")
iex> ref = Finch.async_request(req, MyFinch)
iex> flush()
{ref, {:status, 200}}
{ref, {:headers, [...]}}
{ref, {:data, "..."}}
{ref, :done}
## Options
Shares options with `request/3`.
"""
@spec async_request(Request.t(), name(), request_opts()) :: request_ref()
def async_request(%Request{} = req, name, opts \\ []) do
{pool, pool_mod} = get_pool(req, name)
pool_mod.async_request(pool, req, name, opts)
end
@doc """
Cancels a request sent with `async_request/3`.
"""
@spec cancel_async_request(request_ref()) :: :ok
def cancel_async_request(request_ref) when Finch.Pool.is_request_ref(request_ref) do
{pool_mod, _cancel_ref} = request_ref
pool_mod.cancel_async_request(request_ref)
end
defp get_pool(%Request{scheme: scheme, unix_socket: unix_socket}, name)
when is_binary(unix_socket) do
PoolManager.get_pool(name, {scheme, {:local, unix_socket}, 0})
end
defp get_pool(%Request{scheme: scheme, host: host, port: port}, name) do
PoolManager.get_pool(name, {scheme, host, port})
end
@doc """
Get pool metrics.
When given a URL or SHP tuple, this returns the metrics list for that specific
pool. The number of items in the metrics list depends on the configured
`:count` option and each entry will have a `pool_index` going from 1 to
`:count`.
When `:default` is provided, Finch returns the metrics for all pools started
from the `:default` configuration. In this case the return value is a map
keyed by each pool's `{scheme, host, port}` tuple with the corresponding
metrics list as the value.
The metrics struct depends on the pool scheme defined in the `:protocols`
option: `Finch.HTTP1.PoolMetrics` for `:http1` and `Finch.HTTP2.PoolMetrics`
for `:http2`. See the documentation for those modules for more details.
`{:error, :not_found}` is returned in the following scenarios:
* There is no pool registered for the given Finch instance and URL/SHP.
* The pool has `start_pool_metrics?: false` (the default).
* `:default` is provided but no pools have been started from the
`:default` configuration (or none have metrics enabled).
## Examples
iex> Finch.get_pool_status(MyFinch, "https://httpbin.org")
{:ok, [
%Finch.HTTP1.PoolMetrics{
pool_index: 1,
pool_size: 50,
available_connections: 43,
in_use_connections: 7
},
%Finch.HTTP1.PoolMetrics{
pool_index: 2,
pool_size: 50,
available_connections: 37,
in_use_connections: 13
}]
}
iex> Finch.get_pool_status(MyFinch, :default)
{:ok,
%{
{:https, "httpbin.org", 443} => [
%Finch.HTTP1.PoolMetrics{
pool_index: 1,
pool_size: 50,
available_connections: 43,
in_use_connections: 7
}
]
}}
"""
@spec get_pool_status(name(), url :: String.t() | scheme_host_port() | :default) ::
{:ok, pool_metrics()}
| {:ok, default_pool_metrics()}
| {:error, :not_found}
def get_pool_status(finch_name, url) when is_binary(url) do
{s, h, p, _, _} = Request.parse_url(url)
get_pool_status(finch_name, {s, h, p})
end
def get_pool_status(finch_name, :default) do
finch_name
|> PoolManager.get_default_shps()
|> Enum.reduce(%{}, fn shp, acc ->
case get_pool_status(finch_name, shp) do
{:ok, metrics} -> Map.put(acc, shp, metrics)
{:error, :not_found} -> acc
end
end)
|> case do
result when result == %{} -> {:error, :not_found}
result -> {:ok, result}
end
end
def get_pool_status(finch_name, shp) when is_tuple(shp) do
case PoolManager.get_pool(finch_name, shp, auto_start?: false) do
{_pool, pool_mod} ->
pool_mod.get_pool_status(finch_name, shp)
:not_found ->
{:error, :not_found}
end
end
@doc """
Stops the pool of processes associated with the given scheme, host, port (aka SHP).
This function can be invoked to manually stop the pool to the given SHP when you know it's not
going to be used anymore.
Note that this function is not safe with respect to concurrent requests. Invoking it while
another request to the same SHP is taking place might result in the failure of that request. It
is the responsibility of the client to ensure that no request to the same SHP is taking place
while this function is being invoked.
"""
@spec stop_pool(name(), url :: String.t() | scheme_host_port()) :: :ok | {:error, :not_found}
def stop_pool(finch_name, url) when is_binary(url) do
{s, h, p, _, _} = Request.parse_url(url)
stop_pool(finch_name, {s, h, p})
end
def stop_pool(finch_name, shp) when is_tuple(shp) do
case PoolManager.all_pool_instances(finch_name, shp) do
[] ->
{:error, :not_found}
children ->
Enum.each(
children,
fn {pid, _module} ->
DynamicSupervisor.terminate_child(pool_supervisor_name(finch_name), pid)
end
)
PoolManager.maybe_remove_default_shp(finch_name, shp)
:ok
end
end
end

View File

@@ -0,0 +1,21 @@
defmodule Finch.Error do
@moduledoc """
An HTTP error.
This exception struct is used to represent errors of all sorts for the HTTP/2 protocol.
"""
@type t() :: %__MODULE__{reason: atom()}
defexception [:reason]
@impl true
def exception(reason) when is_atom(reason) do
%__MODULE__{reason: reason}
end
@impl true
def message(%__MODULE__{reason: reason}) do
"#{reason}"
end
end

View File

@@ -0,0 +1,372 @@
defmodule Finch.HTTP1.Conn do
@moduledoc false
alias Finch.SSL
alias Finch.Telemetry
def new(scheme, host, port, opts, parent) do
%{
scheme: scheme,
host: host,
port: port,
opts: opts.conn_opts,
parent: parent,
last_checkin: System.monotonic_time(),
max_idle_time: opts.conn_max_idle_time,
mint: nil
}
end
def connect(%{mint: mint} = conn, name) when not is_nil(mint) do
meta = %{
scheme: conn.scheme,
host: conn.host,
port: conn.port,
name: name
}
Telemetry.event(:reused_connection, %{}, meta)
{:ok, conn}
end
def connect(%{mint: nil} = conn, name) do
meta = %{
scheme: conn.scheme,
host: conn.host,
port: conn.port,
name: name
}
start_time = Telemetry.start(:connect, meta)
# By default we force HTTP1, but we allow someone to set
# custom protocols in case they don't know if a connection
# is HTTP1/HTTP2, but they are fine as treating HTTP2
# connections has HTTP2.
conn_opts =
conn.opts
|> Keyword.put(:mode, :passive)
|> Keyword.put_new(:protocols, [:http1])
case Mint.HTTP.connect(conn.scheme, conn.host, conn.port, conn_opts) do
{:ok, mint} ->
Telemetry.stop(:connect, start_time, meta)
SSL.maybe_log_secrets(conn.scheme, conn_opts, mint)
{:ok, %{conn | mint: mint}}
{:error, error} ->
meta = Map.put(meta, :error, error)
Telemetry.stop(:connect, start_time, meta)
{:error, conn, error}
end
end
def transfer(conn, pid) do
case Mint.HTTP.controlling_process(conn.mint, pid) do
# Mint.HTTP.controlling_process causes a side-effect, but it doesn't actually
# change the conn, so we can ignore the value returned above.
{:ok, _} -> {:ok, conn}
{:error, error} -> {:error, conn, error}
end
end
def open?(%{mint: nil}), do: false
def open?(%{mint: mint}), do: Mint.HTTP.open?(mint)
def idle_time(conn, unit \\ :native) do
idle_time = System.monotonic_time() - conn.last_checkin
System.convert_time_unit(idle_time, :native, unit)
end
def reusable?(%{max_idle_time: :infinity}, _idle_time), do: true
def reusable?(%{max_idle_time: max_idle_time}, idle_time), do: idle_time <= max_idle_time
def set_mode(conn, mode) when mode in [:active, :passive] do
case Mint.HTTP.set_mode(conn.mint, mode) do
{:ok, mint} -> {:ok, %{conn | mint: mint}}
_ -> {:error, "Connection is dead"}
end
end
def discard(%{mint: nil}, _), do: :unknown
def discard(conn, message) do
case Mint.HTTP.stream(conn.mint, message) do
{:ok, mint, _responses} -> {:ok, %{conn | mint: mint}}
{:error, _, reason, _} -> {:error, reason}
:unknown -> :unknown
end
end
def request(%{mint: nil} = conn, _, _, _, _, _, _, _), do: {:error, conn, "Could not connect"}
def request(conn, req, acc, fun, name, receive_timeout, request_timeout, idle_time) do
full_path = Finch.Request.request_path(req)
metadata = %{request: req, name: name}
extra_measurements = %{idle_time: idle_time}
start_time = Telemetry.start(:send, metadata, extra_measurements)
try do
case Mint.HTTP.request(
conn.mint,
req.method,
full_path,
req.headers,
stream_or_body(req.body)
) do
{:ok, mint, ref} ->
case maybe_stream_request_body(mint, ref, req.body) do
{:ok, mint} ->
Telemetry.stop(:send, start_time, metadata, extra_measurements)
start_time = Telemetry.start(:recv, metadata, extra_measurements)
resp_metadata = %{status: nil, headers: [], trailers: []}
timeouts = %{receive_timeout: receive_timeout, request_timeout: request_timeout}
response =
receive_response(
[],
acc,
fun,
mint,
ref,
timeouts,
:headers,
resp_metadata
)
handle_response(response, conn, metadata, start_time, extra_measurements)
{:error, mint, error} ->
handle_request_error(
conn,
mint,
error,
acc,
metadata,
start_time,
extra_measurements
)
end
{:error, mint, error} ->
handle_request_error(conn, mint, error, acc, metadata, start_time, extra_measurements)
end
catch
kind, error ->
close(conn)
Telemetry.exception(:recv, start_time, kind, error, __STACKTRACE__, metadata)
:erlang.raise(kind, error, __STACKTRACE__)
end
end
defp stream_or_body({:stream, _}), do: :stream
defp stream_or_body(body), do: body
defp handle_request_error(conn, mint, error, acc, metadata, start_time, extra_measurements) do
metadata = Map.put(metadata, :error, error)
Telemetry.stop(:send, start_time, metadata, extra_measurements)
{:error, %{conn | mint: mint}, error, acc}
end
defp maybe_stream_request_body(mint, ref, {:stream, stream}) do
with {:ok, mint} <- stream_request_body(mint, ref, stream) do
Mint.HTTP.stream_request_body(mint, ref, :eof)
end
end
defp maybe_stream_request_body(mint, _, _), do: {:ok, mint}
defp stream_request_body(mint, ref, stream) do
Enum.reduce_while(stream, {:ok, mint}, fn
chunk, {:ok, mint} -> {:cont, Mint.HTTP.stream_request_body(mint, ref, chunk)}
_chunk, error -> {:halt, error}
end)
end
def close(%{mint: nil} = conn), do: conn
def close(conn) do
{:ok, mint} = Mint.HTTP.close(conn.mint)
%{conn | mint: mint}
end
defp handle_response(response, conn, metadata, start_time, extra_measurements) do
case response do
{:ok, mint, acc, resp_metadata} ->
metadata = Map.merge(metadata, resp_metadata)
Telemetry.stop(:recv, start_time, metadata, extra_measurements)
{:ok, %{conn | mint: mint}, acc}
{:error, mint, error, acc, resp_metadata} ->
metadata = Map.merge(metadata, Map.put(resp_metadata, :error, error))
Telemetry.stop(:recv, start_time, metadata, extra_measurements)
{:error, %{conn | mint: mint}, error, acc}
end
end
defp receive_response(
entries,
acc,
fun,
mint,
ref,
timeouts,
fields,
resp_metadata
)
defp receive_response(
[{:done, ref} | _],
acc,
_fun,
mint,
ref,
_timeouts,
_fields,
resp_metadata
) do
{:ok, mint, acc, resp_metadata}
end
defp receive_response(
_,
acc,
_fun,
mint,
_ref,
timeouts,
_fields,
resp_metadata
)
when timeouts.request_timeout < 0 do
{:ok, mint} = Mint.HTTP.close(mint)
{:error, mint, %Mint.TransportError{reason: :timeout}, acc, resp_metadata}
end
defp receive_response(
[],
acc,
fun,
mint,
ref,
timeouts,
fields,
resp_metadata
) do
start_time = System.monotonic_time(:millisecond)
case Mint.HTTP.recv(mint, 0, timeouts.receive_timeout) do
{:ok, mint, entries} ->
timeouts =
if is_integer(timeouts.request_timeout) do
elapsed_time = System.monotonic_time(:millisecond) - start_time
update_in(timeouts.request_timeout, &(&1 - elapsed_time))
else
timeouts
end
receive_response(
entries,
acc,
fun,
mint,
ref,
timeouts,
fields,
resp_metadata
)
{:error, mint, error, _responses} ->
{:error, mint, error, acc, resp_metadata}
end
end
defp receive_response(
[entry | entries],
acc,
fun,
mint,
ref,
timeouts,
fields,
resp_metadata
) do
case entry do
{:status, ^ref, value} ->
case fun.({:status, value}, acc) do
{:cont, acc} ->
receive_response(
entries,
acc,
fun,
mint,
ref,
timeouts,
fields,
%{resp_metadata | status: value}
)
{:halt, acc} ->
{:ok, mint} = Mint.HTTP.close(mint)
{:ok, mint, acc, resp_metadata}
other ->
raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}"
end
{:headers, ^ref, value} ->
resp_metadata = update_in(resp_metadata, [fields], &(&1 ++ value))
case fun.({fields, value}, acc) do
{:cont, acc} ->
receive_response(
entries,
acc,
fun,
mint,
ref,
timeouts,
fields,
resp_metadata
)
{:halt, acc} ->
{:ok, mint} = Mint.HTTP.close(mint)
{:ok, mint, acc, resp_metadata}
other ->
raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}"
end
{:data, ^ref, value} ->
case fun.({:data, value}, acc) do
{:cont, acc} ->
receive_response(
entries,
acc,
fun,
mint,
ref,
timeouts,
:trailers,
resp_metadata
)
{:halt, acc} ->
{:ok, mint} = Mint.HTTP.close(mint)
{:ok, mint, acc, resp_metadata}
other ->
raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}"
end
{:error, ^ref, error} ->
{:error, mint, error, acc, resp_metadata}
end
end
end

View File

@@ -0,0 +1,377 @@
defmodule Finch.HTTP1.Pool do
@moduledoc false
@behaviour NimblePool
@behaviour Finch.Pool
defmodule State do
@moduledoc false
defstruct [
:registry,
:shp,
:pool_idx,
:metric_ref,
:opts,
:activity_info
]
end
alias Finch.HTTP1.Conn
alias Finch.Telemetry
alias Finch.HTTP1.PoolMetrics
def child_spec(opts) do
{
_shp,
_registry_name,
_pool_size,
_conn_opts,
pool_max_idle_time,
_start_pool_metrics?,
_pool_idx
} = opts
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
restart: restart_option(pool_max_idle_time)
}
end
def start_link(
{shp, registry_name, pool_size, conn_opts, pool_max_idle_time, start_pool_metrics?,
pool_idx}
) do
NimblePool.start_link(
worker:
{__MODULE__, {registry_name, shp, pool_idx, pool_size, start_pool_metrics?, conn_opts}},
pool_size: pool_size,
lazy: true,
worker_idle_timeout: pool_idle_timeout(pool_max_idle_time)
)
end
@impl Finch.Pool
def request(pool, req, acc, fun, name, opts) do
pool_timeout = Keyword.get(opts, :pool_timeout, 5_000)
receive_timeout = Keyword.get(opts, :receive_timeout, 15_000)
request_timeout = Keyword.get(opts, :request_timeout, :infinity)
metadata = %{request: req, pool: pool, name: name}
start_time = Telemetry.start(:queue, metadata)
try do
NimblePool.checkout!(
pool,
:checkout,
fn from, {state, conn, idle_time} ->
Telemetry.stop(:queue, start_time, metadata, %{idle_time: idle_time})
case Conn.connect(conn, name) do
{:ok, conn} ->
Conn.request(conn, req, acc, fun, name, receive_timeout, request_timeout, idle_time)
|> case do
{:ok, conn, acc} ->
{{:ok, acc}, transfer_if_open(conn, state, from)}
{:error, conn, error, acc} ->
{{:error, error, acc}, transfer_if_open(conn, state, from)}
end
{:error, conn, error} ->
{{:error, error, acc}, transfer_if_open(conn, state, from)}
end
end,
pool_timeout
)
catch
:exit, data ->
Telemetry.exception(:queue, start_time, :exit, data, __STACKTRACE__, metadata)
# Provide helpful error messages for known errors
case data do
{:timeout, {NimblePool, :checkout, _affected_pids}} ->
reraise(
"""
Finch was unable to provide a connection within the timeout due to excess queuing \
for connections. Consider adjusting the pool size, count, timeout or reducing the \
rate of requests if it is possible that the downstream service is unable to keep up \
with the current rate.
""",
__STACKTRACE__
)
_ ->
exit(data)
end
end
end
@impl Finch.Pool
def async_request(pool, req, name, opts) do
owner = self()
pid =
spawn_link(fn ->
monitor = Process.monitor(owner)
request_ref = {__MODULE__, self()}
case request(
pool,
req,
{owner, monitor, request_ref},
&send_async_response/2,
name,
opts
) do
{:ok, _} -> send(owner, {request_ref, :done})
{:error, error, _acc} -> send(owner, {request_ref, {:error, error}})
end
end)
{__MODULE__, pid}
end
defp send_async_response(response, {owner, monitor, request_ref}) do
if process_down?(monitor) do
exit(:shutdown)
end
send(owner, {request_ref, response})
{:cont, {owner, monitor, request_ref}}
end
defp process_down?(monitor) do
receive do
{:DOWN, ^monitor, _, _, _} -> true
after
0 -> false
end
end
@impl Finch.Pool
def cancel_async_request({_, pid} = _request_ref) do
Process.unlink(pid)
Process.exit(pid, :shutdown)
:ok
end
@impl Finch.Pool
def get_pool_status(finch_name, shp) do
case Finch.PoolManager.get_pool_count(finch_name, shp) do
nil ->
{:error, :not_found}
count ->
1..count
|> Enum.map(&PoolMetrics.get_pool_status(finch_name, shp, &1))
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(&elem(&1, 1))
|> case do
[] -> {:error, :not_found}
result -> {:ok, result}
end
end
end
@impl NimblePool
def init_pool({registry, shp, pool_idx, pool_size, start_pool_metrics?, opts}) do
{:ok, metric_ref} =
if start_pool_metrics?,
do: PoolMetrics.init(registry, shp, pool_idx, pool_size),
else: {:ok, nil}
# Register our pool with our module name as the key. This allows the caller
# to determine the correct pool module to use to make the request
{:ok, _} = Registry.register(registry, shp, __MODULE__)
acitivity_info =
if opts[:pool_max_idle_time] != :infinity, do: init_activity_info(), else: nil
state = %__MODULE__.State{
registry: registry,
shp: shp,
pool_idx: pool_idx,
metric_ref: metric_ref,
opts: opts,
activity_info: acitivity_info
}
{:ok, state}
end
@impl NimblePool
def init_worker(%__MODULE__.State{shp: {scheme, host, port}, opts: opts} = pool_state) do
{:ok, Conn.new(scheme, host, port, opts, self()), pool_state}
end
@impl NimblePool
def handle_checkout(:checkout, _, %{mint: nil} = conn, %__MODULE__.State{} = pool_state) do
idle_time = System.monotonic_time() - conn.last_checkin
PoolMetrics.maybe_add(pool_state.metric_ref, in_use_connections: 1)
{:ok, {:fresh, conn, idle_time}, conn, pool_state}
end
def handle_checkout(:checkout, _from, conn, %__MODULE__.State{} = pool_state) do
idle_time = System.monotonic_time() - conn.last_checkin
%__MODULE__.State{
shp: {scheme, host, port},
metric_ref: metric_ref
} = pool_state
with true <- Conn.reusable?(conn, idle_time),
{:ok, conn} <- Conn.set_mode(conn, :passive) do
PoolMetrics.maybe_add(metric_ref, in_use_connections: 1)
{:ok, {:reuse, conn, idle_time}, conn, update_activity_info(:checkout, pool_state)}
else
false ->
meta = %{
scheme: scheme,
host: host,
port: port
}
# Deprecated, remember to delete when we remove the :max_idle_time pool config option!
Telemetry.event(:max_idle_time_exceeded, %{idle_time: idle_time}, meta)
Telemetry.event(:conn_max_idle_time_exceeded, %{idle_time: idle_time}, meta)
{:remove, :closed, pool_state}
_ ->
{:remove, :closed, pool_state}
end
end
@impl NimblePool
def handle_checkin(checkin, _from, _old_conn, %__MODULE__.State{} = pool_state) do
%__MODULE__.State{metric_ref: metric_ref} = pool_state
PoolMetrics.maybe_add(metric_ref, in_use_connections: -1)
with {:ok, conn} <- checkin,
{:ok, conn} <- Conn.set_mode(conn, :active) do
{
:ok,
%{conn | last_checkin: System.monotonic_time()},
update_activity_info(:checkin, pool_state)
}
else
_ ->
{:remove, :closed, update_activity_info(:checkin, pool_state)}
end
end
@impl NimblePool
def handle_update(new_conn, _old_conn, %__MODULE__.State{} = pool_state) do
{:ok, new_conn, pool_state}
end
@impl NimblePool
def handle_info(message, conn) do
case Conn.discard(conn, message) do
{:ok, conn} -> {:ok, conn}
:unknown -> {:ok, conn}
{:error, _error} -> {:remove, :closed}
end
end
@impl NimblePool
def handle_ping(conn, %__MODULE__.State{} = pool_state) do
%__MODULE__.State{
shp: {scheme, host, port},
opts: opts,
activity_info: activity_info
} = pool_state
max_idle_time = Map.get(opts, :pool_max_idle_time, :infinity)
now = System.monotonic_time(:millisecond)
diff_from_last_checkout = now - activity_info.last_checkout_ts
is_idle? = diff_from_last_checkout > max_idle_time
max_idle_time_configured? = is_number(max_idle_time)
any_connection_in_use? = activity_info.in_use_count > 0
cond do
not max_idle_time_configured? ->
{:ok, conn}
any_connection_in_use? ->
{:ok, conn}
is_idle? ->
meta = %{
scheme: scheme,
host: host,
port: port
}
Telemetry.event(:pool_max_idle_time_exceeded, %{}, meta)
{:stop, :idle_timeout}
true ->
{:ok, conn}
end
end
@impl NimblePool
# On terminate, effectively close it.
# This will succeed even if it was already closed or if we don't own it.
def terminate_worker(_reason, conn, %__MODULE__.State{} = pool_state) do
Conn.close(conn)
{:ok, pool_state}
end
@impl NimblePool
def handle_cancelled(:checked_out, %__MODULE__.State{} = pool_state) do
%__MODULE__.State{metric_ref: metric_ref} = pool_state
PoolMetrics.maybe_add(metric_ref, in_use_connections: -1)
:ok
end
def handle_cancelled(:queued, _pool_state), do: :ok
defp transfer_if_open(conn, state, {pid, _} = from) do
if Conn.open?(conn) do
if state == :fresh do
NimblePool.update(from, conn)
case Conn.transfer(conn, pid) do
{:ok, conn} -> {:ok, conn}
{:error, _, _} -> :closed
end
else
{:ok, conn}
end
else
:closed
end
end
defp restart_option(:infinity), do: :permanent
defp restart_option(_pool_max_idle_time), do: :transient
defp pool_idle_timeout(:infinity), do: nil
defp pool_idle_timeout(pool_max_idle_time), do: pool_max_idle_time
defp init_activity_info() do
%{in_use_count: 0, last_checkout_ts: System.monotonic_time(:millisecond)}
end
defp update_activity_info(
_checkout_or_checkin,
%__MODULE__.State{activity_info: nil} = pool_state
) do
pool_state
end
defp update_activity_info(:checkout, %__MODULE__.State{} = pool_state) do
update_in(pool_state.activity_info, fn %{in_use_count: count} ->
%{in_use_count: count + 1, last_checkout_ts: System.monotonic_time(:millisecond)}
end)
end
defp update_activity_info(:checkin, %__MODULE__.State{} = pool_state) do
update_in(pool_state.activity_info.in_use_count, &max(&1 - 1, 0))
end
end

View File

@@ -0,0 +1,81 @@
defmodule Finch.HTTP1.PoolMetrics do
@moduledoc """
HTTP1 Pool metrics.
Available metrics:
* `:pool_index` - Index of the pool
* `:pool_size` - Total number of connections of the pool
* `:available_connections` - Number of available connections
* `:in_use_connections` - Number of connections currently in use
Caveats:
* A given number X of `available_connections` does not mean that currently
exists X connections to the server sitting on the pool. Because Finch uses
a lazy strategy for workers initialization, every pool starts with it's
size as available connections even if they are not started yet. In practice
this means that `available_connections` may be connections sitting on the pool
or available space on the pool for a new one if required.
"""
@type t :: %__MODULE__{}
defstruct [
:pool_index,
:pool_size,
:available_connections,
:in_use_connections
]
@atomic_idx [
pool_idx: 1,
pool_size: 2,
in_use_connections: 3
]
def init(registry, shp, pool_idx, pool_size) do
ref = :atomics.new(length(@atomic_idx), [])
:atomics.add(ref, @atomic_idx[:pool_idx], pool_idx)
:atomics.add(ref, @atomic_idx[:pool_size], pool_size)
:persistent_term.put({__MODULE__, registry, shp, pool_idx}, ref)
{:ok, ref}
end
def maybe_add(nil, _metrics_list), do: :ok
def maybe_add(ref, metrics_list) do
Enum.each(metrics_list, fn {metric_name, val} ->
:atomics.add(ref, @atomic_idx[metric_name], val)
end)
end
def get_pool_status(name, shp, pool_idx) do
{__MODULE__, name, shp, pool_idx}
|> :persistent_term.get(nil)
|> get_pool_status()
end
def get_pool_status(nil), do: {:error, :not_found}
def get_pool_status(ref) do
%{
pool_idx: pool_idx,
pool_size: pool_size,
in_use_connections: in_use_connections
} =
@atomic_idx
|> Enum.map(fn {k, idx} -> {k, :atomics.get(ref, idx)} end)
|> Map.new()
result = %__MODULE__{
pool_index: pool_idx,
pool_size: pool_size,
available_connections: pool_size - in_use_connections,
in_use_connections: in_use_connections
}
{:ok, result}
end
end

View File

@@ -0,0 +1,844 @@
defmodule Finch.HTTP2.Pool do
@moduledoc false
@behaviour :gen_statem
@behaviour Finch.Pool
alias Mint.HTTP2
alias Mint.HTTPError
alias Finch.Error
alias Finch.Telemetry
alias Finch.SSL
alias Finch.HTTP2.RequestStream
alias Finch.HTTP2.PoolMetrics
require Logger
@default_receive_timeout 15_000
@impl true
def callback_mode(), do: [:state_functions, :state_enter]
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]}
}
end
# Call the pool with the request. The pool will multiplex multiple requests
# and stream the result set back to the calling process using `send`
@impl Finch.Pool
def request(pool, request, acc, fun, name, opts) do
opts = Keyword.put_new(opts, :receive_timeout, @default_receive_timeout)
timeout = opts[:receive_timeout]
request_ref = make_request_ref(pool)
case :gen_statem.call(pool, {:request, request_ref, request, opts}) do
{:ok, recv_start} ->
monitor = Process.monitor(pool)
# If the timeout is an integer, we add a fail-safe "after" clause that fires
# after a timeout that is double the original timeout (min 2000ms). This means
# that if there are no bugs in our code, then the normal :request_timeout is
# returned, but otherwise we have a way to escape this code, raise an error, and
# get the process unstuck.
fail_safe_timeout = if is_integer(timeout), do: max(2000, timeout * 2), else: :infinity
try do
response_waiting_loop(acc, fun, request_ref, monitor, fail_safe_timeout, :headers)
catch
kind, error ->
metadata = %{request: request, name: name}
Telemetry.exception(:recv, recv_start, kind, error, __STACKTRACE__, metadata)
:ok = :gen_statem.call(pool, {:cancel, request_ref})
clean_responses(request_ref)
Process.demonitor(monitor)
:erlang.raise(kind, error, __STACKTRACE__)
end
{:error, error} ->
{:error, error, acc}
end
end
@impl Finch.Pool
def async_request(pool, req, _name, opts) do
opts = Keyword.put_new(opts, :receive_timeout, @default_receive_timeout)
request_ref = make_request_ref(pool)
:ok = :gen_statem.cast(pool, {:async_request, self(), request_ref, req, opts})
request_ref
end
@impl Finch.Pool
def cancel_async_request({_, {pool, _}} = request_ref) do
:ok = :gen_statem.call(pool, {:cancel, request_ref})
clean_responses(request_ref)
end
@impl Finch.Pool
def get_pool_status(finch_name, shp) do
case Finch.PoolManager.get_pool_count(finch_name, shp) do
nil ->
{:error, :not_found}
count ->
1..count
|> Enum.map(&PoolMetrics.get_pool_status(finch_name, shp, &1))
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(&elem(&1, 1))
|> case do
[] -> {:error, :not_found}
result -> {:ok, result}
end
end
end
defp make_request_ref(pool) do
{__MODULE__, {pool, make_ref()}}
end
defp response_waiting_loop(acc, fun, request_ref, monitor_ref, fail_safe_timeout, fields)
defp response_waiting_loop(acc, fun, request_ref, monitor_ref, fail_safe_timeout, fields) do
receive do
{^request_ref, {:status, value}} ->
case fun.({:status, value}, acc) do
{:cont, acc} ->
response_waiting_loop(
acc,
fun,
request_ref,
monitor_ref,
fail_safe_timeout,
fields
)
{:halt, acc} ->
cancel_async_request(request_ref)
Process.demonitor(monitor_ref)
{:ok, acc}
other ->
raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}"
end
{^request_ref, {:headers, value}} ->
case fun.({fields, value}, acc) do
{:cont, acc} ->
response_waiting_loop(
acc,
fun,
request_ref,
monitor_ref,
fail_safe_timeout,
fields
)
{:halt, acc} ->
cancel_async_request(request_ref)
Process.demonitor(monitor_ref)
{:ok, acc}
other ->
raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}"
end
{^request_ref, {:data, value}} ->
case fun.({:data, value}, acc) do
{:cont, acc} ->
response_waiting_loop(
acc,
fun,
request_ref,
monitor_ref,
fail_safe_timeout,
:trailers
)
{:halt, acc} ->
cancel_async_request(request_ref)
Process.demonitor(monitor_ref)
{:ok, acc}
other ->
raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}"
end
{^request_ref, :done} ->
Process.demonitor(monitor_ref)
{:ok, acc}
{^request_ref, {:error, error}} ->
Process.demonitor(monitor_ref)
{:error, error, acc}
{:DOWN, ^monitor_ref, _, _, _} ->
{:error, :connection_process_went_down}
after
fail_safe_timeout ->
Process.demonitor(monitor_ref)
raise "no response was received even after waiting #{fail_safe_timeout}ms. " <>
"This is likely a bug in Finch, but we're raising so that your system doesn't " <>
"get stuck in an infinite receive."
end
end
defp clean_responses(request_ref) do
receive do
{^request_ref, _} -> clean_responses(request_ref)
after
0 -> :ok
end
end
def start_link({_shp, _finch_name, _pool_config, _start_pool_metrics?, _pool_idx} = opts) do
:gen_statem.start_link(__MODULE__, opts, [])
end
@impl true
def init({{scheme, host, port} = shp, registry, pool_opts, start_pool_metrics?, pool_idx}) do
{:ok, metrics_ref} =
if start_pool_metrics?,
do: PoolMetrics.init(registry, shp, pool_idx),
else: {:ok, nil}
{:ok, _} = Registry.register(registry, shp, __MODULE__)
data = %{
conn: nil,
finch_name: registry,
scheme: scheme,
host: host,
port: port,
pool_idx: pool_idx,
requests: %{},
refs: %{},
requests_by_pid: %{},
backoff_base: 500,
backoff_max: 10_000,
connect_opts: pool_opts[:conn_opts] || [],
metrics_ref: metrics_ref
}
{:ok, :disconnected, data, {:next_event, :internal, {:connect, 0}}}
end
@doc false
def disconnected(event, content, data)
def disconnected(:enter, :disconnected, _) do
:keep_state_and_data
end
# When entering a disconnected state we need to fail all of the pending
# requests
def disconnected(:enter, _, data) do
:ok =
Enum.each(data.requests, fn {_ref, request} ->
send(
request.from_pid,
{request.request_ref, {:error, Error.exception(:connection_closed)}}
)
end)
# It's possible that we're entering this state before we are alerted of the
# fact that the socket is closed. This most often happens if we're in a read
# only state but have no pending requests to wait on. In this case we can just
# close the connection and throw it away.
if data.conn do
HTTP2.close(data.conn)
end
data =
data
|> Map.put(:requests, %{})
|> Map.put(:conn, nil)
actions = [{{:timeout, :reconnect}, data.backoff_base, 1}]
{:keep_state, data, actions}
end
def disconnected(:internal, {:connect, failure_count}, data) do
metadata = %{
scheme: data.scheme,
host: data.host,
port: data.port,
name: data.finch_name
}
start = Telemetry.start(:connect, metadata)
case HTTP2.connect(data.scheme, data.host, data.port, data.connect_opts) do
{:ok, conn} ->
Telemetry.stop(:connect, start, metadata)
SSL.maybe_log_secrets(data.scheme, data.connect_opts, conn)
data = %{data | conn: conn}
{:next_state, :connected, data}
{:error, error} ->
metadata = Map.put(metadata, :error, error)
Telemetry.stop(:connect, start, metadata)
Logger.warning([
"Failed to connect to #{data.scheme}://#{data.host}:#{data.port}: ",
Exception.message(error)
])
delay = backoff(data.backoff_base, data.backoff_max, failure_count)
{:keep_state_and_data, {{:timeout, :reconnect}, delay, failure_count + 1}}
end
end
# Capture timeout after trying to reconnect. Immediately attempt to reconnect
# to the upstream server
def disconnected({:timeout, :reconnect}, failure_count, _data) do
{:keep_state_and_data, {:next_event, :internal, {:connect, failure_count}}}
end
# Immediately fail a request if we're disconnected
def disconnected({:call, from}, {:request, _, _, _}, _data) do
{:keep_state_and_data, {:reply, from, {:error, Error.exception(:disconnected)}}}
end
# Ignore cancel requests if we are disconnected
def disconnected({:call, from}, {:cancel, _request_ref}, _data) do
{:keep_state_and_data, {:reply, from, {:error, Error.exception(:disconnected)}}}
end
# Immediately fail a request if we're disconnected
def disconnected(:cast, {:async_request, pid, request_ref, _, _}, _data) do
send(pid, {request_ref, {:error, Error.exception(:disconnected)}})
:keep_state_and_data
end
# We cancel all request timeouts as soon as we enter the :disconnected state, but
# some timeouts might fire while changing states, so we need to handle them here.
# Since we replied to all pending requests when entering the :disconnected state,
# we can just do nothing here.
def disconnected({:timeout, {:request_timeout, _ref}}, _content, _data) do
:keep_state_and_data
end
# Its possible that we can receive an info message telling us that a socket
# has been closed. This happens after we enter a disconnected state from a
# read_only state but we don't have any requests that are open. We've already
# closed the connection and thrown it away at this point so we can just retain
# our current state.
def disconnected(:info, _message, _data) do
:keep_state_and_data
end
@doc false
def connected(event, content, data)
def connected(:enter, _old_state, _data) do
:keep_state_and_data
end
# Issue request to the upstream server. We store a ref to the request so we
# know who to respond to when we've completed everything
def connected({:call, {from_pid, _from_ref} = from}, {:request, request_ref, req, opts}, data) do
send_request(from, from_pid, request_ref, req, opts, data)
end
def connected({:call, from}, {:cancel, request_ref}, data) do
data = cancel_request(data, request_ref)
{:keep_state, data, {:reply, from, :ok}}
end
def connected(:cast, {:async_request, pid, request_ref, req, opts}, data) do
if is_nil(data.requests_by_pid[pid]) do
Process.monitor(pid)
end
send_request(nil, pid, request_ref, req, opts, data)
end
def connected(:info, {:DOWN, _, :process, pid, _}, data) do
{:keep_state, cancel_requests(data, pid)}
end
def connected(:info, message, data) do
case HTTP2.stream(data.conn, message) do
{:ok, conn, responses} ->
data = put_in(data.conn, conn)
{data, response_actions} = handle_responses(data, responses)
cond do
HTTP2.open?(data.conn, :write) ->
data = continue_requests(data)
{:keep_state, data, response_actions}
HTTP2.open?(data.conn, :read) && Enum.any?(data.requests) ->
{:next_state, :connected_read_only, data, response_actions}
true ->
{:next_state, :disconnected, data, response_actions}
end
{:error, conn, error, responses} ->
Logger.error([
"Received error from server #{data.scheme}:#{data.host}:#{data.port}: ",
Exception.message(error)
])
data = put_in(data.conn, conn)
{data, actions} = handle_responses(data, responses)
if HTTP2.open?(conn, :read) && Enum.any?(data.requests) do
{:next_state, :connected_read_only, data, actions}
else
{:next_state, :disconnected, data, actions}
end
:unknown ->
Logger.warning(["Received unknown message: ", inspect(message)])
:keep_state_and_data
end
end
def connected({:timeout, {:request_timeout, ref}}, _content, data) do
with {:pop, {request, data}} when not is_nil(request) <- {:pop, pop_request(data, ref)},
{:ok, conn} <- HTTP2.cancel_request(data.conn, ref) do
data = put_in(data.conn, conn)
send(request.from_pid, {request.request_ref, {:error, Error.exception(:request_timeout)}})
{:keep_state, data}
else
{:error, conn, _error} ->
data = put_in(data.conn, conn)
cond do
HTTP2.open?(conn, :write) ->
{:keep_state, data}
# Don't bother entering read only mode if we don't have any pending requests.
HTTP2.open?(conn, :read) && Enum.any?(data.requests) ->
{:next_state, :connected_read_only, data}
true ->
{:next_state, :disconnected, data}
end
# The timer might have fired while we were receiving :done/:error for this
# request, so we don't have the request stored anymore but we still get the
# timer event. In those cases, we do nothing.
{:pop, {nil, _data}} ->
:keep_state_and_data
end
end
@doc false
def connected_read_only(event, content, data)
def connected_read_only(:enter, _old_state, data) do
data =
Enum.reduce(data.requests, data, fn
# request is awaiting a response and should stay in state
{_ref, %{stream: %{status: :done}}}, data ->
data
# request is still sending data and should be discarded
{ref, %{stream: %{status: :streaming}} = request}, data ->
{^request, data} = pop_request(data, ref)
reply(request, {:error, Error.exception(:read_only)})
data
end)
{:keep_state, data}
end
# If we're in a read only state then respond with an error immediately
def connected_read_only({:call, from}, {:request, _, _, _}, _) do
{:keep_state_and_data, {:reply, from, {:error, Error.exception(:read_only)}}}
end
def connected_read_only({:call, from}, {:cancel, request_ref}, data) do
data = cancel_request(data, request_ref)
{:keep_state, data, {:reply, from, :ok}}
end
def connected_read_only(:cast, {:async_request, pid, request_ref, _, _}, _) do
send(pid, {request_ref, {:error, Error.exception(:read_only)}})
:keep_state_and_data
end
def connected_read_only(:info, {:DOWN, _, :process, pid, _}, data) do
{:keep_state, cancel_requests(data, pid)}
end
def connected_read_only(:info, message, data) do
case HTTP2.stream(data.conn, message) do
{:ok, conn, responses} ->
data = put_in(data.conn, conn)
{data, actions} = handle_responses(data, responses)
# If the connection is still open for reading and we have pending requests
# to receive, we should try to wait for the responses. Otherwise enter
# the disconnected state so we can try to re-establish a connection.
if HTTP2.open?(conn, :read) && Enum.any?(data.requests) do
{:keep_state, data, actions}
else
{:next_state, :disconnected, data, actions}
end
{:error, conn, error, responses} ->
Logger.error([
"Received error from server #{data.scheme}://#{data.host}:#{data.port}: ",
Exception.message(error)
])
data = put_in(data.conn, conn)
{data, actions} = handle_responses(data, responses)
# Same as above, if we're still waiting on responses, we should stay in
# this state. Otherwise, we should enter the disconnected state and try
# to re-establish a connection.
if HTTP2.open?(conn, :read) && Enum.any?(data.requests) do
{:keep_state, data, actions}
else
{:next_state, :disconnected, data, actions}
end
:unknown ->
Logger.warning(["Received unknown message: ", inspect(message)])
:keep_state_and_data
end
end
# In this state, we don't need to call HTTP2.cancel_request/2 since the connection
# is closed for writing, so we can't tell the server to cancel the request anymore.
def connected_read_only({:timeout, {:request_timeout, ref}}, _content, data) do
# We might get a request timeout that fired in the moment when we received the
# whole request, so we don't have the request in the state but we get the
# timer event anyways. In those cases, we don't do anything.
{request, data} = pop_request(data, ref)
# Its possible that the request doesn't exist so we guard against that here.
if request != nil do
send(request.from_pid, {request.request_ref, {:error, Error.exception(:request_timeout)}})
end
# If we're out of requests then we should enter the disconnected state.
# Otherwise wait for the remaining responses.
if Enum.empty?(data.requests) do
{:next_state, :disconnected, data}
else
{:keep_state, data}
end
end
defp send_request(from, from_pid, request_ref, req, opts, data) do
telemetry_metadata = %{request: req, name: data.finch_name}
request = %{
stream: RequestStream.new(req.body),
from: from,
from_pid: from_pid,
request_ref: request_ref,
telemetry: %{
metadata: telemetry_metadata,
send: Telemetry.start(:send, telemetry_metadata)
}
}
body = if req.body == nil, do: nil, else: :stream
data
|> start_request(req.method, Finch.Request.request_path(req), req.headers, body)
|> stream_request(request, opts)
end
defp start_request(data, method, path, headers, body) do
case HTTP2.request(data.conn, method, path, headers, body) do
{:ok, conn, ref} ->
{:ok, put_in(data.conn, conn), ref}
{:error, conn, reason} ->
{:error, put_in(data.conn, conn), reason}
end
end
defp stream_request({:ok, data, ref}, request, opts) do
data = put_request(data, ref, request)
case continue_request(data, ref, request) do
{:ok, data} ->
# Set a timeout to close the request after a given timeout
request_timeout = {{:timeout, {:request_timeout, ref}}, opts[:receive_timeout], nil}
{:keep_state, data, [request_timeout]}
error ->
stream_request(error, request, opts)
end
end
defp stream_request({:error, data, %HTTPError{reason: :closed_for_writing}}, request, _opts) do
reply(request, {:error, Error.exception(:read_only)})
if HTTP2.open?(data.conn, :read) && Enum.any?(data.requests) do
{:next_state, :connected_read_only, data}
else
{:next_state, :disconnected, data}
end
end
defp stream_request({:error, data, error}, request, _opts) do
reply(request, {:error, error})
if HTTP2.open?(data.conn) do
{:keep_state, data}
else
{:next_state, :disconnected, data}
end
end
defp handle_responses(data, responses) do
Enum.reduce(responses, {data, _actions = []}, fn response, {data, actions} ->
handle_response(data, response, actions)
end)
end
defp handle_response(data, {kind, ref, value}, actions)
when kind in [:status, :headers] do
data =
if request = data.requests[ref] do
send(request.from_pid, {request.request_ref, {kind, value}})
request = put_in(request.telemetry.metadata[kind], value)
put_in(data.requests[ref], request)
else
data
end
{data, actions}
end
defp handle_response(data, {:data, ref, value}, actions) do
if request = data.requests[ref] do
send(request.from_pid, {request.request_ref, {:data, value}})
end
{data, actions}
end
defp handle_response(data, {:done, ref}, actions) do
{request, data} = pop_request(data, ref)
if request do
send(request.from_pid, {request.request_ref, :done})
Telemetry.stop(:recv, request.telemetry.recv, request.telemetry.metadata)
end
{data, [cancel_request_timeout_action(ref) | actions]}
end
defp handle_response(data, {:error, ref, error}, actions) do
{request, data} = pop_request(data, ref)
if request do
send(request.from_pid, {request.request_ref, {:error, error}})
Telemetry.stop(
:recv,
request.telemetry.recv,
Map.put(request.telemetry.metadata, :error, error)
)
end
{data, [cancel_request_timeout_action(ref) | actions]}
end
defp cancel_request_timeout_action(request_ref) do
# By setting the timeout to :infinity, we cancel this timeout as per
# gen_statem documentation.
{{:timeout, {:request_timeout, request_ref}}, :infinity, nil}
end
# Exponential backoff with jitter
# The backoff algorithm optimizes for tight bounds on completing a request successfully.
# It does this by first calculating an exponential backoff factor based on the
# number of retries that have been performed. It then multiplies this factor against the
# base delay. The total maximum delay is found by taking the minimum of either the calculated delay
# or the maximum delay specified. This creates an upper bound on the maximum delay
# we can see.
#
# In order to find the actual delay value we take a random number between 0 and
# the maximum delay based on a uniform distribution. This randomness ensures that
# our retried requests don't "harmonize" making it harder for the downstream
# service to heal.
defp backoff(base_backoff, max_backoff, failure_count) do
factor = :math.pow(2, failure_count)
max_sleep = trunc(min(max_backoff, base_backoff * factor))
:rand.uniform(max_sleep)
end
# this is also a wrapper (Mint.HTTP2.stream_request_body/3)
defp stream_request_body(data, ref, body) do
case HTTP2.stream_request_body(data.conn, ref, body) do
{:ok, conn} -> {:ok, put_in(data.conn, conn)}
{:error, conn, reason} -> {:error, put_in(data.conn, conn), reason}
end
end
defp stream_chunks(data, ref, body, %{stream: %{status: :done}}) do
with {:ok, data} <- stream_request_body(data, ref, body) do
stream_request_body(data, ref, :eof)
end
end
defp stream_chunks(data, ref, body, _), do: stream_request_body(data, ref, body)
defp continue_requests(data) do
Enum.reduce(data.requests, data, fn {ref, request}, data ->
with true <- request.stream.status == :streaming,
true <- HTTP2.open?(data.conn, :write),
{:ok, data} <- continue_request(data, ref, request) do
data
else
false ->
data
{:error, data, %HTTPError{reason: :closed_for_writing}} ->
reply(request, {:error, Error.exception(:read_only)})
data
{:error, data, reason} ->
reply(request, {:error, reason})
data
end
end)
end
defp continue_request(data, ref, request) do
with :streaming <- request.stream.status,
window = smallest_window(data.conn, ref),
{stream, chunks} = RequestStream.next_chunk(request.stream, window),
request = %{request | stream: stream},
{:ok, data} <- stream_chunks(data, ref, chunks, request) do
{:ok, complete_request_if_done(data, ref, request)}
else
:done ->
{:ok, complete_request_if_done(data, ref, request)}
{:error, data, reason} ->
{_from, data} = pop_request(data, ref)
{:error, data, reason}
end
end
defp complete_request_if_done(data, ref, %{stream: %{status: :done}} = request) do
%{from: from, telemetry: telemetry} = request
Telemetry.stop(:send, telemetry.send, telemetry.metadata)
recv_start = Telemetry.start(:recv, telemetry.metadata)
request = put_in(request.telemetry[:recv], recv_start)
if from do
reply(request, {:ok, recv_start})
end
put_in(data.requests[ref], request)
end
defp complete_request_if_done(data, ref, request) do
put_in(data.requests[ref], request)
end
defp smallest_window(conn, ref) do
min(
HTTP2.get_window_size(conn, :connection),
HTTP2.get_window_size(conn, {:request, ref})
)
end
defp cancel_requests(data, pid) do
if request_refs = data.requests_by_pid[pid] do
Enum.reduce(request_refs, data, fn request_ref, data ->
cancel_request(data, request_ref)
end)
else
data
end
end
defp cancel_request(data, request_ref) do
# If the Mint ref isn't present, it was removed because the request
# already completed and there's nothing to cancel.
if ref = data.refs[request_ref] do
conn =
case HTTP2.cancel_request(data.conn, ref) do
{:ok, conn} -> conn
{:error, conn, _error} -> conn
end
data = put_in(data.conn, conn)
{_from, data} = pop_request(data, ref)
data
else
data
end
end
defp put_request(data, ref, request) do
PoolMetrics.maybe_add(data.metrics_ref, in_flight_requests: 1)
data
|> put_in([:requests, ref], request)
|> put_in([:refs, request.request_ref], ref)
|> put_pid(request.from_pid, request.request_ref)
end
defp pop_request(data, ref) do
PoolMetrics.maybe_add(data.metrics_ref, in_flight_requests: -1)
case pop_in(data.requests[ref]) do
{nil, data} ->
{nil, data}
{request, data} ->
{_ref, data} =
data
|> pop_pid(request.from_pid, request.request_ref)
|> pop_in([:refs, request.request_ref])
{request, data}
end
end
defp put_pid(data, pid, request_ref) do
update_in(data.requests_by_pid, fn requests_by_pid ->
Map.update(requests_by_pid, pid, MapSet.new([request_ref]), &MapSet.put(&1, request_ref))
end)
end
defp pop_pid(data, pid, request_ref) do
update_in(data.requests_by_pid, fn requests_by_pid ->
requests =
requests_by_pid
|> Map.get(pid, MapSet.new())
|> MapSet.delete(request_ref)
if Enum.empty?(requests) do
Map.delete(requests_by_pid, pid)
else
Map.put(requests_by_pid, pid, requests)
end
end)
end
defp reply(%{from: nil, from_pid: pid, request_ref: request_ref}, reply) do
send(pid, {request_ref, reply})
:ok
end
defp reply(%{from: from}, reply) do
:gen_statem.reply(from, reply)
end
end

View File

@@ -0,0 +1,68 @@
defmodule Finch.HTTP2.PoolMetrics do
@moduledoc """
HTTP2 Pool metrics.
Available metrics:
* `:pool_index` - Index of the pool
* `:in_flight_requests` - Number of requests currently on the connection
Caveats:
* HTTP2 pools have only one connection and leverage the multiplex nature
of the protocol. That's why we only keep the in flight requests, representing
the number of streams currently running on the connection.
"""
@type t :: %__MODULE__{}
defstruct [
:pool_index,
:in_flight_requests
]
@atomic_idx [
pool_idx: 1,
in_flight_requests: 2
]
def init(finch_name, shp, pool_idx) do
ref = :atomics.new(length(@atomic_idx), [])
:atomics.put(ref, @atomic_idx[:pool_idx], pool_idx)
:persistent_term.put({__MODULE__, finch_name, shp, pool_idx}, ref)
{:ok, ref}
end
def maybe_add(nil, _metrics_list), do: :ok
def maybe_add(ref, metrics_list) do
Enum.each(metrics_list, fn {metric_name, val} ->
:atomics.add(ref, @atomic_idx[metric_name], val)
end)
end
def get_pool_status(name, shp, pool_idx) do
{__MODULE__, name, shp, pool_idx}
|> :persistent_term.get(nil)
|> get_pool_status()
end
def get_pool_status(nil), do: {:error, :not_found}
def get_pool_status(ref) do
%{
pool_idx: pool_idx,
in_flight_requests: in_flight_requests
} =
@atomic_idx
|> Enum.map(fn {k, idx} -> {k, :atomics.get(ref, idx)} end)
|> Map.new()
result = %__MODULE__{
pool_index: pool_idx,
in_flight_requests: in_flight_requests
}
{:ok, result}
end
end

View File

@@ -0,0 +1,83 @@
defmodule Finch.HTTP2.RequestStream do
@moduledoc false
defstruct [:body, :status, :buffer, :continuation]
def new(body) do
enumerable =
case body do
{:stream, stream} -> Stream.map(stream, &with_byte_size/1)
nil -> [with_byte_size("")]
io_data -> [with_byte_size(io_data)]
end
reducer = &reduce_with_suspend/2
%__MODULE__{
body: body,
status: if(body == nil, do: :done, else: :streaming),
buffer: <<>>,
continuation: &Enumerable.reduce(enumerable, &1, reducer)
}
end
defp with_byte_size(binary) when is_binary(binary), do: {binary, byte_size(binary)}
defp with_byte_size(io_data), do: io_data |> IO.iodata_to_binary() |> with_byte_size()
defp reduce_with_suspend(
{message, message_size},
{message_buffer, message_buffer_size, window}
)
when message_size + message_buffer_size > window do
{:suspend,
{[{message, message_size} | message_buffer], message_size + message_buffer_size, window}}
end
defp reduce_with_suspend(
{message, message_size},
{message_buffer, message_buffer_size, window}
) do
{:cont, {[message | message_buffer], message_size + message_buffer_size, window}}
end
# gets the next chunk of data that will fit into the given window size
def next_chunk(request, window)
# when the buffer is empty, continue reducing the stream
def next_chunk(%__MODULE__{buffer: <<>>} = request, window) do
continue_reduce(request, {[], 0, window})
end
def next_chunk(%__MODULE__{buffer: buffer} = request, window) do
case buffer do
<<bytes_to_send::binary-size(window), rest::binary>> ->
# when the buffer contains more bytes than a window, send as much of the
# buffer as we can
{put_in(request.buffer, rest), bytes_to_send}
_ ->
# when the buffer can fit in the windows, continue reducing using the buffer
# as the accumulator
continue_reduce(request, {[buffer], byte_size(buffer), window})
end
end
defp continue_reduce(request, acc) do
case request.continuation.({:cont, acc}) do
{finished, {messages, _size, _window}} when finished in [:done, :halted] ->
{put_in(request.status, :done), Enum.reverse(messages)}
{:suspended,
{[{overload_message, overload_message_size} | messages_that_fit], total_size, window_size},
next_continuation} ->
fittable_size = window_size - (total_size - overload_message_size)
<<fittable_binary::binary-size(fittable_size), overload_binary::binary>> =
overload_message
request = %{request | continuation: next_continuation, buffer: overload_binary}
{request, Enum.reverse([fittable_binary | messages_that_fit])}
end
end
end

View File

@@ -0,0 +1,32 @@
defmodule Finch.Pool do
@moduledoc false
# Defines a behaviour that both http1 and http2 pools need to implement.
@type request_ref :: {pool_mod :: module(), cancel_ref :: term()}
@callback request(
pid(),
Finch.Request.t(),
acc,
Finch.stream(acc),
Finch.name(),
list()
) :: {:ok, acc} | {:error, term(), acc}
when acc: term()
@callback async_request(
pid(),
Finch.Request.t(),
Finch.name(),
list()
) :: request_ref()
@callback cancel_async_request(request_ref()) :: :ok
@callback get_pool_status(
finch_name :: atom(),
{schema :: atom(), host :: String.t(), port :: integer()}
) :: {:ok, list(map)} | {:error, :not_found}
defguard is_request_ref(ref) when tuple_size(ref) == 2 and is_atom(elem(ref, 0))
end

View File

@@ -0,0 +1,208 @@
defmodule Finch.PoolManager do
@moduledoc false
use GenServer
@mint_tls_opts [
:cacertfile,
:cacerts,
:ciphers,
:depth,
:eccs,
:hibernate_after,
:partial_chain,
:reuse_sessions,
:secure_renegotiate,
:server_name_indication,
:signature_algs,
:signature_algs_cert,
:supported_groups,
:verify,
:verify_fun,
:versions
]
@default_conn_hostname "localhost"
def start_link(config) do
GenServer.start_link(__MODULE__, config, name: config.manager_name)
end
@impl true
def init(config) do
if config.default_pool_config.start_pool_metrics? do
:ets.new(default_shp_table(config.registry_name), [
:set,
:public,
:named_table
])
end
Enum.each(config.pools, fn {shp, _} ->
do_start_pools(shp, config)
end)
{:ok, config}
end
def get_pool(registry_name, {_scheme, _host, _port} = key, opts \\ []) do
case lookup_pool(registry_name, key) do
{pid, _} = pool when is_pid(pid) ->
pool
:none ->
if Keyword.get(opts, :auto_start?, true),
do: start_pools(registry_name, key),
else: :not_found
end
end
def lookup_pool(registry, key) do
case all_pool_instances(registry, key) do
[] ->
:none
[pool] ->
pool
pools ->
# TODO implement alternative strategies
Enum.random(pools)
end
end
def all_pool_instances(registry, key), do: Registry.lookup(registry, key)
def start_pools(registry_name, shp) do
{:ok, config} = Registry.meta(registry_name, :config)
GenServer.call(config.manager_name, {:start_pools, shp})
end
@impl true
def handle_call({:start_pools, shp}, _from, state) do
reply =
case lookup_pool(state.registry_name, shp) do
:none -> do_start_pools(shp, state)
pool -> pool
end
{:reply, reply, state}
end
defp do_start_pools(shp, config) do
pool_config = pool_config(config, shp)
if pool_config.start_pool_metrics? do
maybe_track_default_shp(config, shp)
put_pool_count(config, shp, pool_config.count)
end
Enum.map(1..pool_config.count, fn pool_idx ->
pool_args = pool_args(shp, config, pool_config, pool_idx)
# Choose pool type here...
{:ok, pid} =
DynamicSupervisor.start_child(config.supervisor_name, {pool_config.mod, pool_args})
{pid, pool_config.mod}
end)
|> hd()
end
defp put_pool_count(%{registry_name: name}, shp, val),
do: :persistent_term.put({__MODULE__, :pool_count, name, shp}, val)
def get_pool_count(finch_name, shp),
do: :persistent_term.get({__MODULE__, :pool_count, finch_name, shp}, nil)
defp maybe_track_default_shp(%{pools: pools, registry_name: name}, shp) do
if Map.has_key?(pools, shp),
do: :ok,
else: add_default_shp(name, shp)
end
defp default_shp_table(name), do: :"#{name}.default_shp_table"
defp add_default_shp(name, shp) do
true =
name
|> default_shp_table()
|> :ets.insert({shp})
:ok
end
def get_default_shps(name) do
tname = default_shp_table(name)
if :ets.whereis(tname) == :undefined do
[]
else
tname
|> :ets.tab2list()
|> Enum.map(fn {shp} -> shp end)
end
end
def maybe_remove_default_shp(name, shp) do
tname = default_shp_table(name)
if :ets.whereis(tname) == :undefined do
:ok
else
true = :ets.delete(tname, shp)
:ok
end
end
defp pool_config(%{pools: config, default_pool_config: default}, shp) do
config
|> Map.get(shp, default)
|> maybe_drop_tls_options(shp)
|> maybe_add_hostname(shp)
end
# Drop TLS options from :conn_opts for default pools with :http scheme,
# otherwise you will get :badarg error from :gen_tcp
defp maybe_drop_tls_options(config, {:http, _, _} = _shp) when is_map(config) do
with conn_opts when is_list(conn_opts) <- config[:conn_opts],
trns_opts when is_list(trns_opts) <- conn_opts[:transport_opts] do
trns_opts = Keyword.drop(trns_opts, @mint_tls_opts)
conn_opts = Keyword.put(conn_opts, :transport_opts, trns_opts)
Map.put(config, :conn_opts, conn_opts)
else
_ -> config
end
end
defp maybe_drop_tls_options(config, _), do: config
# Hostname is required when the address is not a URL (binary) so we need to specify
# a default value in case the configuration does not specify one.
defp maybe_add_hostname(config, {_scheme, {:local, _path}, _port} = _shp) when is_map(config) do
conn_opts =
config |> Map.get(:conn_opts, []) |> Keyword.put_new(:hostname, @default_conn_hostname)
Map.put(config, :conn_opts, conn_opts)
end
defp maybe_add_hostname(config, _), do: config
defp pool_args(shp, config, %{mod: Finch.HTTP1.Pool} = pool_config, pool_idx),
do: {
shp,
config.registry_name,
pool_config.size,
pool_config,
pool_config.pool_max_idle_time,
pool_config.start_pool_metrics?,
pool_idx
}
defp pool_args(shp, config, %{mod: Finch.HTTP2.Pool} = pool_config, pool_idx),
do: {
shp,
config.registry_name,
pool_config,
pool_config.start_pool_metrics?,
pool_idx
}
end

View File

@@ -0,0 +1,157 @@
defmodule Finch.Request do
@moduledoc """
A request struct.
"""
@enforce_keys [:scheme, :host, :port, :method, :path, :headers, :body, :query]
defstruct [
:scheme,
:host,
:port,
:method,
:path,
:headers,
:body,
:query,
:unix_socket,
private: %{}
]
@atom_methods [
:get,
:post,
:put,
:patch,
:delete,
:head,
:options
]
@methods [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS"
]
@atom_to_method Enum.zip(@atom_methods, @methods) |> Enum.into(%{})
@typedoc """
An HTTP request method represented as an `atom()` or a `String.t()`.
The following atom methods are supported: `#{Enum.map_join(@atom_methods, "`, `", &inspect/1)}`.
You can use any arbitrary method by providing it as a `String.t()`.
"""
@type method() :: :get | :post | :head | :patch | :delete | :options | :put | String.t()
@typedoc """
A Uniform Resource Locator, the address of a resource on the Web.
"""
@type url() :: String.t() | URI.t()
@typedoc """
Request headers.
"""
@type headers() :: Mint.Types.headers()
@typedoc """
Optional request body.
"""
@type body() :: iodata() | {:stream, Enumerable.t()} | nil
@type private_metadata() :: %{optional(atom()) => term()}
@type t :: %__MODULE__{
scheme: Mint.Types.scheme(),
host: String.t() | nil,
port: :inet.port_number(),
method: String.t(),
path: String.t(),
headers: headers(),
body: body(),
query: String.t() | nil,
unix_socket: String.t() | nil,
private: private_metadata()
}
@doc """
Sets a new **private** key and value in the request metadata. This storage is meant to be used by libraries
and frameworks to inject information about the request that needs to be retrieved later on, for example,
from handlers that consume `Finch.Telemetry` events.
"""
@spec put_private(t(), key :: atom(), value :: term()) :: t()
def put_private(%__MODULE__{private: private} = request, key, value) when is_atom(key) do
%{request | private: Map.put(private, key, value)}
end
def put_private(%__MODULE__{}, key, _) do
raise ArgumentError, """
got unsupported private metadata key #{inspect(key)}
only atoms are allowed as keys of the `:private` field.
"""
end
@doc false
def request_path(%{path: path, query: nil}), do: path
def request_path(%{path: path, query: ""}), do: path
def request_path(%{path: path, query: query}), do: "#{path}?#{query}"
@doc false
def build(method, url, headers, body, opts) do
unix_socket = Keyword.get(opts, :unix_socket)
{scheme, host, port, path, query} = parse_url(url)
%Finch.Request{
scheme: scheme,
host: host,
port: port,
method: build_method(method),
path: path,
headers: headers,
body: body,
query: query,
unix_socket: unix_socket
}
end
@doc false
def parse_url(url) when is_binary(url) do
url |> URI.parse() |> parse_url()
end
def parse_url(%URI{} = parsed_uri) do
normalized_path = parsed_uri.path || "/"
scheme =
case parsed_uri.scheme do
"https" ->
:https
"http" ->
:http
nil ->
raise ArgumentError, "scheme is required for url: #{URI.to_string(parsed_uri)}"
scheme ->
raise ArgumentError,
"invalid scheme \"#{scheme}\" for url: #{URI.to_string(parsed_uri)}"
end
{scheme, parsed_uri.host, parsed_uri.port, normalized_path, parsed_uri.query}
end
defp build_method(method) when is_binary(method), do: method
defp build_method(method) when method in @atom_methods, do: @atom_to_method[method]
defp build_method(method) do
supported = Enum.map_join(@atom_methods, ", ", &inspect/1)
raise ArgumentError, """
got unsupported atom method #{inspect(method)}.
Only the following methods can be provided as atoms: #{supported}.
Otherwise you must pass a binary.
"""
end
end

View File

@@ -0,0 +1,21 @@
defmodule Finch.Response do
@moduledoc """
A response to a request.
"""
alias __MODULE__
defstruct [
:status,
body: "",
headers: [],
trailers: []
]
@type t :: %Response{
status: Mint.Types.status(),
body: binary(),
headers: Mint.Types.headers(),
trailers: Mint.Types.headers()
}
end

View File

@@ -0,0 +1,26 @@
defmodule Finch.SSL do
@moduledoc false
alias Mint.HTTP
def maybe_log_secrets(:https, conn_opts, mint) do
ssl_key_log_file_device = Keyword.get(conn_opts, :ssl_key_log_file_device)
if ssl_key_log_file_device != nil do
socket = HTTP.get_socket(mint)
# Note: not every ssl library version returns information for :keylog. By using `with` here,
# anything other than the expected return value is silently ignored.
with {:ok, [{:keylog, keylog_items}]} <- :ssl.connection_information(socket, [:keylog]) do
for keylog_item <- keylog_items do
:ok = IO.puts(ssl_key_log_file_device, keylog_item)
end
end
else
:ok
end
end
def maybe_log_secrets(_scheme, _conn_opts, _mint) do
:ok
end
end

View File

@@ -0,0 +1,318 @@
defmodule Finch.Telemetry do
@moduledoc """
Telemetry integration.
Unless specified, all times are in `:native` units.
Finch executes the following events:
### Request Start
`[:finch, :request, :start]` - Executed when `Finch.request/3` or `Finch.stream/5` is called.
#### Measurements
* `:system_time` - The system time.
#### Metadata
* `:name` - The name of the Finch instance.
* `:request` - The request (`Finch.Request`).
### Request Stop
`[:finch, :request, :stop]` - Executed after `Finch.request/3` or `Finch.stream/5` ended.
#### Measurements
* `:duration` - Time taken from the request start event.
#### Metadata
* `:name` - The name of the Finch instance.
* `:request` - The request (`Finch.Request`).
* `:result` - The result of the operation. In case of `Finch.stream/5` this is
`{:ok, acc} | {:error, Exception.t()}`, where `acc` is the accumulator result of the
reducer passed in `Finch.stream/5`. In case of `Finch.request/3` this is
`{:ok, Finch.Response.t()} | {:error, Exception.t()}`.
### Request Exception
`[:finch, :request, :exception]` - Executed when an exception occurs while executing
`Finch.request/3` or `Finch.stream/5`.
#### Measurements
* `:duration` - The time it took since the start before raising the exception.
#### Metadata
* `:name` - The name of the Finch instance.
* `:request` - The request (`Finch.Request`).
* `:kind` - The type of exception.
* `:reason` - Error description or error data.
* `:stacktrace` - The stacktrace.
### Queue Start
`[:finch, :queue, :start]` - Executed before checking out an HTTP1 connection from the pool.
#### Measurements
* `:system_time` - The system time.
#### Metadata
* `:name` - The name of the Finch instance.
* `:pool` - The pool's PID.
* `:request` - The request (`Finch.Request`).
### Queue Stop
`[:finch, :queue, :stop]` - Executed after an HTTP1 connection is retrieved from the pool.
#### Measurements
* `:duration` - Time taken to check out a pool connection.
* `:idle_time` - Elapsed time since the connection was last checked in or initialized.
#### Metadata
* `:name` - The name of the Finch instance.
* `:pool` - The pool's PID.
* `:request` - The request (`Finch.Request`).
### Queue Exception
`[:finch, :queue, :exception]` - Executed if checking out an HTTP1 connection throws an exception.
#### Measurements
* `:duration` - The time it took since queue start event before raising an exception.
#### Metadata
* `:name` - The name of the Finch instance.
* `:request` - The request (`Finch.Request`).
* `:kind` - The type of exception.
* `:reason` - Error description or error data.
* `:stacktrace` - The stacktrace.
### Connect Start
`[:finch, :connect, :start]` - Executed before opening a new connection.
If a connection is being re-used this event will *not* be executed.
#### Measurements
* `:system_time` - The system time.
#### Metadata
* `:name` - The name of the Finch instance.
* `:scheme` - The scheme used in the connection. either `http` or `https`.
* `:host` - The host address.
* `:port` - The port to connect on.
### Connect Stop
`[:finch, :connect, :stop]` - Executed after a connection is opened.
#### Measurements
* `:duration` - Time taken to connect to the host.
#### Metadata
* `:name` - The name of the Finch instance.
* `:scheme` - The scheme used in the connection. either `http` or `https`.
* `:host` - The host address.
* `:port` - The port to connect on.
* `:error` - This value is optional. It includes any errors that occurred while opening the connection.
### Send Start
`[:finch, :send, :start]` - Executed before sending a request.
#### Measurements
* `:name` - The name of the Finch instance.
* `:system_time` - The system time.
* `:idle_time` - Elapsed time since the connection was last checked in or initialized.
#### Metadata
* `:request` - The request (`Finch.Request`).
### Send Stop
`[:finch, :send, :stop]` - Executed after a request is finished.
#### Measurements
* `:name` - The name of the Finch instance.
* `:duration` - Time taken to make the request.
* `:idle_time` - Elapsed time since the connection was last checked in or initialized.
#### Metadata
* `:request` - The request (`Finch.Request`).
* `:error` - This value is optional. It includes any errors that occurred while making the request.
### Receive Start
`[:finch, :recv, :start]` - Executed before receiving the response.
#### Measurements
* `:system_time` - The system time.
* `:idle_time` - Elapsed time since the connection was last checked in or initialized.
#### Metadata
* `:name` - The name of the Finch instance.
* `:request` - The request (`Finch.Request`).
### Receive Stop
`[:finch, :recv, :stop]` - Executed after a response has been fully received.
#### Measurements
* `:duration` - Duration to receive the response.
* `:idle_time` - Elapsed time since the connection was last checked in or initialized.
#### Metadata
* `:name` - The name of the Finch instance.
* `:request` - The request (`Finch.Request`).
* `:status` - The response status (`Mint.Types.status()`).
* `:headers` - The response headers (`Mint.Types.headers()`).
* `:error` - This value is optional. It includes any errors that occurred while receiving the response.
### Receive Exception
`[:finch, :recv, :exception]` - Executed if an exception is thrown before the response has
been fully received.
#### Measurements
* `:duration` - The time it took before raising an exception
#### Metadata
* `:name` - The name of the Finch instance.
* `:request` - The request (`Finch.Request`).
* `:kind` - The type of exception.
* `:reason` - Error description or error data.
* `:stacktrace` - The stacktrace.
### Reused Connection
`[:finch, :reused_connection]` - Executed if an existing HTTP1 connection is reused. There are no measurements provided with this event.
#### Metadata
* `:name` - The name of the Finch instance.
* `:scheme` - The scheme used in the connection. either `http` or `https`.
* `:host` - The host address.
* `:port` - The port to connect on.
### Conn Max Idle Time Exceeded
`[:finch, :conn_max_idle_time_exceeded]` - Executed if an HTTP1 connection was discarded because the `conn_max_idle_time` had been reached.
#### Measurements
* `:idle_time` - Elapsed time since the connection was last checked in or initialized.
#### Metadata
* `:scheme` - The scheme used in the connection. either `http` or `https`.
* `:host` - The host address.
* `:port` - The port to connect on.
### Pool Max Idle Time Exceeded
`[:finch, :pool_max_idle_time_exceeded]` - Executed if an HTTP1 pool was terminated because the `pool_max_idle_time` has been reached. There are no measurements provided with this event.
#### Metadata
* `:scheme` - The scheme used in the connection. either `http` or `https`.
* `:host` - The host address.
* `:port` - The port to connect on.
### Max Idle Time Exceeded (Deprecated)
`[:finch, :max_idle_time_exceeded]` - Executed if an HTTP1 connection was discarded because the `max_idle_time` had been reached.
*Deprecated:* use `:conn_max_idle_time_exceeded` event instead.
#### Measurements
* `:idle_time` - Elapsed time since the connection was last checked in or initialized.
#### Metadata
* `:scheme` - The scheme used in the connection. either `http` or `https`.
* `:host` - The host address.
* `:port` - The port to connect on.
"""
@doc false
# emits a `start` telemetry event and returns the the start time
def start(event, meta \\ %{}, extra_measurements \\ %{}) do
start_time = System.monotonic_time()
:telemetry.execute(
[:finch, event, :start],
Map.merge(extra_measurements, %{system_time: System.system_time()}),
meta
)
start_time
end
@doc false
# Emits a stop event.
def stop(event, start_time, meta \\ %{}, extra_measurements \\ %{}) do
end_time = System.monotonic_time()
measurements = Map.merge(extra_measurements, %{duration: end_time - start_time})
:telemetry.execute(
[:finch, event, :stop],
measurements,
meta
)
end
@doc false
def exception(event, start_time, kind, reason, stack, meta \\ %{}, extra_measurements \\ %{}) do
end_time = System.monotonic_time()
measurements = Map.merge(extra_measurements, %{duration: end_time - start_time})
meta =
meta
|> Map.put(:kind, kind)
|> Map.put(:reason, reason)
|> Map.put(:stacktrace, stack)
:telemetry.execute([:finch, event, :exception], measurements, meta)
end
@doc false
# Used for reporting generic events
def event(event, measurements, meta) do
:telemetry.execute([:finch, event], measurements, meta)
end
@doc false
# Used to easily create :start, :stop, :exception events.
def span(event, start_metadata, fun) do
:telemetry.span(
[:finch, event],
start_metadata,
fun
)
end
end