When your application depends on third-party APIs that require auth tokens, you encounter the problem of token expiry. Tokens usually have a limited lifespan, and you need to refresh them before they expire. Using expired tokens will result in errors. Getting new token at the time of request will work, but it will increase response time, and what if there’s an error while you’re getting the token? Best solution will be to have something that will be responsible for refreshing the token before it expires. Luckily, we have GenServers for this!
I came across this while going through ExAws code, which is a library for interacting with AWS services in Elixir. You can check their implementation here
We will create a GenServer that will cache the token and will be responsible for refreshing it before it expires. We’ll store the token, along with the time of issue and expiry in an ETS table.
Our GenServer will expose a get/0
function to get the token. When it’s called for the first time, it will get a token from API, store the date in ETS table, and return it. It will also use Process.send_after/3
to send a message to itself to refresh the token before expiry. When the message is received, it will get a new token from API and store it in ETS table. This way, we will always have a valid token in the cache. As an added check, we’ll also check if the token is expired or not before returning it in the get/0
function. We’ll store the ets table reference in the state of the GenServer.
def get() do :ets.lookup(__MODULE__, @token_key) |> refresh_auth_if_required() |> parse_cached_auth end
To be on the safe side, we’ll have a refresh_leading_time
which will be the time difference between when the token will expire and when we’ll refresh it. For example, say current time is 10:00 AM, and we got a token which will expire in 30 minutes (at 10:30 AM). If refresh_leading_time
is 5 minutes, we’ll refresh the token at 10:25 AM. This will give us some buffer time in case the token takes some time to refresh or there are any errors, and we have to retry.
We’ll have a refresh_auth_now/1
function that will take in a reference to the ETS table and will refresh the token. It will also schedule a message to itself to refresh the token before expiry. If there’s an error while getting the token, it will schedule a retry in 5 seconds.
defp refresh_auth_now(ets) do with {:ok, auth} <- get_auth() do :ets.insert(ets, {@token_key, auth}) Process.send_after(__MODULE__, :refresh_auth, next_refresh_in(auth)) auth else {:error, _e} -> # schedule a retry in 5 seconds on error Process.send_after(__MODULE__, :refresh_auth, 5_000) end end
next_refresh_in/1
will calculate the time difference between when the token will expire and when we’ll refresh it. It will also subtract the refresh_leading_time
from it.
defp next_refresh_in(%{expiration_s: expiration_s, token_issued_at: token_issued_at}) do expires_in_ms = expiration_s * 1000 token_age_ms = get_token_age_s(token_issued_at) * 1000 next_refresh_in_ms = expires_in_ms - token_age_ms max(0, next_refresh_in_ms - @refresh_lead_time_ms) end
defp get_token_age_s(token_issued_at) do DateTime.diff(DateTime.utc_now(), token_issued_at, :second) end
We will store the actual token, the time at which it was issued, and its expiry in the ETS table. For this, we’ll have the get_auth
and parse_auth
functions.
defp get_auth() do with {:ok, token, expiration} <- RandomApi.get_access_token() do {:ok, %{ expiration_s: expiration, token: token, token_issued_at: DateTime.utc_now() }} else {:error, e} -> Logger.error("Error getting the auth token: #{inspect(e)}") {:error, e} end end
defp parse_cached_auth(%{token: token}) do {:ok, token} end
Finally, we just need to add genserver callbacks, and some extra functions to maintain the ETS table state in the GenServer’s state. You can check the full code here.
That’s all for this post. I hope you found it useful!