Skip to main content

aws_runtime/
static_stability.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Static-stability credentials caching for AWS clients.
7//!
8//! `StaticStabilityCache` is the default identity cache for AWS clients (installed by codegen and
9//! `aws-config`). It is a retain-always, partition-keyed cache that provides *static stability*: on
10//! a failed credential refresh it keeps serving the previously-resolved identity past expiration
11//! (subject to backoff), so applications continue signing requests through a credential-source
12//! outage. It caches any `Identity` — credentials and bearer tokens alike — and reads its
13//! static-stability eligibility from a generic identity property.
14//!
15//! The `invalidation` submodule carries the auth-failure detection half of invalidation;
16//! the cache's `ResolveCachedIdentity::invalidate` is the action half.
17
18pub mod invalidation;
19
20use aws_credential_types::provider::error::CredentialsError;
21use aws_credential_types::StaticStabilityEligible;
22use aws_smithy_async::future::timeout::Timeout;
23use aws_smithy_async::rt::sleep::AsyncSleep;
24use aws_smithy_runtime_api::box_error::BoxError;
25use aws_smithy_runtime_api::client::identity::{
26    Identity, IdentityCachePartition, IdentityFuture, ResolveCachedIdentity, ResolveIdentity,
27    SharedIdentityCache, SharedIdentityResolver,
28};
29use aws_smithy_runtime_api::client::runtime_components::{
30    RuntimeComponents, RuntimeComponentsBuilder,
31};
32use aws_smithy_runtime_api::shared::IntoShared;
33use aws_smithy_types::config_bag::ConfigBag;
34use aws_smithy_types::retry::RetryConfig;
35use aws_smithy_types::timeout::TimeoutConfig;
36use std::collections::HashMap;
37use std::error::Error;
38use std::fmt;
39use std::sync::{Arc, Mutex, RwLock};
40use std::time::{Duration, SystemTime};
41use tracing::Instrument;
42
43// Blocking refresh point before expiration
44const DEFAULT_MANDATORY_WINDOW: Duration = Duration::from_secs(60);
45// Refresh point before expiry for caching-only (ineligible) identities
46const CACHING_ONLY_BUFFER_TIME: Duration = Duration::from_secs(10);
47// Uniform backoff floor after a failed refresh.
48const BACKOFF_MIN_SECS: u64 = 300;
49// Uniform backoff jitter span (300..=600s total).
50const BACKOFF_JITTER_SECS: u64 = 300;
51// Non-recoverable error cache floor + jitter span (1..=5s total).
52const ERROR_CACHE_MIN_SECS: u64 = 1;
53const ERROR_CACHE_JITTER_SECS: u64 = 4;
54
55const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_millis(3100);
56
57// NOTE: `pessimistic_load_timeout` below is deliberately duplicated from
58// `aws_smithy_runtime::client::identity::cache::lazy::pessimistic_load_timeout` rather than
59// imported, to avoid the one-way `pub` API exposure.
60//
61// Derive a pessimistic load timeout from the configured retry/timeout so the source's own retries
62// can finish before the cache kills the future.
63fn pessimistic_load_timeout(config_bag: &ConfigBag) -> Duration {
64    let retry_config = config_bag
65        .load::<RetryConfig>()
66        .cloned()
67        .unwrap_or_else(RetryConfig::standard);
68    let timeout_config = config_bag
69        .load::<TimeoutConfig>()
70        .cloned()
71        .unwrap_or_else(TimeoutConfig::disabled);
72
73    let attempts = retry_config.max_attempts();
74    let initial_backoff = retry_config.initial_backoff().as_secs_f64();
75    let max_backoff = retry_config.max_backoff().as_secs_f64();
76
77    // Worst-case total backoff: sum of min(initial * 2^i, max_backoff) for each retry.
78    let total_backoff: f64 = (0..attempts.saturating_sub(1))
79        .map(|i| (initial_backoff * 2.0_f64.powi(i as i32)).min(max_backoff))
80        .sum();
81
82    // Per-attempt ceiling: connect_timeout (floored at the default, doubled to approximate a full
83    // attempt) or operation_attempt_timeout when larger.
84    let connect = timeout_config
85        .connect_timeout()
86        .unwrap_or(DEFAULT_CONNECT_TIMEOUT)
87        .max(DEFAULT_CONNECT_TIMEOUT)
88        .as_secs_f64();
89    let attempt_ceiling = timeout_config
90        .operation_attempt_timeout()
91        .map(|d| d.as_secs_f64())
92        .unwrap_or(0.0);
93    let per_attempt = (connect * 2.0).max(attempt_ceiling);
94    let total_attempts = attempts as f64 * per_attempt;
95
96    // Floor: at least one attempt's worth of budget even if max_attempts is 0.
97    let computed = total_backoff + total_attempts;
98    Duration::from_secs_f64(computed.max(per_attempt))
99}
100
101type NonRecoverablePredicate = Arc<dyn Fn(&BoxError) -> bool + Send + Sync>;
102
103/// The default identity cache for AWS clients: retain-always, partition-keyed, static-stability.
104///
105/// See the [module docs](self). Build one with [`StaticStabilityCache::builder`].
106pub struct StaticStabilityCache {
107    partitions: RwLock<HashMap<IdentityCachePartition, Arc<Partition>>>,
108    load_timeout: Option<Duration>,
109    non_recoverable: Option<NonRecoverablePredicate>,
110    // Tests only, `None` in production (jittered 300..=600s backoff).
111    backoff_override: Option<Duration>,
112    // Tests only, `None` in production (advisory window from the lifetime table).
113    advisory_window_override: Option<Duration>,
114}
115
116impl fmt::Debug for StaticStabilityCache {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.debug_struct("StaticStabilityCache")
119            .field("load_timeout", &self.load_timeout)
120            .field(
121                "non_recoverable",
122                &self.non_recoverable.as_ref().map(|_| "<predicate>"),
123            )
124            .finish()
125    }
126}
127
128impl StaticStabilityCache {
129    /// Returns a builder for [`StaticStabilityCache`].
130    pub fn builder() -> StaticStabilityCacheBuilder {
131        StaticStabilityCacheBuilder::default()
132    }
133
134    fn new(load_timeout: Option<Duration>) -> Self {
135        Self {
136            partitions: RwLock::new(HashMap::new()),
137            load_timeout,
138            non_recoverable: Some(Arc::new(aws_non_recoverable)),
139            backoff_override: None,
140            advisory_window_override: None,
141        }
142    }
143
144    // Test-only overrides for the otherwise non-configurable refresh backoff and advisory window.
145    #[cfg(test)]
146    fn with_overrides(
147        mut self,
148        backoff_override: Option<Duration>,
149        advisory_window_override: Option<Duration>,
150    ) -> Self {
151        self.backoff_override = backoff_override;
152        self.advisory_window_override = advisory_window_override;
153        self
154    }
155
156    // Get-or-create the per-source partition.
157    // Read-mostly: the hit path takes only a shared read lock.
158    fn partition(&self, key: IdentityCachePartition) -> Arc<Partition> {
159        if let Some(p) = self.partitions.read().unwrap().get(&key).cloned() {
160            return p;
161        }
162        // Unbounded: a client-level cache sees only its configured resolvers (a small, static set),
163        // and per-operation `config_override` uses its own cache — so partitions don't grow with
164        // request volume. `entry` also collapses the check-then-insert race with another writer.
165        self.partitions
166            .write()
167            .unwrap()
168            .entry(key)
169            .or_insert_with(|| Arc::new(Partition::default()))
170            .clone()
171    }
172
173    fn snapshot_partitions(&self) -> Vec<Arc<Partition>> {
174        self.partitions.read().unwrap().values().cloned().collect()
175    }
176
177    // Refresh a partition from the source, then commit (success) or serve-cached/back-off/raise
178    // (failure). The source `.await` is held under the async `refresh_gate` only (acquired by the
179    // caller); the sync `state` lock is taken only for the brief snapshot/commit sections.
180    async fn refresh(
181        &self,
182        part: &Partition,
183        resolver: &SharedIdentityResolver,
184        runtime_components: &RuntimeComponents,
185        config_bag: &ConfigBag,
186    ) -> Result<Identity, BoxError> {
187        // Non-recoverable error cache: if a terminal failure is still cached, re-raise it without
188        // contacting the source, so a caller looping on the error cannot hammer the source. Checked
189        // here — the caller already holds the refresh gate — before any source call.
190        {
191            let st = part.state.lock().unwrap();
192            if let (Some(err), Some(expires_at)) = (&st.cached_error, st.cached_error_expires_at) {
193                if runtime_components.time_source().expect("validated").now() < expires_at {
194                    return Err(err.clone().into());
195                }
196            }
197        }
198
199        let prev = part.state.lock().unwrap().cached.clone();
200
201        let sleep_impl = runtime_components.sleep_impl().expect("validated");
202        let load_timeout = self
203            .load_timeout
204            .unwrap_or_else(|| pessimistic_load_timeout(config_bag));
205        let timeout_future = sleep_impl.sleep(load_timeout);
206        let resolved: Result<Identity, BoxError> = async move {
207            match Timeout::new(
208                resolver.resolve_identity(runtime_components, config_bag),
209                timeout_future,
210            )
211            .await
212            {
213                Ok(result) => result,
214                // Timeout: converts a *hung* source into a serve-cached decision (recoverable).
215                Err(_elapsed) => {
216                    Err(format!("credential resolution timed out after {:?}", load_timeout).into())
217                }
218            }
219        }
220        .instrument(tracing::debug_span!("load_identity"))
221        .await;
222
223        // The source call may have taken a while (up to the load timeout), so read `now` fresh from
224        // the (validated) runtime time source rather than a pre-await value.
225        let now = runtime_components.time_source().expect("validated").now();
226
227        // An `Ok` response already expired by `now` is treated as a failed refresh (retain + back
228        // off), not cached as fresh.
229        let refreshed = resolved.and_then(|id| {
230            if expired(id.expiration(), now) {
231                Err("credential source returned already-expired credentials".into())
232            } else {
233                Ok(id)
234            }
235        });
236
237        match refreshed {
238            Ok(id) => {
239                let mut st = part.state.lock().unwrap();
240                st.cached = Some(id.clone());
241                match id.expiration() {
242                    Some(expiry) => {
243                        st.expiration = Some(expiry);
244                        if eligible(&id) {
245                            // Static-stability overlay: advisory + mandatory windows.
246                            let advisory_window = self
247                                .advisory_window_override
248                                .unwrap_or_else(|| advisory_window_for(lifetime(expiry, now)));
249                            st.advisory_at = Some(sub(expiry, advisory_window));
250                            st.mandatory_at = Some(sub(expiry, DEFAULT_MANDATORY_WINDOW));
251                        } else {
252                            // Ineligible (custom/process): a single caching-only window at expiry.
253                            let at = sub(expiry, CACHING_ONLY_BUFFER_TIME);
254                            st.advisory_at = Some(at);
255                            st.mandatory_at = Some(at);
256                        }
257                    }
258                    // Non-expiring identity: no expiration-based refresh. Served indefinitely
259                    // (classify returns Valid when `expiration` is None); only invalidation, which
260                    // sets `expiration = now`, forces a refresh.
261                    None => {
262                        st.expiration = None;
263                        st.advisory_at = None;
264                        st.mandatory_at = None;
265                    }
266                }
267                st.next_refresh_allowed_at = None; // clear backoff
268                st.cached_error = None; // a success clears any cached terminal error
269                st.cached_error_expires_at = None;
270                Ok(id)
271            }
272            Err(err) => {
273                // A non-recoverable error raises immediately — before backoff and before
274                // serve-cached. The cache holds only a predicate, naming no error types.
275                if self.non_recoverable.as_ref().is_some_and(|p| p(&err)) {
276                    // Cache the terminal error for a short, jittered window so repeat callers are
277                    // served it without another source call; still raise it to this caller now.
278                    let cached = CachedNonRecoverableError(Arc::from(err));
279                    {
280                        let mut st = part.state.lock().unwrap();
281                        st.cached_error = Some(cached.clone());
282                        st.cached_error_expires_at = Some(now + jittered_error_cache());
283                    }
284                    return Err(cached.into());
285                }
286                let mut st = part.state.lock().unwrap();
287                // Backoff AND serve-stale are BOTH static stability — gated on eligibility.
288                match prev {
289                    Some(c) if eligible(&c) => {
290                        st.next_refresh_allowed_at =
291                            Some(now + self.backoff_override.unwrap_or_else(jittered_backoff));
292                        tracing::warn!(
293                            error = ?err,
294                            "credential refresh failed; serving cached credentials (static stability)"
295                        );
296                        Ok(c)
297                    }
298                    // Ineligible: serve only if still valid, else raise.
299                    Some(c) if !expired(st.expiration, now) => Ok(c),
300                    _ => Err(err),
301                }
302            }
303        }
304    }
305}
306
307impl ResolveCachedIdentity for StaticStabilityCache {
308    fn validate_base_client_config(
309        &self,
310        runtime_components: &RuntimeComponentsBuilder,
311        _cfg: &ConfigBag,
312    ) -> Result<(), BoxError> {
313        validate(
314            runtime_components.time_source().is_some(),
315            runtime_components.sleep_impl().is_some(),
316        )
317    }
318
319    fn validate_final_config(
320        &self,
321        runtime_components: &RuntimeComponents,
322        _cfg: &ConfigBag,
323    ) -> Result<(), BoxError> {
324        validate(
325            runtime_components.time_source().is_some(),
326            runtime_components.sleep_impl().is_some(),
327        )
328    }
329
330    fn resolve_cached_identity<'a>(
331        &'a self,
332        resolver: SharedIdentityResolver,
333        runtime_components: &'a RuntimeComponents,
334        config_bag: &'a ConfigBag,
335    ) -> IdentityFuture<'a> {
336        IdentityFuture::new(async move {
337            let now = runtime_components.time_source().expect("validated").now();
338            let part = self.partition(resolver.cache_partition());
339
340            // 1) snapshot + classify under the SYNC lock — no `.await` held
341            let decision = { classify(&part.state.lock().unwrap(), now) };
342
343            match decision {
344                // Valid or backed-off: serve cached, no source contact.
345                Decision::Valid(id) | Decision::RateLimited(id) => Ok(id),
346                // Advisory: refresh only if we win the gate, else serve cached now.
347                Decision::Advisory(cached) => match part.refresh_gate.try_lock() {
348                    Ok(_permit) => {
349                        self.refresh(&part, &resolver, runtime_components, config_bag)
350                            .await
351                    }
352                    Err(_) => Ok(cached),
353                },
354                // Mandatory (incl. expired) and initial fetch: block on the gate, then
355                // reuse an in-flight result if another task refreshed while we waited.
356                Decision::Mandatory | Decision::Initial => {
357                    let _permit = part.refresh_gate.lock().await;
358                    if let Some(id) = part.recheck(now) {
359                        return Ok(id);
360                    }
361                    // No rate-limiting during initial-fetch.
362                    self.refresh(&part, &resolver, runtime_components, config_bag)
363                        .await
364                }
365            }
366        })
367    }
368
369    fn invalidate(&self, rejected: &Identity) {
370        for part in self.snapshot_partitions() {
371            let mut st = part.state.lock().unwrap();
372            if st.cached.as_ref().is_some_and(|c| c.ptr_eq(rejected)) {
373                // Route the next resolution through the mandatory path. classify() keys off
374                // advisory_at/mandatory_at, so collapse them to the epoch (a definitely-past time)
375                // — no time source needed here. Deliberately leave next_refresh_allowed_at
376                // (backoff) and cached (static stability) untouched.
377                st.expiration = Some(SystemTime::UNIX_EPOCH);
378                st.advisory_at = Some(SystemTime::UNIX_EPOCH);
379                st.mandatory_at = Some(SystemTime::UNIX_EPOCH);
380                break; // an identity is cached in exactly one partition
381            }
382        }
383    }
384}
385
386// One cache slot per credential source (per `IdentityCachePartition`).
387#[derive(Debug, Default)]
388struct Partition {
389    // Guards the synchronous snapshot/commit sections. NEVER held across `.await`.
390    state: Mutex<CachedState>,
391    // Async single-flight gate for the source call. Held across `.await`.
392    refresh_gate: tokio::sync::Mutex<()>,
393}
394
395impl Partition {
396    // Re-classify after acquiring the gate: another task may have refreshed while we waited.
397    fn recheck(&self, now: SystemTime) -> Option<Identity> {
398        let st = self.state.lock().unwrap();
399        match classify(&st, now) {
400            Decision::Valid(id) | Decision::RateLimited(id) => Some(id),
401            _ => None,
402        }
403    }
404}
405
406#[derive(Debug, Default)]
407struct CachedState {
408    // Retain-always; only ever *replaced* on success, never cleared.
409    cached: Option<Identity>,
410    expiration: Option<SystemTime>,
411    // Non-blocking refresh point.
412    advisory_at: Option<SystemTime>,
413    // Blocking refresh point (<= expiration).
414    mandatory_at: Option<SystemTime>,
415    // Backoff gate after a failed refresh.
416    next_refresh_allowed_at: Option<SystemTime>,
417    // Short-lived, jittered cache of a non-recoverable error, re-raised to repeat callers.
418    cached_error: Option<CachedNonRecoverableError>,
419    cached_error_expires_at: Option<SystemTime>,
420}
421
422enum Decision {
423    Valid(Identity),
424    RateLimited(Identity),
425    Advisory(Identity),
426    Mandatory,
427    Initial,
428}
429
430fn classify(st: &CachedState, now: SystemTime) -> Decision {
431    let Some(id) = st.cached.clone() else {
432        return Decision::Initial;
433    };
434    // Non-expiring identity (source reported no expiration): serve indefinitely. Only invalidation
435    // (which sets `expiration = now`) forces a refresh.
436    if st.expiration.is_none() {
437        return Decision::Valid(id);
438    }
439    if matches!(st.advisory_at, Some(a) if now < a) {
440        return Decision::Valid(id);
441    }
442    // Backoff gate sits AFTER Valid, BEFORE the advisory/mandatory split so an
443    // expired-but-backed-off credential is served without contacting the source.
444    if matches!(st.next_refresh_allowed_at, Some(t) if now < t) {
445        return Decision::RateLimited(id);
446    }
447    match st.mandatory_at {
448        Some(m) if now < m => Decision::Advisory(id),
449        _ => Decision::Mandatory, // at/after mandatory_at, includes past-expiry
450    }
451}
452
453fn eligible(id: &Identity) -> bool {
454    id.property::<StaticStabilityEligible>().is_some()
455}
456
457fn expired(expiration: Option<SystemTime>, now: SystemTime) -> bool {
458    matches!(expiration, Some(exp) if exp <= now)
459}
460
461fn sub(t: SystemTime, d: Duration) -> SystemTime {
462    t.checked_sub(d).unwrap_or(t)
463}
464
465fn lifetime(expiry: SystemTime, now: SystemTime) -> Duration {
466    expiry.duration_since(now).unwrap_or_default()
467}
468
469// Advisory window tiers, selected by remaining credential lifetime:
470// `<= 20min -> 5min`, `> 20min && < 90min -> 15min`, `>= 90min -> 60min`.
471fn advisory_window_for(lifetime: Duration) -> Duration {
472    if lifetime <= Duration::from_secs(20 * 60) {
473        Duration::from_secs(5 * 60)
474    } else if lifetime < Duration::from_secs(90 * 60) {
475        Duration::from_secs(15 * 60)
476    } else {
477        Duration::from_secs(60 * 60)
478    }
479}
480
481fn jittered_backoff() -> Duration {
482    Duration::from_secs(BACKOFF_MIN_SECS + fastrand::u64(0..=BACKOFF_JITTER_SECS))
483}
484
485fn jittered_error_cache() -> Duration {
486    Duration::from_secs(ERROR_CACHE_MIN_SECS + fastrand::u64(0..=ERROR_CACHE_JITTER_SECS))
487}
488
489// A `Clone` wrapper so a cached non-recoverable error can be re-raised to multiple callers
490// (`BoxError` is not `Clone`). The underlying error is exposed through `source`, matching how
491// `Arc`-wrapped errors elsewhere in the runtime surface their cause.
492#[derive(Clone, Debug)]
493struct CachedNonRecoverableError(Arc<dyn Error + Send + Sync>);
494
495impl fmt::Display for CachedNonRecoverableError {
496    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
497        fmt::Display::fmt(&self.0, f)
498    }
499}
500
501impl Error for CachedNonRecoverableError {
502    fn source(&self) -> Option<&(dyn Error + 'static)> {
503        Some(self.0.as_ref())
504    }
505}
506
507// Default non-recoverable predicate injected into the cache by the AWS layer: a terminal
508// `CredentialsError::Unrecoverable` anywhere in the source chain bypasses backoff and static
509// stability. Providers such as `ChainProvider` wrap the base-provider error, so walk the chain
510// rather than inspecting only the outermost error.
511fn aws_non_recoverable(err: &BoxError) -> bool {
512    let mut source: Option<&(dyn Error + 'static)> = Some(&**err);
513    while let Some(e) = source {
514        if e.downcast_ref::<CredentialsError>()
515            .is_some_and(CredentialsError::is_unrecoverable)
516        {
517            return true;
518        }
519        source = e.source();
520    }
521    false
522}
523
524fn validate(has_time_source: bool, has_sleep_impl: bool) -> Result<(), BoxError> {
525    if !has_time_source {
526        return Err("StaticStabilityCache requires a time source to be configured".into());
527    }
528    if !has_sleep_impl {
529        return Err(
530            "StaticStabilityCache requires an async sleep implementation to be configured".into(),
531        );
532    }
533    Ok(())
534}
535
536/// Builder for [`StaticStabilityCache`].
537#[derive(Clone, Debug, Default)]
538pub struct StaticStabilityCacheBuilder {
539    load_timeout: Option<Duration>,
540}
541
542impl StaticStabilityCacheBuilder {
543    /// Sets the timeout bounding a single credential-source resolution. When unset, a default
544    /// timeout is derived from the configured retry/timeout.
545    pub fn load_timeout(mut self, load_timeout: Duration) -> Self {
546        self.load_timeout = Some(load_timeout);
547        self
548    }
549
550    /// Builds a [`SharedIdentityCache`] wrapping the configured [`StaticStabilityCache`].
551    pub fn build(self) -> SharedIdentityCache {
552        StaticStabilityCache::new(self.load_timeout).into_shared()
553    }
554}
555
556#[cfg(test)]
557impl StaticStabilityCache {
558    // Advisory window (expiration - advisory_at) of the sole partition, for suite assertions.
559    fn advisory_window(&self) -> Option<Duration> {
560        let parts = self.partitions.read().unwrap();
561        let part = parts.values().next()?;
562        let st = part.state.lock().unwrap();
563        match (st.expiration, st.advisory_at) {
564            (Some(exp), Some(adv)) => exp.duration_since(adv).ok(),
565            _ => None,
566        }
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use aws_smithy_async::test_util::tick_advance_sleep::tick_advance_time_and_sleep;
574    use aws_smithy_async::test_util::ManualTimeSource;
575    use serde::Deserialize;
576    use std::sync::atomic::{AtomicUsize, Ordering};
577
578    // Data-driven execution of the modeled credential-refresh test suite (test-data/). Our
579    // implementation diverges from the suite in two spots, handled inline: invalidation matches by
580    // pointer identity rather than access key id, and the refresh backoff uses a fixed test value
581    // (production jitters it). The configured advisory window is applied via a test-only builder
582    // knob. A `rateLimited` expectation coincides with `sourceContacted == false` for a credential
583    // past its refresh point, so it needs no separate assertion.
584    #[derive(Deserialize)]
585    #[serde(rename_all = "camelCase")]
586    struct Scenario {
587        documentation: String,
588        given: Given,
589        steps: Vec<Step>,
590    }
591
592    #[derive(Deserialize)]
593    #[serde(rename_all = "camelCase")]
594    struct Given {
595        cached_credentials: String,
596        access_key_id: Option<String>,
597        configured_advisory_window_seconds: Option<u64>,
598        refresh_backoff_seconds: Option<u64>,
599    }
600
601    #[derive(Deserialize)]
602    #[serde(tag = "type", rename_all = "camelCase")]
603    enum Step {
604        #[serde(rename_all = "camelCase")]
605        GetCredentials {
606            response: Option<String>,
607            lifetime_seconds: Option<u64>,
608            expected: Expected,
609        },
610        #[serde(rename_all = "camelCase")]
611        Invalidate {
612            rejected_access_key_id: String,
613        },
614        AdvanceTime {
615            seconds: u64,
616        },
617    }
618
619    #[derive(Deserialize)]
620    #[serde(rename_all = "camelCase")]
621    struct Expected {
622        result: String,
623        source_contacted: bool,
624        advisory_window_seconds: Option<u64>,
625    }
626
627    const SUITE: &str = include_str!("../test-data/credential-refresh-tests.json");
628    // Seed lifetime 60min -> advisory_at=2700, mandatory_at=3540, expiry=3600.
629    const SEED_LIFE: u64 = 3600;
630
631    async fn source_get(
632        cache: &StaticStabilityCache,
633        resolver: &SharedIdentityResolver,
634        rc: &RuntimeComponents,
635        cfg: &ConfigBag,
636        contacts: &AtomicUsize,
637    ) -> (Result<Identity, BoxError>, bool) {
638        let before = contacts.load(Ordering::SeqCst);
639        let r = cache
640            .resolve_cached_identity(resolver.clone(), rc, cfg)
641            .await;
642        (r, contacts.load(Ordering::SeqCst) > before)
643    }
644
645    async fn run_scenario(s: &Scenario) {
646        let doc = s.documentation.as_str();
647        let seeded = s.given.cached_credentials != "none";
648        // Place the seeded credential in the requested window (seed lifetime 3600).
649        let start = match s.given.cached_credentials.as_str() {
650            "valid" => 1000,
651            "advisory" => 3000,
652            "mandatory" => 3550,
653            "expired" => 3700,
654            "none" => 0,
655            other => panic!("{doc}: unknown cachedCredentials {other:?}"),
656        };
657
658        // Pre-walk the steps to build the source queue in order and assign fresh ids, computing
659        // each fresh response's expiry against the clock at that step.
660        let mut queue: Vec<Result<Identity, BoxError>> = Vec::new();
661        if seeded {
662            queue.push(Ok(identity(1, SEED_LIFE, true)));
663        }
664        let mut next_id = 1u32;
665        let mut fresh_ids = std::collections::VecDeque::new();
666        let mut clk = start;
667        for step in &s.steps {
668            match step {
669                Step::AdvanceTime { seconds } => clk += seconds,
670                Step::GetCredentials {
671                    response,
672                    lifetime_seconds,
673                    ..
674                } => match response.as_deref() {
675                    Some("freshCredentials") => {
676                        next_id += 1;
677                        fresh_ids.push_back(next_id);
678                        let life = lifetime_seconds.unwrap_or(SEED_LIFE);
679                        queue.push(Ok(identity(next_id, clk + life, true)));
680                    }
681                    Some("staleCredentials") => {
682                        next_id += 1;
683                        queue.push(Ok(identity(next_id, clk, true))); // expiry <= now
684                    }
685                    Some("error") => queue.push(Err("recoverable".into())),
686                    Some("nonRecoverableError") => {
687                        queue.push(Err(CredentialsError::unrecoverable("terminal").into()))
688                    }
689                    Some(other) => panic!("{doc}: unknown response {other:?}"),
690                    None => {}
691                },
692                Step::Invalidate { .. } => {}
693            }
694        }
695
696        // Build an isolated harness: a concrete cache (for internal accessors) with a
697        // fixed backoff.
698        let time = ManualTimeSource::new(epoch(0));
699        let (_tick, sleep) = tick_advance_time_and_sleep();
700        let components = RuntimeComponentsBuilder::for_tests()
701            .with_time_source(Some(time.clone()))
702            .with_sleep_impl(Some(sleep))
703            .build()
704            .unwrap();
705        let contacts = Arc::new(AtomicUsize::new(0));
706        let resolver = SharedIdentityResolver::new(MockSource {
707            results: Mutex::new(queue),
708            contacts: contacts.clone(),
709        });
710        let cache = StaticStabilityCache::new(None).with_overrides(
711            s.given.refresh_backoff_seconds.map(Duration::from_secs),
712            s.given
713                .configured_advisory_window_seconds
714                .map(Duration::from_secs),
715        );
716        let cfg = ConfigBag::base();
717
718        // Establish the given-state: fetch once to cache the seed, then move into the window.
719        let mut cached_id = 1u32;
720        let mut served: Option<Identity> = None;
721        if seeded {
722            let (r, _) = source_get(&cache, &resolver, &components, &cfg, &contacts).await;
723            served = Some(r.expect("seed fetch"));
724            time.set_time(epoch(start));
725        }
726
727        // Execute the steps.
728        let mut clk = start;
729        for step in &s.steps {
730            match step {
731                Step::AdvanceTime { seconds } => {
732                    clk += seconds;
733                    time.set_time(epoch(clk));
734                }
735                Step::Invalidate {
736                    rejected_access_key_id,
737                } => {
738                    // ptr_eq translation: a matching access key id invalidates the served instance;
739                    // a stale id invalidates a different allocation (a no-op).
740                    if s.given.access_key_id.as_deref() == Some(rejected_access_key_id.as_str()) {
741                        cache.invalidate(served.as_ref().expect("a served identity"));
742                    } else {
743                        cache.invalidate(&identity(999, SEED_LIFE, true));
744                    }
745                }
746                Step::GetCredentials { expected, .. } => {
747                    let (r, contacted) =
748                        source_get(&cache, &resolver, &components, &cfg, &contacts).await;
749                    assert_eq!(
750                        contacted, expected.source_contacted,
751                        "{doc}: sourceContacted"
752                    );
753                    match expected.result.as_str() {
754                        "cachedCredentials" => {
755                            let got = r.expect("cachedCredentials");
756                            assert_eq!(id_of(&got), cached_id, "{doc}: cached id");
757                            served = Some(got);
758                        }
759                        "newCredentials" => {
760                            let got = r.expect("newCredentials");
761                            cached_id = fresh_ids.pop_front().expect("a queued fresh id");
762                            assert_eq!(id_of(&got), cached_id, "{doc}: new id");
763                            served = Some(got);
764                        }
765                        "noCredentialsError" => assert!(r.is_err(), "{doc}: noCredentialsError"),
766                        "nonRecoverableError" => {
767                            let Err(e) = r else {
768                                panic!("{doc}: expected nonRecoverableError");
769                            };
770                            // The error may be the cache's `CachedNonRecoverableError` wrapper, so
771                            // check the source chain (the same detection the cache uses).
772                            assert!(aws_non_recoverable(&e), "{doc}: nonRecoverableError");
773                        }
774                        other => panic!("{doc}: unknown result {other:?}"),
775                    }
776                    if let Some(w) = expected.advisory_window_seconds {
777                        assert_eq!(
778                            cache.advisory_window(),
779                            Some(Duration::from_secs(w)),
780                            "{doc}: advisoryWindowSeconds"
781                        );
782                    }
783                }
784            }
785        }
786    }
787
788    #[tokio::test]
789    async fn test_suite() {
790        let scenarios: Vec<Scenario> = serde_json::from_str(SUITE).expect("valid suite json");
791        assert_eq!(scenarios.len(), 24, "expected the full modeled suite");
792        for s in &scenarios {
793            run_scenario(s).await;
794        }
795    }
796
797    #[derive(Debug, Clone, PartialEq)]
798    struct TestCreds {
799        id: u32,
800    }
801
802    fn epoch(secs: u64) -> SystemTime {
803        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
804    }
805
806    // Build an identity with a distinguishing id, an absolute expiration, and (optionally) the
807    // static-stability eligibility marker.
808    fn identity(id: u32, expiry_secs: u64, eligible: bool) -> Identity {
809        let mut b = Identity::builder()
810            .data(TestCreds { id })
811            .expiration(epoch(expiry_secs));
812        if eligible {
813            b = b.property(StaticStabilityEligible);
814        }
815        b.build().unwrap()
816    }
817
818    fn id_of(identity: &Identity) -> u32 {
819        identity.data::<TestCreds>().unwrap().id
820    }
821
822    // Mock credential source: returns queued results in order and counts how many times it is
823    // actually contacted (so tests can assert `sourceContacted`).
824    #[derive(Debug)]
825    struct MockSource {
826        results: Mutex<Vec<Result<Identity, BoxError>>>,
827        contacts: Arc<AtomicUsize>,
828    }
829
830    impl ResolveIdentity for MockSource {
831        fn resolve_identity<'a>(
832            &'a self,
833            _rc: &'a RuntimeComponents,
834            _cfg: &'a ConfigBag,
835        ) -> IdentityFuture<'a> {
836            self.contacts.fetch_add(1, Ordering::SeqCst);
837            let mut list = self.results.lock().unwrap();
838            let next = if list.is_empty() {
839                Err("mock source: no more results".into())
840            } else {
841                list.remove(0)
842            };
843            IdentityFuture::ready(next)
844        }
845    }
846
847    struct Harness {
848        cache: SharedIdentityCache,
849        resolver: SharedIdentityResolver,
850        components: RuntimeComponents,
851        config_bag: ConfigBag,
852        time: ManualTimeSource,
853        contacts: Arc<AtomicUsize>,
854    }
855
856    impl Harness {
857        fn new(results: Vec<Result<Identity, BoxError>>) -> Self {
858            let time = ManualTimeSource::new(epoch(0));
859            // A tick-advance sleep never fires unless explicitly ticked, so the (immediately
860            // resolving) mock source always wins the refresh Timeout race — no spurious timeouts.
861            let (_tick, sleep) = tick_advance_time_and_sleep();
862            let components = RuntimeComponentsBuilder::for_tests()
863                .with_time_source(Some(time.clone()))
864                .with_sleep_impl(Some(sleep))
865                .build()
866                .unwrap();
867            let contacts = Arc::new(AtomicUsize::new(0));
868            let resolver = SharedIdentityResolver::new(MockSource {
869                results: Mutex::new(results),
870                contacts: contacts.clone(),
871            });
872            Self {
873                cache: StaticStabilityCache::builder().build(),
874                resolver,
875                components,
876                config_bag: ConfigBag::base(),
877                time,
878                contacts,
879            }
880        }
881
882        // One `getCredentials` step: returns the result and whether the source was contacted.
883        async fn get(&self) -> (Result<Identity, BoxError>, bool) {
884            let before = self.contacts.load(Ordering::SeqCst);
885            let result = self
886                .cache
887                .resolve_cached_identity(self.resolver.clone(), &self.components, &self.config_bag)
888                .await;
889            let contacted = self.contacts.load(Ordering::SeqCst) > before;
890            (result, contacted)
891        }
892
893        fn advance_to(&self, secs: u64) {
894            self.time.set_time(epoch(secs));
895        }
896    }
897
898    // Window selection (advisoryWindowSeconds) by remaining lifetime:
899    // <=20min -> 5min, >20 && <90 -> 15min, >=90 -> 60min.
900    #[test]
901    fn advisory_window_selection() {
902        let m = |mins: u64| Duration::from_secs(mins * 60);
903        assert_eq!(advisory_window_for(m(10)), m(5));
904        assert_eq!(advisory_window_for(m(20)), m(5)); // boundary, inclusive
905        assert_eq!(advisory_window_for(m(21)), m(15));
906        assert_eq!(advisory_window_for(m(60)), m(15));
907        assert_eq!(advisory_window_for(m(89)), m(15));
908        assert_eq!(advisory_window_for(m(90)), m(60)); // boundary
909        assert_eq!(advisory_window_for(m(6 * 60)), m(60));
910    }
911
912    // The JSON suite's determinism relies on this window staying strictly within (0s, 6s): an
913    // immediate retry (0s elapsed) must still be cached, and a 6s advance must always clear it.
914    #[test]
915    fn jittered_error_cache_bounds() {
916        for _ in 0..1000 {
917            let d = jittered_error_cache();
918            assert!(
919                (Duration::from_secs(1)..=Duration::from_secs(5)).contains(&d),
920                "error-cache window {d:?} out of 1..=5s"
921            );
922        }
923    }
924
925    // A non-recoverable error wrapped by an outer provider error (as `ChainProvider` produces) is
926    // still detected by walking the source chain, not just the outermost error.
927    #[test]
928    fn non_recoverable_walks_source_chain() {
929        let wrapped: BoxError =
930            CredentialsError::provider_error(CredentialsError::unrecoverable("expired SSO token"))
931                .into();
932        assert!(
933            aws_non_recoverable(&wrapped),
934            "a nested Unrecoverable must be detected"
935        );
936
937        let recoverable: BoxError = CredentialsError::provider_error("STS 503").into();
938        assert!(
939            !aws_non_recoverable(&recoverable),
940            "no Unrecoverable anywhere in the chain"
941        );
942    }
943
944    // Ineligible (custom/process) identity: no serve-stale.
945    // expected: a failed refresh after expiry raises rather than serving stale.
946    #[tokio::test]
947    async fn ineligible_error_after_expiry_is_raised() {
948        let h = Harness::new(vec![Ok(identity(1, 3600, false)), Err("transient".into())]);
949        assert_eq!(id_of(&h.get().await.0.unwrap()), 1);
950
951        h.advance_to(3700); // expired
952        let (r, _contacted) = h.get().await;
953        assert!(
954            r.is_err(),
955            "ineligible creds must not be served past expiry on failure"
956        );
957    }
958
959    // A failed refresh applies the real (jittered) backoff: within it the source is not contacted
960    // (rate-limited); once it elapses the refresh is retried and succeeds. This is the only test
961    // exercising `jittered_backoff()` — the JSON suite injects a fixed backoff.
962    #[tokio::test]
963    async fn backoff_rate_limits_then_recovers() {
964        let h = Harness::new(vec![
965            Ok(identity(1, 3600, true)),
966            Err("transient".into()),
967            Ok(identity(2, 7200, true)),
968        ]);
969        assert_eq!(id_of(&h.get().await.0.unwrap()), 1);
970
971        h.advance_to(3700); // expired -> failed refresh -> serve cached + backoff
972        let (r, contacted) = h.get().await;
973        assert_eq!(id_of(&r.unwrap()), 1, "failed refresh serves cached");
974        assert!(contacted, "a refresh was attempted");
975
976        h.advance_to(3800); // < 300s later: strictly inside the backoff -> rate-limited
977        let (r, contacted) = h.get().await;
978        assert_eq!(id_of(&r.unwrap()), 1);
979        assert!(!contacted, "within backoff: source not contacted");
980
981        h.advance_to(3700 + 601); // past the max backoff (600s) -> refresh retried
982        let (r, contacted) = h.get().await;
983        assert_eq!(
984            id_of(&r.unwrap()),
985            2,
986            "refresh retried once backoff elapsed"
987        );
988        assert!(contacted);
989    }
990
991    // A source that reports no expiration is treated as non-expiring: fetched once and served
992    // indefinitely, never refreshed on a timer. Only invalidation forces a refresh.
993    #[tokio::test]
994    async fn no_expiry_served_indefinitely() {
995        let no_expiry = Identity::builder()
996            .data(TestCreds { id: 1 })
997            .property(StaticStabilityEligible)
998            .build()
999            .unwrap();
1000        assert_eq!(no_expiry.expiration(), None);
1001        // Seed a single response: a timer-driven refresh would exhaust it and flip `contacted`.
1002        let h = Harness::new(vec![Ok(no_expiry)]);
1003        assert_eq!(id_of(&h.get().await.0.unwrap()), 1);
1004
1005        // Advance far past any window a synthetic expiry could have produced (the old default was
1006        // 15m). A non-expiring identity is still served without contacting the source.
1007        h.advance_to(100 * 3600); // 100 hours
1008        let (r, contacted) = h.get().await;
1009        assert_eq!(id_of(&r.unwrap()), 1);
1010        assert!(
1011            !contacted,
1012            "a no-expiration identity is non-expiring: served indefinitely, never refreshed on a timer"
1013        );
1014    }
1015
1016    // A non-expiring identity still refreshes when invalidated (auth failure) —
1017    // invalidation is the only refresh trigger for creds without an expiration.
1018    #[tokio::test]
1019    async fn no_expiry_refreshes_on_invalidation() {
1020        let no_expiry = Identity::builder()
1021            .data(TestCreds { id: 1 })
1022            .property(StaticStabilityEligible)
1023            .build()
1024            .unwrap();
1025        let h = Harness::new(vec![Ok(no_expiry), Ok(identity(2, 3600, true))]);
1026
1027        let served = h.get().await.0.unwrap();
1028        assert_eq!(id_of(&served), 1);
1029
1030        // Without invalidation it would never refresh; invalidating the served identity forces it.
1031        h.cache.invalidate(&served);
1032
1033        let (r, contacted) = h.get().await;
1034        assert_eq!(
1035            id_of(&r.unwrap()),
1036            2,
1037            "invalidation forces a refresh even for a no-expiration identity"
1038        );
1039        assert!(contacted);
1040    }
1041
1042    // Two partitions, rejected identity in a non-sole partition: `invalidate` must find the
1043    // matching slot by identity (ptr_eq) and collapse only it, leaving the other untouched.
1044    // Partition iteration order is unspecified (HashMap), so this covers the walk-past-a-
1045    // non-match path regardless of order.
1046    #[tokio::test]
1047    async fn invalidate_targets_only_the_matching_partition() {
1048        let time = ManualTimeSource::new(epoch(0));
1049        let (_tick, sleep) = tick_advance_time_and_sleep();
1050        let components = RuntimeComponentsBuilder::for_tests()
1051            .with_time_source(Some(time.clone()))
1052            .with_sleep_impl(Some(sleep))
1053            .build()
1054            .unwrap();
1055        let cfg = ConfigBag::base();
1056        let cache = StaticStabilityCache::new(None);
1057
1058        // Two sources -> two distinct cache partitions.
1059        let contacts_a = Arc::new(AtomicUsize::new(0));
1060        let resolver_a = SharedIdentityResolver::new(MockSource {
1061            results: Mutex::new(vec![Ok(identity(1, 3600, true))]),
1062            contacts: contacts_a.clone(),
1063        });
1064        let contacts_b = Arc::new(AtomicUsize::new(0));
1065        let resolver_b = SharedIdentityResolver::new(MockSource {
1066            results: Mutex::new(vec![
1067                Ok(identity(2, 3600, true)),
1068                Ok(identity(3, 3600, true)),
1069            ]),
1070            contacts: contacts_b.clone(),
1071        });
1072
1073        // Seed both partitions.
1074        let (a1, _) = source_get(&cache, &resolver_a, &components, &cfg, &contacts_a).await;
1075        assert_eq!(id_of(&a1.unwrap()), 1);
1076        let (b1, _) = source_get(&cache, &resolver_b, &components, &cfg, &contacts_b).await;
1077        let served_b = b1.unwrap();
1078        assert_eq!(id_of(&served_b), 2);
1079
1080        // Reject the identity living in the second source's partition.
1081        cache.invalidate(&served_b);
1082
1083        // That partition refreshes on the next resolve (mandatory path; source contacted).
1084        let (b2, contacted_b) =
1085            source_get(&cache, &resolver_b, &components, &cfg, &contacts_b).await;
1086        assert_eq!(id_of(&b2.unwrap()), 3, "rejected partition must refresh");
1087        assert!(contacted_b, "invalidated partition must contact the source");
1088
1089        // The other partition is untouched: still served from cache, no source contact.
1090        let (a2, contacted_a) =
1091            source_get(&cache, &resolver_a, &components, &cfg, &contacts_a).await;
1092        assert_eq!(
1093            id_of(&a2.unwrap()),
1094            1,
1095            "non-matching partition must be unaffected"
1096        );
1097        assert!(
1098            !contacted_a,
1099            "invalidate must not touch a non-matching partition"
1100        );
1101    }
1102
1103    // A source whose resolution blocks on a gate until released — for concurrency tests.
1104    #[derive(Debug)]
1105    struct GatedSource {
1106        gate: Arc<tokio::sync::Notify>,
1107        contacts: Arc<AtomicUsize>,
1108        results: Mutex<Vec<Result<Identity, BoxError>>>,
1109    }
1110
1111    impl ResolveIdentity for GatedSource {
1112        fn resolve_identity<'a>(
1113            &'a self,
1114            _rc: &'a RuntimeComponents,
1115            _cfg: &'a ConfigBag,
1116        ) -> IdentityFuture<'a> {
1117            let next = self.results.lock().unwrap().remove(0);
1118            let (gate, contacts) = (self.gate.clone(), self.contacts.clone());
1119            IdentityFuture::new(async move {
1120                contacts.fetch_add(1, Ordering::SeqCst);
1121                gate.notified().await; // block until released
1122                next
1123            })
1124        }
1125    }
1126
1127    // Within the advisory window, only ONE caller refreshes; the others return cached
1128    // immediately without waiting or contacting the source (non-blocking single-flight).
1129    #[tokio::test]
1130    async fn advisory_concurrent_single_flight() {
1131        let time = ManualTimeSource::new(epoch(0));
1132        // Tick-advance sleep: never ticked here, so the refresh Timeout never fires and a
1133        // gate-blocked source stays blocked until the driver releases it (deterministic, no
1134        // real time).
1135        let (_tick, sleep) = tick_advance_time_and_sleep();
1136        let components = RuntimeComponentsBuilder::for_tests()
1137            .with_time_source(Some(time.clone()))
1138            .with_sleep_impl(Some(sleep))
1139            .build()
1140            .unwrap();
1141        let cfg = ConfigBag::base();
1142        let gate = Arc::new(tokio::sync::Notify::new());
1143        let contacts = Arc::new(AtomicUsize::new(0));
1144        let resolver = SharedIdentityResolver::new(GatedSource {
1145            gate: gate.clone(),
1146            contacts: contacts.clone(),
1147            results: Mutex::new(vec![
1148                Ok(identity(1, 3600, true)),
1149                Ok(identity(2, 7200, true)),
1150            ]),
1151        });
1152        let cache = StaticStabilityCache::builder().build();
1153
1154        // Seed the initial fetch: permit exactly one source resolution.
1155        gate.notify_one();
1156        let seed = cache
1157            .resolve_cached_identity(resolver.clone(), &components, &cfg)
1158            .await
1159            .unwrap();
1160        assert_eq!(id_of(&seed), 1);
1161        assert_eq!(contacts.load(Ordering::SeqCst), 1);
1162
1163        time.set_time(epoch(3000)); // advisory window (2700 <= now < 3540)
1164
1165        // Two concurrent advisory callers + a driver that releases the single in-flight refresh.
1166        let get1 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1167        let get2 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1168        let driver = async {
1169            tokio::task::yield_now().await;
1170            gate.notify_one();
1171        };
1172        let (r1, r2, ()) = tokio::join!(get1, get2, driver);
1173
1174        let mut ids = [id_of(&r1.unwrap()), id_of(&r2.unwrap())];
1175        ids.sort();
1176        assert_eq!(
1177            ids,
1178            [1, 2],
1179            "one caller refreshed (id=2), the other served cached (id=1)"
1180        );
1181        assert_eq!(
1182            contacts.load(Ordering::SeqCst),
1183            2,
1184            "single-flight: only one refresh contacted the source"
1185        );
1186    }
1187
1188    // Concurrency test 2: within the mandatory window / expired, one caller refreshes and all
1189    // others WAIT for it and reuse the result (no additional source contacts).
1190    #[tokio::test]
1191    async fn mandatory_concurrent_single_flight_all_reuse() {
1192        let time = ManualTimeSource::new(epoch(0));
1193        let (_tick, sleep) = tick_advance_time_and_sleep();
1194        let components = RuntimeComponentsBuilder::for_tests()
1195            .with_time_source(Some(time.clone()))
1196            .with_sleep_impl(Some(sleep))
1197            .build()
1198            .unwrap();
1199        let cfg = ConfigBag::base();
1200        let gate = Arc::new(tokio::sync::Notify::new());
1201        let contacts = Arc::new(AtomicUsize::new(0));
1202        let resolver = SharedIdentityResolver::new(GatedSource {
1203            gate: gate.clone(),
1204            contacts: contacts.clone(),
1205            results: Mutex::new(vec![
1206                Ok(identity(1, 3600, true)),
1207                Ok(identity(2, 10_000, true)),
1208            ]),
1209        });
1210        let cache = StaticStabilityCache::builder().build();
1211
1212        gate.notify_one();
1213        let seed = cache
1214            .resolve_cached_identity(resolver.clone(), &components, &cfg)
1215            .await
1216            .unwrap();
1217        assert_eq!(id_of(&seed), 1);
1218        assert_eq!(contacts.load(Ordering::SeqCst), 1);
1219
1220        time.set_time(epoch(3700)); // expired -> mandatory path (blocking lock + recheck)
1221
1222        // Three concurrent callers + a driver that releases the single in-flight refresh.
1223        let get1 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1224        let get2 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1225        let get3 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1226        let driver = async {
1227            tokio::task::yield_now().await;
1228            gate.notify_one();
1229        };
1230        let (r1, r2, r3, ()) = tokio::join!(get1, get2, get3, driver);
1231
1232        // Every caller receives the one refreshed identity; only one refresh contacted the source.
1233        assert_eq!(id_of(&r1.unwrap()), 2);
1234        assert_eq!(id_of(&r2.unwrap()), 2);
1235        assert_eq!(id_of(&r3.unwrap()), 2);
1236        assert_eq!(
1237            contacts.load(Ordering::SeqCst),
1238            2,
1239            "one refresh shared by all waiters"
1240        );
1241    }
1242
1243    // Concurrency + non-recoverable: the one caller that refreshes fails non-recoverably and
1244    // caches the error; the waiters reuse the cached error instead of each re-contacting the
1245    // source.
1246    #[tokio::test]
1247    async fn mandatory_concurrent_non_recoverable_reuses_cached_error() {
1248        let time = ManualTimeSource::new(epoch(0));
1249        let (_tick, sleep) = tick_advance_time_and_sleep();
1250        let components = RuntimeComponentsBuilder::for_tests()
1251            .with_time_source(Some(time.clone()))
1252            .with_sleep_impl(Some(sleep))
1253            .build()
1254            .unwrap();
1255        let cfg = ConfigBag::base();
1256        let gate = Arc::new(tokio::sync::Notify::new());
1257        let contacts = Arc::new(AtomicUsize::new(0));
1258        let resolver = SharedIdentityResolver::new(GatedSource {
1259            gate: gate.clone(),
1260            contacts: contacts.clone(),
1261            results: Mutex::new(vec![
1262                Ok(identity(1, 3600, true)),
1263                Err(CredentialsError::unrecoverable("terminal").into()),
1264            ]),
1265        });
1266        let cache = StaticStabilityCache::builder().build();
1267
1268        gate.notify_one();
1269        let seed = cache
1270            .resolve_cached_identity(resolver.clone(), &components, &cfg)
1271            .await
1272            .unwrap();
1273        assert_eq!(id_of(&seed), 1);
1274        assert_eq!(contacts.load(Ordering::SeqCst), 1);
1275
1276        time.set_time(epoch(3700)); // expired -> mandatory path
1277
1278        // Three concurrent callers + a driver that releases the single in-flight refresh.
1279        let get1 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1280        let get2 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1281        let get3 = cache.resolve_cached_identity(resolver.clone(), &components, &cfg);
1282        let driver = async {
1283            tokio::task::yield_now().await;
1284            gate.notify_one();
1285        };
1286        let (r1, r2, r3, ()) = tokio::join!(get1, get2, get3, driver);
1287
1288        // Every caller receives the non-recoverable error; only one contacted the source.
1289        for r in [r1, r2, r3] {
1290            let Err(e) = r else {
1291                panic!("expected a non-recoverable error");
1292            };
1293            assert!(
1294                aws_non_recoverable(&e),
1295                "each waiter gets the non-recoverable error"
1296            );
1297        }
1298        assert_eq!(
1299            contacts.load(Ordering::SeqCst),
1300            2,
1301            "one refresh failed for all callers; the cache suppressed re-contacts"
1302        );
1303    }
1304}