Current section

Files

Jump to
gemini_ex lib gemini auth token_cache.ex
Raw

lib/gemini/auth/token_cache.ex

defmodule Gemini.Auth.TokenCache do
@moduledoc """
ETS-based token caching with automatic expiration handling.
Provides thread-safe token caching to reduce API calls for token refresh.
Tokens are automatically considered expired based on their TTL, with a
configurable refresh buffer to ensure tokens are refreshed before expiration.
## Features
- Thread-safe ETS-based storage
- Automatic expiration handling
- Refresh buffer (default 5 minutes before expiry)
- Multiple cache key support for different credentials
## Usage
# Initialize the cache (called automatically on application start)
TokenCache.init()
# Cache a token with 3600 second TTL
TokenCache.put("my_key", "access_token_here", 3600)
# Retrieve cached token (returns nil if expired)
case TokenCache.get("my_key") do
{:ok, token} -> {:ok, token}
:error -> # Token expired or not found, refresh needed
end
# Invalidate a cached token
TokenCache.invalidate("my_key")
"""
require Logger
@table_name :gemini_token_cache
# Refresh tokens 5 minutes (300 seconds) before they expire
@default_refresh_buffer 300
@type cache_key :: String.t() | atom()
@type token :: String.t()
@type ttl :: pos_integer()
@type cache_entry :: {cache_key(), token(), expiry_time :: integer()}
@doc false
@spec credential_key(atom(), map() | keyword()) :: String.t()
def credential_key(kind, identity)
when is_atom(kind) and (is_map(identity) or is_list(identity)) do
digest =
{kind, identity |> Map.new() |> Enum.sort()}
|> :erlang.term_to_binary()
|> then(&:crypto.hash(:sha256, &1))
|> Base.url_encode64(padding: false)
"credential-cache://#{kind}/#{digest}"
end
@doc """
Initialize the token cache table.
Creates an ETS table for storing tokens. This is called automatically
when the application starts, but can be called manually if needed.
Safe to call multiple times - if the table already exists, it will
not create a new one.
## Examples
iex> Gemini.Auth.TokenCache.init()
:ok
"""
@spec init() :: :ok
def init do
if :ets.whereis(@table_name) == :undefined do
# Serialize creation so concurrent callers don't crash with :already_exists
:global.trans({:gemini_token_cache_init, node()}, fn -> ensure_table_created() end)
end
:ok
end
@doc """
Cache a token with the specified time-to-live (TTL).
The token will be considered expired after `ttl` seconds, minus the
refresh buffer (default 5 minutes). This ensures tokens are refreshed
before they actually expire.
## Parameters
- `key`: Unique identifier for this token (string or atom)
- `token`: The access token to cache
- `ttl`: Time-to-live in seconds
## Options
- `:refresh_buffer` - Seconds before expiry to consider token expired
(default: 300 seconds / 5 minutes)
## Examples
# Cache token for 1 hour
TokenCache.put("vertex_ai_token", "ya29.abc123", 3600)
# Cache with custom refresh buffer (refresh 10 minutes early)
TokenCache.put("my_token", "token123", 3600, refresh_buffer: 600)
"""
@spec put(cache_key(), token(), ttl(), keyword()) :: :ok
def put(key, token, ttl, opts \\ []) when is_binary(token) and is_integer(ttl) and ttl > 0 do
refresh_buffer = Keyword.get(opts, :refresh_buffer, @default_refresh_buffer)
# Calculate actual expiry time with buffer
effective_ttl = max(ttl - refresh_buffer, 0)
expiry_time = System.system_time(:second) + effective_ttl
ensure_table_exists()
:ets.insert(@table_name, {key, token, expiry_time})
Logger.debug(
"[TokenCache] Cached token for #{key_ref(key)} (expires in #{effective_ttl}s, buffer: #{refresh_buffer}s)"
)
:ok
end
@doc """
Retrieve a cached token if it exists and has not expired.
Returns `{:ok, token}` if the token is cached and still valid,
or `:error` if the token is expired or not found.
## Parameters
- `key`: The cache key used when storing the token
## Returns
- `{:ok, token}` - Token is cached and still valid
- `:error` - Token is expired, not found, or cache not initialized
## Examples
case TokenCache.get("my_key") do
{:ok, token} ->
# Use the cached token
{:ok, token}
:error ->
# Token expired or not found, need to refresh
refresh_token()
end
"""
@spec get(cache_key()) :: {:ok, token()} | :error
def get(key) do
if :ets.whereis(@table_name) == :undefined do
Logger.warning("[TokenCache] Token cache not initialized")
:error
else
lookup_token(key)
end
end
@doc """
Invalidate (remove) a cached token.
Useful when you know a token is no longer valid and want to force
a refresh on the next request.
## Parameters
- `key`: The cache key to invalidate
## Examples
TokenCache.invalidate("my_key")
"""
@spec invalidate(cache_key()) :: :ok
def invalidate(key) do
ensure_table_exists()
:ets.delete(@table_name, key)
Logger.debug("[TokenCache] Invalidated cache for #{key_ref(key)}")
:ok
end
@doc """
Clear all cached tokens.
Removes all entries from the cache. Useful for testing or when
you need to force refresh all tokens.
## Examples
TokenCache.clear()
"""
@spec clear() :: :ok
def clear do
ensure_table_exists()
:ets.delete_all_objects(@table_name)
Logger.debug("[TokenCache] Cleared all cached tokens")
:ok
end
@doc """
Get statistics about the token cache.
Returns information about the cache including number of entries
and which tokens are cached.
## Returns
Map with cache statistics:
- `:size` - Number of cached tokens
- `:keys` - List of irreversible cache-key fingerprints
## Examples
TokenCache.stats()
#=> %{size: 2, keys: ["cache-key://sha256/...", "cache-key://sha256/..."]}
"""
@spec stats() :: %{size: non_neg_integer(), keys: [String.t()]}
def stats do
case :ets.whereis(@table_name) do
:undefined ->
%{size: 0, keys: []}
_ref ->
size = :ets.info(@table_name, :size)
keys =
@table_name
|> :ets.match({:"$1", :_, :_})
|> List.flatten()
|> Enum.map(&key_ref/1)
%{size: size, keys: keys}
end
end
# Private helpers
defp ensure_table_exists do
case :ets.whereis(@table_name) do
:undefined -> init()
_ref -> :ok
end
end
defp ensure_table_created do
if :ets.whereis(@table_name) == :undefined do
create_table()
end
:ok
end
defp create_table do
:ets.new(@table_name, [
:set,
:public,
:named_table,
read_concurrency: true,
write_concurrency: true
])
Logger.debug("[TokenCache] Initialized token cache table")
rescue
ArgumentError ->
# Another process created the table first
:ok
end
defp lookup_token(key) do
case :ets.lookup(@table_name, key) do
[{^key, token, expiry_time}] ->
validate_token(key, token, expiry_time)
[] ->
Logger.debug("[TokenCache] Cache miss for #{key_ref(key)}")
:error
end
end
defp validate_token(key, token, expiry_time) do
now = System.system_time(:second)
if now < expiry_time do
Logger.debug("[TokenCache] Cache hit for #{key_ref(key)}")
{:ok, token}
else
Logger.debug(
"[TokenCache] Cache expired for #{key_ref(key)} (expired #{now - expiry_time}s ago)"
)
# Clean up expired entry
:ets.delete(@table_name, key)
:error
end
end
defp key_ref(key) do
digest =
key
|> :erlang.term_to_binary()
|> then(&:crypto.hash(:sha256, &1))
|> Base.url_encode64(padding: false)
"cache-key://sha256/#{digest}"
end
end