Real world usecase for GenServer and Agent in Elixir

Sreenadh TC's avatar

Sreenadh TC

Concurrency is the bread and butter of an Elixir OTP application. We are gonna look at one of many ways how Elixir helps in achieving concurrency for serving several function calls parallely.
Concepts discussed in this blog post requires a very basic understanding of GenServer in Elixir/Erlang.

Any application that talks to an external API requiring OAuth2 credentials, would have to manage the access token and the rudementary step of refreshing the token when it expires. Although there are two common ways of making sure the tokens are refreshed, I tend to incline on the side of making sure the token is refreshed just before it expires. This way, I don't have to wait for the token failure in order to refresh it.
The other way would be to fire a refresh when we receive a 401. But wait! ⚠️

🚨 In most cases, distinguishing between an expired token and a wrong token is really hard, as both cases would have an HTTP-401 Unauthorized response from the server

Our goal

We want a module inside our Fancy application that gives us an OAuth2 access token to call https://funny-server.foo/videos/charlie-chaplin API.
An ideal way of keeping the token refreshed and accessible by every other module in our Fancy application is, to cache it somewhere. Caching could be as simple as an in-memory object, in Redis or even in a file. But for storing a small string like the access token, Redis and Files are way too fancy 🤷🏽‍♂️

Best option is to use an In-Memory object storage. Elixir/Erlang in general let's us store and share data in about 4 different ways.

1. GenServer

GenServer (or General Server) is the most common and obvious OTP library you will utilize in Elixir application. We can use GenServer to implement behaviours as BEAM processes which holds a state (our data). We can also query the same state and make changes to the current state through calls and casts.

2. Agent

Agent is built on top of GenServer. It requires lesser lines of code. When it comes to the actual use, one could argue that it is similar to GenServer. As for the actual usecase of storing/sharing state (our data), Agent does exactly the same thing which a bare-bone GenServer can, but in a more developer friendly manner by abstracting away the GenServer implementation. Agent exposes 4 main functions, cast/2, get/3, update/3 and get_and_update/3. We'll see them in detail later on in the post.

3. ETS (Erlang Term Storage) tables

Unless you have the need and a strong reasoning behind it, I would recommend not to use ETS for usecases like our's. The number of tables in an Erlang node used to be limited, but this is no longer the case now. Even then, one thing to keep in mind is about the inability of automatic garbage collection of the tables. This can be an issue as Erlang will only remove the tables when it's owner process terminates. Long story short, let's not use this option for now.

4. Dedicated Storages

Modern cloud services offer high speed cache storage, and disks which can be an option for some usecases. However our's is not one. Files and network can also be options, but those aren't viable for us.

Alright, so let's look at GenServer and Agent in detail. Firstly I will show how a GenServer version of Fancy.Auth looks like. We'll then see how Agent help us unclutter some of our code into a concise easy to read set of lines.

defmodule Fancy.Auth do
  use GenServer

  @endpoint "https://funny-server.foo/oauth2/token"

  # Client APIs
  def start_link(_arg) do
    GenServer.start(__MODULE__, %{}, name: __MODULE__)
  end

  def token do
    GenServer.call(__MODULE__, :token)
  end

  # Server APIs
  def init(state) do
  # fetch token and save it as state in the GenServer process
  {:ok, refresh_token()}
  end

  def handle_call(:token, _from, state) do
    {token, new_state} = get_token(state)
    {:reply, token, new_state}
  end

  defp refresh_token() do
    # ... lines skipped for brevity ...

    %{status_code: 200, body: body} =
      resp = HTTPoison.post!(@endpoint, payload, headers, options)
    # Successfully fetched access token

    body = Jason.decode!(body, keys: :atoms)
    Map.put(body, :expires_in, :os.system_time(:seconds) + body.expires_in)
  end

  defp get_token(%{expires_in: expires_in, access_token: token} = state) do
    now = :os.system_time(:seconds)

    # I am greedy ^_^
    has_aged? = now + 1 > expires_in

    if has_aged? do
      # Refresh OAuth token as it has aged
      auth = refresh_token()
      {auth.access_token, auth}
    else
      # No need to refresh, send back the same token
      {token, state}
    end
  end
end

This is a very simple and straightforward GenServer which exposes a single API, the Fancy.Auth.token/1 that retrieves an access token from funny-server.foo and also refreshes if it is expiring in the next second. Now let us see how we can achieve same functionality using Agent with lesser but more readable lines of code.

defmodule Fancy.Auth do
  use Agent

  @endpoint "https://funny-server.foo/oauth2/token"

  def start_link(_arg) do
    Agent.start_link(fn -> refresh_token() end, name: __MODULE__)
  end

  def token do
    token = Agent.get_and_update(__MODULE__, fn state -> get_token(state) end)
    token
  end

  # ... lines below are exactly the same as in the GenServer snippet ...
  defp refresh_token() do
    # ... lines skipped for brevity ...

    %{status_code: 200, body: body} =
       resp = HTTPoison.post!(@endpoint, payload, headers, options)
    # Successfully fetched access token

    body = Jason.decode!(body, keys: :atoms)
    Map.put(body, :expires_in, :os.system_time(:seconds) + body.expires_in)
  end

  defp get_token(%{expires_in: expires_in, access_token: token} = state) do
    now = :os.system_time(:seconds)

    # I am greedy ^_^
    has_aged? = now + 1 > expires_in

    if has_aged? do
      # Refresh OAuth token as it has aged
      auth = refresh_token()
      {auth.access_token, auth}
    else
      # No need to refresh, send back the same token
      {token, state}
    end
  end

I have used Agent.get_and_update/3 here, but we can also do it in two steps by using Agent.get/3 inside Auth.token/1 and Agent.update/3 inside Auth.refresh_token/0.
Agent.cast/2 can be used to make the Auth.refresh_token/0 asynchronous. Keep in mind that cast is Fire-and-Forget, meaning the actual execution of API call for refreshing the access token happens asynchronously. Depending on your use case, this may or may not be favorable.

What exactly did we gain here by using Agent over GenServer?

I would agree on the fact that GenServer makes our module look more complex. In reality, the only core functionality of this module is, fetching an access token. For such concise usecase Agent makes more sense.

While implementing a GenServer behaviour, a common pattern is abstraction of Client -> Server functions away from the user code for packages or the business logic in applications. This introduces additional lines of code in our module. While this is great for a more complex server, our's doesn't need to be bloated with GenServer handle_call functions and other stuff. Agent looks more neat and thin!

Having said that, there is no real performance gain in using GenServer over Agent for such usecases. I am not talking about the benchmarks where you might see an ever so slightly better performance from GenServer, which would be of the order of µs. However, a GenServer is much easier to understand in more complex cases. Separating callbacks from the public API would let me document the code better and also help me reuse most of the code.

In the end its your code, take the call!

This post does not intend to favor one OTP over the other. I believe the choice of Agent vs GenServer comes down to more of a personal preference for a particular usecase.

Keep it simple and stupid with Agent when the module itself is a simple key-value access mechanism. Separate the concerns using GenServer when the functionality of the module is much larger and you require fine tuned handling of callbacks!

I hope you found this post useful in implementing similar real world usecases. Stay tuned to our blog for more exciting topics!