Skip to main content

src/aws@internal@credentials_cache.erl

-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
    ).