-module(aws@internal@credentials_cache).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/aws/internal/credentials_cache.gleam").
-export([start/3, start_default/1, get/1, as_provider/1, shutdown/1, shutdown_sync/2]).
-export_type([cache/0, message/0, state/0, start_error/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Credentials cache: a `gleam_otp` actor that owns a `Provider` and caches\n"
" the last successful `Credentials`. Re-fetches when the cached value is\n"
" within `buffer_seconds` of its `expires_at`, or when the cache is empty\n"
" (first call) or the previous fetch failed.\n"
"\n"
" Non-expiring credentials (`expires_at = None`) are cached forever — env\n"
" vars don't rotate without a process restart, so re-reading them on every\n"
" signed request would be wasteful.\n"
"\n"
" Concurrency: actor messages are handled sequentially, so two parallel\n"
" `get` calls during the first fetch coalesce into a single provider\n"
" invocation. No thundering-herd shielding beyond that — adequate for the\n"
" rates AWS SDKs see in practice.\n"
).
-opaque cache() :: {cache, gleam@erlang@process:subject(message())}.
-type message() :: {get,
gleam@erlang@process:subject({ok, aws@credentials:credentials()} |
{error, aws@credentials:provider_error()})} |
stop.
-type state() :: {state,
aws@credentials:provider(),
fun(() -> integer()),
integer(),
gleam@option:option(aws@credentials:credentials())}.
-type start_error() :: {start_failed, gleam@otp@actor:start_error()}.
-file("src/aws/internal/credentials_cache.gleam", 192).
-spec fresh_enough(state()) -> boolean().
fresh_enough(State) ->
case erlang:element(5, State) of
none ->
false;
{some, Creds} ->
case erlang:element(5, Creds) of
none ->
true;
{some, Expires_at} ->
(Expires_at - (erlang:element(3, State))()) > erlang:element(
4,
State
)
end
end.
-file("src/aws/internal/credentials_cache.gleam", 145).
-spec handle_message(state(), message()) -> gleam@otp@actor:next(state(), message()).
handle_message(State, Message) ->
case Message of
stop ->
gleam@otp@actor:stop();
{get, Reply} ->
case fresh_enough(State) of
true ->
Creds@1 = case erlang:element(5, State) of
{some, Creds} -> Creds;
_assert_fail ->
erlang:error(#{gleam_error => let_assert,
message => <<"Pattern match failed, no pattern matched the value."/utf8>>,
file => <<?FILEPATH/utf8>>,
module => <<"aws/internal/credentials_cache"/utf8>>,
function => <<"handle_message"/utf8>>,
line => 154,
value => _assert_fail,
start => 5552,
'end' => 5589,
pattern_start => 5563,
pattern_end => 5574})
end,
aws@internal@log:debug(
fun() ->
<<<<"aws credentials cache: hit (source "/utf8,
(erlang:element(6, Creds@1))/binary>>/binary,
")"/utf8>>
end
),
gleam@erlang@process:send(Reply, {ok, Creds@1}),
gleam@otp@actor:continue(State);
false ->
aws@internal@log:debug(
fun() ->
<<"aws credentials cache: miss — refreshing"/utf8>>
end
),
case (erlang:element(3, erlang:element(2, State)))() of
{ok, Creds@2} ->
aws@internal@log:debug(
fun() ->
<<<<"aws credentials cache: refreshed (source "/utf8,
(erlang:element(6, Creds@2))/binary>>/binary,
")"/utf8>>
end
),
gleam@erlang@process:send(Reply, {ok, Creds@2}),
gleam@otp@actor:continue(
{state,
erlang:element(2, State),
erlang:element(3, State),
erlang:element(4, State),
{some, Creds@2}}
);
{error, Error} ->
aws@internal@log:debug(
fun() ->
<<"aws credentials cache: refresh failed"/utf8>>
end
),
gleam@erlang@process:send(Reply, {error, Error}),
gleam@otp@actor:continue(State)
end
end
end.
-file("src/aws/internal/credentials_cache.gleam", 67).
?DOC(
" Start the cache actor.\n"
"\n"
" - `provider`: the upstream provider this cache wraps. Can itself be a\n"
" `credentials.chain([...])` — the cache doesn't care.\n"
" - `clock`: returns unix seconds. The default production wiring uses\n"
" `erlang:system_time(second)`; tests pass a closure over a controlled\n"
" counter so they can fast-forward across expiries.\n"
" - `buffer_seconds`: trigger a refresh this many seconds before\n"
" `expires_at`. See `default_buffer_seconds`.\n"
).
-spec start(aws@credentials:provider(), fun(() -> integer()), integer()) -> {ok,
cache()} |
{error, start_error()}.
start(Provider, Clock, Buffer_seconds) ->
Initial_state = {state, Provider, Clock, Buffer_seconds, none},
case begin
_pipe = gleam@otp@actor:new(Initial_state),
_pipe@1 = gleam@otp@actor:on_message(_pipe, fun handle_message/2),
gleam@otp@actor:start(_pipe@1)
end of
{ok, Started} ->
{ok, {cache, erlang:element(3, Started)}};
{error, Reason} ->
{error, {start_failed, Reason}}
end.
-file("src/aws/internal/credentials_cache.gleam", 91).
?DOC(
" Start a cache using the OS clock and `default_buffer_seconds`. For\n"
" production wiring this is almost always what you want.\n"
).
-spec start_default(aws@credentials:provider()) -> {ok, cache()} |
{error, start_error()}.
start_default(Provider) ->
start(Provider, fun aws_ffi:unix_seconds/0, 300).
-file("src/aws/internal/credentials_cache.gleam", 103).
?DOC(
" Fetch the current credentials, refreshing from the wrapped provider if\n"
" the cache is empty or the credentials are within the refresh buffer of\n"
" expiry. Returns whatever the provider produced — the cache itself never\n"
" fabricates errors.\n"
).
-spec get(cache()) -> {ok, aws@credentials:credentials()} |
{error, aws@credentials:provider_error()}.
get(Cache) ->
case aws@internal@actor_lifecycle:safe_call(
erlang:element(2, Cache),
5000,
fun(Field@0) -> {get, Field@0} end
) of
{ok, Provider_result} ->
Provider_result;
{error, nil} ->
aws@internal@log:warning(
<<"aws credentials cache: actor unavailable (dead or timed out)"/utf8>>
),
{error,
{fetch_failed, <<"credentials cache actor unavailable"/utf8>>}}
end.
-file("src/aws/internal/credentials_cache.gleam", 126).
?DOC(
" Re-expose the cache as a regular `Provider`. The returned provider's\n"
" `fetch` closure proxies to `get(cache)` — so the rest of the SDK can\n"
" thread `Provider` values around as before, but now hot-path reads\n"
" debounce into the actor and avoid re-running the seven-stage chain\n"
" on every signed request.\n"
).
-spec as_provider(cache()) -> aws@credentials:provider().
as_provider(Cache) ->
{provider, <<"Cached"/utf8>>, fun() -> get(Cache) end}.
-file("src/aws/internal/credentials_cache.gleam", 133).
?DOC(
" Tell the cache actor to exit. Fire-and-forget. See\n"
" `aws/internal/actor_lifecycle.shutdown_via_stop` for the contract;\n"
" idempotent because Erlang silently drops sends to a dead Pid.\n"
).
-spec shutdown(cache()) -> nil.
shutdown(Cache) ->
aws@internal@actor_lifecycle:shutdown_via_stop(
erlang:element(2, Cache),
stop
).
-file("src/aws/internal/credentials_cache.gleam", 141).
?DOC(
" Synchronous teardown — monitors the actor, sends `Stop`, waits for\n"
" `DOWN`. `Ok(Nil)` on clean exit, `Error(Nil)` only on real timeout.\n"
" Already-dead actors short-circuit to `Ok(Nil)` via\n"
" `subject_owner` returning `Error`.\n"
).
-spec shutdown_sync(cache(), integer()) -> {ok, nil} | {error, nil}.
shutdown_sync(Cache, Timeout_ms) ->
aws@internal@actor_lifecycle:shutdown_via_stop_sync(
erlang:element(2, Cache),
stop,
Timeout_ms
).