Created
March 4, 2023 10:51
-
-
Save rajrajhans/01f73dc44f0be3682de807abe95430b2 to your computer and use it in GitHub Desktop.
Refreshing Third Party Tokens before they expire in Elixir using GenServers
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# https://rajrajhans.com/2023/02/refreshing-tokens-before-expiry-in-elixir/ | |
defmodule MyApplication.TokenCache do | |
use GenServer | |
alias MyApplication.RandomApi | |
@refresh_lead_time_ms :timer.minutes(5) | |
@token_key :random_api | |
require Logger | |
def start_link(opts \\ []) do | |
GenServer.start_link(__MODULE__, :ok, Keyword.put(opts, :name, __MODULE__)) | |
end | |
def get() do | |
:ets.lookup(__MODULE__, @token_key) | |
|> refresh_auth_if_required() | |
|> parse_cached_auth | |
end | |
## GenServer Callbacks | |
def init(:ok) do | |
ets = :ets.new(__MODULE__, [:named_table, read_concurrency: true]) | |
{:ok, ets} | |
end | |
def handle_call(:refresh_auth, _from, ets) do | |
auth = refresh_auth(ets) | |
{:reply, auth, ets} | |
end | |
def handle_info(:refresh_auth, ets) do | |
refresh_auth(ets) | |
{:noreply, ets} | |
end | |
### Token Cache Helpers | |
defp refresh_auth_if_required([]) do | |
GenServer.call(__MODULE__, :refresh_auth, 30_000) | |
end | |
defp refresh_auth_if_required([{_key, cached_auth}]) do | |
if should_refresh_auth?(cached_auth) do | |
Logger.info("Auth token is about to expire, refreshing it now") | |
GenServer.call(__MODULE__, :refresh_auth, 30_000) | |
else | |
cached_auth | |
end | |
end | |
defp refresh_auth(ets) do | |
:ets.lookup(__MODULE__, @token_key) | |
|> refresh_auth_if_stale(ets) | |
end | |
defp refresh_auth_if_stale([], ets) do | |
refresh_auth_now(ets) | |
end | |
defp refresh_auth_if_stale([{_key, cached_auth}], ets) do | |
if should_refresh_auth?(cached_auth) do | |
Logger.info("Auth token is about to expire, refreshing it now") | |
refresh_auth_now(ets) | |
else | |
# we still have a valid auth token, so simply return that | |
cached_auth | |
end | |
end | |
defp refresh_auth_if_stale(_, ets) do | |
refresh_auth_now(ets) | |
end | |
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 | |
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 next_refresh_in(_), do: raise("Invalid auth struct") | |
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 | |
defp parse_cached_auth(_), do: {:error, :invalid_auth} | |
defp should_refresh_auth?(auth) do | |
next_refresh_in(auth) <= 0 || is_auth_expired?(auth) | |
end | |
defp is_auth_expired?(%{ | |
expiration_s: expiration_s, | |
token_issued_at: token_issued_at, | |
token: _token | |
}) do | |
token_age_s = get_token_age_s(token_issued_at) | |
token_age_s >= expiration_s | |
end | |
defp get_token_age_s(token_issued_at) do | |
DateTime.diff(DateTime.utc_now(), token_issued_at, :second) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment