In today's post, we'll look at two Elixir HTTP client libraries: Mint and Finch. Finch is built on top of Mint. We'll see the benefits offered by this abstraction layer. We'll also talk about some of the existing HTTP client libraries in the ecosystem and discuss some of the things that make Mint and Finch different. Finally, we'll put together a quick project that makes use of Finch to put all of our learning into action.
Let's jump right in!
What Tools Are Currently Available in the Elixir Ecosystem?
While an HTTP client may not be the most interesting part of your application, more than likely you'll use an HTTP client at some point to interface with 3rd party resources or even internal HTTP microservices. Having an HTTP client with usable ergonomics and a friendly API can help ensure that you deliver application features quickly.
If you are looking for something that comes with the BEAM, you can leverage the :httpc
module. While :httpc
works great for simple requests, it
can be limiting at times, given it doesn't have built-in support for connection pools, SSL verification requires a bit
of a ceremony to get set up and the API is not the most intuitive to work with
(see this article for more information).
HTTPoison is a 3rd party library that provides some nice abstractions on top of the Erlang library Hackney in order to
provide a nice developer experience from within Elixir. It also supports some nice features such as connection pools and
the ability to create HTTP client library modules via use HTTPoison.Base
. Unfortunately, in the past, I have
experienced issues with Hackney in high traffic applications (so have other
users). If I suspect that I will be making a large number of concurrent requests
to an HTTP API, I try to keep that in mind and plan for possible failure scenarios.
Tesla provides additional abstraction layers in the form of an HTTP client that can be configured using middlewares (similar to Plug), mock responses for testing, and even different adapters to perform the HTTP requests. In fact, you can even use Mint as an adapter in Tesla. If I'm making a very involved HTTP client with many different interactions and behaviors, I'll usually reach for Tesla as it makes it easy to package these pieces of functionality together, and the testing utilities make your ExUnit tests very clean.
How Are Mint and Finch Different?
The previously mentioned tools all rely on a process to keep track of the ongoing HTTP connection. Mint on the other
hand provides a low-level, process-less API for interacting with TCP/SSL sockets. Every time you interact with Mint, for
example, you'll be given back a new Mint.HTTP1
or Mint.HTTP2
struct handler. In addition, any data coming into the
socket will be sent as a message to the process that initiated the connection. This data can then be captured via a
simple receive
block and handled accordingly. While this may seem limiting, it is by design. The architecture of Mint
lends itself to being extensible and enables other library authors to wrap Mint however they see fit.
This is where Finch comes in. Finch is a library that wraps Mint and provides many of the HTTP client features that you would expect from a fully-fledged HTTP client. For example, with Finch, you get connection pooling and request Telemetry out-of-the-box. While that feature set may not be as thorough as HTTPoison or Tesla, Finch is very much focused on being lightweight and performant.
Hands-On Project
Now that we've discussed some of the design decisions that went into Mint and Finch, it's time to dive into a sample project. Our sample application will be a functional programming language Hacker News counter. It will work by taking a Hacker News article ID and then fetching all the child posts of that parent post. It will then leverage a connection pool to the Hacker News Firebase API to perform a number of concurrent calls to the API to fetch all the child posts. Once all the child posts have been fetched, all the text bodies will be extracted and analyzed for the names of certain functional programming languages. Finally, we'll print out our results, along with some metrics that were collected via Telemetry events. With all that being said, let's jump right into it (for reference, all the code can be found at https://github.com/akoutmos/functional_langs)!
Let's start off by creating a new Elixir project with a supervision tree:
1$ mix new functional_langs --sup
With that in place, let's open the mix.exs
file and add the required dependencies:
1defp deps do
2 [
3 {:finch, "~> 0.3.0"},
4 {:jason, "~> 1.2"}
5 ]
6end
Once your mix.exs
file has been updated, switch over to the terminal and run mix deps.get
to fetch your
dependencies. Next, we'll want to create the file lib/functional_langs/hacker_news_client.ex
that will encompass all of
our Finch related code. This file will include all the calls necessary to interact with the Hacker News Firebase API
and will also include some utility functions to extract the desired data from the JSON payloads. Let's start off by
adding some of the foundation of our lib/functional_langs/hacker_news_client.ex
file:
1defmodule FunctionalLangs.HackerNewsClient do
2 alias Finch.Response
3
4 def child_spec do
5 {Finch,
6 name: __MODULE__,
7 pools: %{
8 "https://hacker-news.firebaseio.com" => [size: pool_size()]
9 }}
10 end
11
12 def pool_size, do: 25
13
14 def get_item(item_id) do
15 :get
16 |> Finch.build("https://hacker-news.firebaseio.com/v0/item/#{item_id}.json")
17 |> Finch.request(__MODULE__)
18 end
19end
Our child_spec/0
function defines the child spec for the Finch connection pool. We will be leveraging this function in
our lib/functional_langs/application.ex
file so that we can start up our Finch connection pool within our application
supervision tree. Our pool_size/0
function defines how big our connection pool to the
https://hacker-news.firebaseio.com
address will be. For testing purposes, 25 concurrent connections should be more
than enough to traverse even the largest Hacker News posts. Lastly, the get_item/1
function is what makes the actual
GET
call to the Hacker News Firebase API. We first define the HTTP verb as an atom, we then build the request, and
finally, we make the request while providing the name of the module (notice that this lines up with the name of the
connection pool in child_spec/0
). With that in place, we are able to make calls to the Hacker News Firebase API and
leverage our connection pool using Finch.
With that in place, let's wrap up our FunctionalLangs.HackerNewsClient
module:
1defmodule FunctionalLangs.HackerNewsClient do
2 ...
3
4 def get_child_ids(parent_item_id) do
5 parent_item_id
6 |> get_item()
7 |> handle_parent_response()
8 end
9
10 defp handle_parent_response({:ok, %Response{body: body}}) do
11 child_ids =
12 body
13 |> Jason.decode!()
14 |> Map.get("kids")
15
16 {:ok, child_ids}
17 end
18
19 def get_child_item(child_id) do
20 child_id
21 |> get_item()
22 |> get_child_item_text()
23 end
24
25 defp get_child_item_text({:ok, %Response{body: body}}) do
26 body
27 |> Jason.decode!()
28 |> case do
29 %{"text" => text} -> String.downcase(text)
30 _ -> ""
31 end
32 end
33end
While the majority of this code snippet is related to unpacking and massaging the data, one thing I'll touch on though
is the pattern match on the Response
struct. If you recall from the previous code snippet, we alias Finch.Response
and then use that alias here. By pattern matching on the struct, we are able to easily extract the response body and work
with the returned data. For more details on Finch.Response
struct, feel free to
check out the documentation.
With our client module wrapped up, let's quickly open up lib/functional_langs/application.ex
and add the following
line to our supervision tree so that our connection pool can be started with the application:
1def start(_type, _args) do
2 children = [
3 FunctionalLangs.HackerNewsClient.child_spec()
4 ]
5
6 ...
7end
Our application will use the Hacker News client that we just wrote to fetch all the child posts of a Hacker News item
and then look for occurrences of functional programming language names in each of those child posts. Once we have
everything tallied up, we'll present a nice ASCII chart and some overarching metrics. With that said, let's open up
lib/functional_langs.ex
and start by adding the following:
1defmodule FunctionalLangs do
2 require Logger
3
4 alias FunctionalLangs.HackerNewsClient
5
6 @langs_of_interest ~w(elixir erlang haskell clojure scala f# idris ocaml)
7 @label_padding 7
8 @telemetry_event_id "finch-timings"
9
10 def generate_report(parent_item_id) do
11 # Setup Telemetry events and Agent to store timings
12 {:ok, http_timings_agent} = Agent.start_link(fn -> [] end)
13 start_time = System.monotonic_time()
14 attach_telemetry_event(http_timings_agent)
15
16 # Get all of the child IDs associated with a parent item
17 {:ok, child_ids} = HackerNewsClient.get_child_ids(parent_item_id)
18
19 # Concurrently process all of the child IDs and aggregate the results into a graph
20 child_ids
21 |> Task.async_stream(&HackerNewsClient.get_child_item/1, max_concurrency: HackerNewsClient.pool_size())
22 |> Enum.reduce([], fn {:ok, text}, acc ->
23 [text | acc]
24 end)
25 |> Enum.map(&count_lang_occurences/1)
26 |> Enum.reduce(%{}, &sum_lang_occurences/2)
27 |> print_table_results()
28
29 # Calculate average API request time and total time
30 average_time = calc_average_req_time(http_timings_agent)
31 total_time = System.convert_time_unit(System.monotonic_time() - start_time, :native, :millisecond)
32
33 # Clean up side-effecty resources
34 :ok = Agent.stop(http_timings_agent)
35 :ok = :telemetry.detach(@telemetry_event_id)
36
37 IO.puts("Average request time to Hacker News Firebase API: #{average_time}ms")
38 IO.puts("Total time to fetch all #{length(child_ids)} child posts: #{total_time}ms")
39 end
40end
The first few lines of generate_report/1
are responsible for starting an Agent that will be used to collect
Telemetry metrics from Finch. The Agent PID is sent over to the attach_telemetry_event/1
function so that the handler
that is created knows the PID of the Agent. The implementation of the attach_telemetry_event/1
function is as follows:
1defmodule FunctionalLangs do
2 ...
3
4 defp attach_telemetry_event(http_timings_agent) do
5 :telemetry.attach(
6 @telemetry_event_id,
7 [:finch, :response, :stop],
8 fn _event, %{duration: duration}, _metadata, _config ->
9 Agent.update(http_timings_agent, fn timings -> [duration | timings] end)
10 end,
11 nil
12 )
13 end
14end
This function simply attaches to the Finch [:finch, :response, :stop]
event and adds the duration metric to the
Agent's list of metrics. Back in our generate_report/1
function, our next step is to get all the child item IDs
from the provided parent_item_id
and put that into our processing pipeline. Our processing pipeline leverages
Task.async_stream/3
in order to concurrently process all the child item IDs. One important thing to note is that
our options to Task.async_stream/3
include max_concurrency: HackerNewsClient.pool_size()
. The reason for this is
that we only want to run the same number of concurrent tasks as there are connections in the Finch connection pool.
Our next piece of the pipeline is to reduce on the results from Task.async_stream/3
and to extract all the text blocks
that were fetched. Afterward, we perform an Enum.map/2
and an Enum.reduce/3
on those results in order to tally up
our results. The implementations of the functions used in Enum.map/2
and Enum.reduce/3
are:
1defmodule FunctionalLangs do
2 ...
3
4 defp count_lang_occurences(text) do
5 Map.new(@langs_of_interest, fn string_of_interest ->
6 count = if String.contains?(text, string_of_interest), do: 1, else: 0
7
8 {string_of_interest, count}
9 end)
10 end
11
12 defp sum_lang_occurences(counts, acc) do
13 Map.merge(acc, counts, fn _lang, count_1, count_2 ->
14 count_1 + count_2
15 end)
16 end
17end
The last step in the pipeline is to take the results and pretty print a sorted ASCII chart so that we can see the
results from greatest to least. Below is the implementation of print_table_results/1
:
1defmodule FunctionalLangs do
2 ...
3
4 defp print_table_results(results) do
5 results
6 |> Enum.sort(fn {_lang_1, count_1}, {_lang_2, count_2} ->
7 count_1 > count_2
8 end)
9 |> Enum.each(fn {language, count} ->
10 label = String.pad_trailing(language, @label_padding)
11 bars = String.duplicate("█", count)
12
13 IO.puts("#{label} |#{bars}")
14 end)
15 end
16end
With that completed, all that's left is to aggregate all the captured Telemetry metrics and compute the average
request time, clean up our side-effecty resources and print the results. The implementation of
calc_average_req_time/1
looks like this:
1defmodule FunctionalLangs do
2 ...
3
4 defp calc_average_req_time(http_timings_agent) do
5 http_timings_agent
6 |> Agent.get(fn timings -> timings end)
7 |> Enum.reduce({0, 0}, fn timing, {sum, count} ->
8 {sum + timing, count + 1}
9 end)
10 |> case do
11 {_, 0} ->
12 "0"
13
14 {sum, count} ->
15 sum
16 |> System.convert_time_unit(:native, :millisecond)
17 |> Kernel./(count)
18 |> :erlang.float_to_binary(decimals: 2)
19 end
20 end
21end
With all that in place, we are ready to use our application! In your terminal, run iex -S mix
to launch an
IEx session with all of our modules loaded. Once your IEx session is up and running, you can call your
FunctionalLangs.generate_report/1
function to generate the language occurrence report (the "23702122" is from the
"July 2020 Who is hiring" post):
1iex(1) ▶ FunctionalLangs.generate_report("23702122")
2scala |█████████████████████████████████████████████
3elixir |██████████████
4clojure |██████████
5haskell |█████
6f# |███
7erlang |██
8ocaml |█
9idris |█
10Average request time to Hacker News Firebase API: 58.17ms
11Total time to fetch all 564 child posts: 2185ms
Conclusion
Thanks for sticking with me to the end and hopefully you learned a thing or two about Mint and Finch! From this tutorial, we learned how to create connection pools with Finch, attach Telemetry handlers to Finch events, and how to make large amounts of concurrent requests to an API. While Finch is relatively new compared to other HTTP clients, it is built upon tried and tested libraries like Mint and NimblePool.
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!