elixir

The State of Elixir HTTP Clients

Alex Koutmos

Alex Koutmos on

The State of Elixir HTTP Clients

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!

Share this article

RSS
Alex Koutmos

Alex Koutmos

Guest author Alex Koutmos is a Senior Software Engineer who writes backends in Elixir, frontends in VueJS and deploys his apps using Kubernetes. When he is not programming or blogging he is wrenching on his 1976 Datsun 280z.

All articles by Alex Koutmos

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps