Skip to main content

aws_smithy_runtime/client/identity/cache/
lazy.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::expiring_cache::ExpiringCache;
7use aws_smithy_async::future::timeout::Timeout;
8use aws_smithy_async::rt::sleep::{AsyncSleep, SharedAsyncSleep};
9use aws_smithy_async::time::{SharedTimeSource, TimeSource};
10use aws_smithy_runtime_api::box_error::BoxError;
11use aws_smithy_runtime_api::client::identity::{
12    Identity, IdentityCachePartition, IdentityFuture, ResolveCachedIdentity, ResolveIdentity,
13    SharedIdentityCache, SharedIdentityResolver,
14};
15use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
16use aws_smithy_runtime_api::shared::IntoShared;
17use aws_smithy_types::config_bag::ConfigBag;
18use aws_smithy_types::retry::RetryConfig;
19use aws_smithy_types::timeout::TimeoutConfig;
20use aws_smithy_types::DateTime;
21use std::collections::HashMap;
22use std::fmt;
23use std::sync::RwLock;
24use std::time::Duration;
25use tracing::Instrument;
26
27const DEFAULT_EXPIRATION: Duration = Duration::from_secs(15 * 60);
28const DEFAULT_BUFFER_TIME: Duration = Duration::from_secs(10);
29const DEFAULT_BUFFER_TIME_JITTER_FRACTION: fn() -> f64 = || fastrand::f64() * 0.5;
30const DEFAULT_MAX_PARTITIONS: usize = 64;
31
32/// Computes a worst-case load timeout that ensures the inner provider's retry strategy
33/// has enough time to exhaust all configured attempts before the cache kills the future.
34///
35/// This is intentionally pessimistic: it assumes every attempt runs to its per-attempt ceiling
36/// and every backoff delay is the maximum for that iteration. The resulting value is a safety
37/// net — in practice, credential resolution completes well before this deadline.
38///
39/// Formula: sum of worst-case backoffs + `attempts × per_attempt`, where the per-attempt ceiling
40/// is `max(connect_timeout × 2, operation_attempt_timeout)` and `connect_timeout` is floored at
41/// the default connect timeout.
42fn pessimistic_load_timeout(config_bag: &ConfigBag) -> Duration {
43    let retry_config = config_bag
44        .load::<RetryConfig>()
45        .cloned()
46        .unwrap_or_else(RetryConfig::standard);
47    let timeout_config = config_bag
48        .load::<TimeoutConfig>()
49        .cloned()
50        .unwrap_or_else(TimeoutConfig::disabled);
51
52    let attempts = retry_config.max_attempts();
53    let initial_backoff = retry_config.initial_backoff().as_secs_f64();
54    let max_backoff = retry_config.max_backoff().as_secs_f64();
55
56    // Worst-case total backoff: sum of min(initial * 2^i, max_backoff) for each retry
57    let total_backoff: f64 = (0..attempts.saturating_sub(1))
58        .map(|i| (initial_backoff * 2.0_f64.powi(i as i32)).min(max_backoff))
59        .sum();
60
61    // Per-attempt ceiling: the worst-case duration of a single credential-fetch attempt.
62    //
63    // connect_timeout only bounds establishing a connection, so we double it to approximate a
64    // full attempt (connection establishment + TLS completion + request send + server processing
65    // + response read). The connect base is floored at DEFAULT_CONNECT_TIMEOUT so an aggressively
66    // low connect_timeout can't shrink the safety net below the default-derived value.
67    //
68    // operation_attempt_timeout, when configured, is already a full-attempt ceiling enforced on
69    // the inner client, so it is used as-is (NOT doubled). We take whichever is larger so the
70    // cache never fires before the inner client's own per-attempt timeout could.
71    //
72    // Note: read_timeout and operation_timeout are intentionally NOT used here. read_timeout will
73    // gain a large default (a blackhole-detection safety net for outer service calls) that would
74    // inflate this timeout far beyond what credential endpoints need. operation_timeout is a
75    // total, outer-scoped budget that can only cap the inner op, so ignoring it keeps this a safe
76    // upper bound.
77    let connect = timeout_config
78        .connect_timeout()
79        .unwrap_or(crate::client::defaults::DEFAULT_CONNECT_TIMEOUT)
80        .max(crate::client::defaults::DEFAULT_CONNECT_TIMEOUT)
81        .as_secs_f64();
82    let attempt_ceiling = timeout_config
83        .operation_attempt_timeout()
84        .map(|d| d.as_secs_f64())
85        .unwrap_or(0.0);
86    let per_attempt = (connect * 2.0).max(attempt_ceiling);
87    let total_attempts = attempts as f64 * per_attempt;
88
89    // Floor: ensure at least one attempt's worth of budget even if max_attempts is 0.
90    let computed = total_backoff + total_attempts;
91    Duration::from_secs_f64(computed.max(per_attempt))
92}
93
94/// Builder for lazy identity caching.
95#[derive(Default, Debug)]
96pub struct LazyCacheBuilder {
97    time_source: Option<SharedTimeSource>,
98    sleep_impl: Option<SharedAsyncSleep>,
99    load_timeout: Option<Duration>,
100    buffer_time: Option<Duration>,
101    buffer_time_jitter_fraction: Option<fn() -> f64>,
102    default_expiration: Option<Duration>,
103    max_partitions: Option<usize>,
104}
105
106impl LazyCacheBuilder {
107    /// Create a new builder.
108    pub fn new() -> Self {
109        Default::default()
110    }
111
112    /// Set the time source for this cache.
113    pub fn time_source(mut self, time_source: impl TimeSource + 'static) -> Self {
114        self.set_time_source(time_source.into_shared());
115        self
116    }
117    /// Set the time source for this cache.
118    pub fn set_time_source(&mut self, time_source: SharedTimeSource) -> &mut Self {
119        self.time_source = Some(time_source.into_shared());
120        self
121    }
122
123    /// Set the async sleep implementation for this cache.
124    pub fn sleep_impl(mut self, sleep_impl: impl AsyncSleep + 'static) -> Self {
125        self.set_sleep_impl(sleep_impl.into_shared());
126        self
127    }
128    /// Set the async sleep implementation for this cache.
129    pub fn set_sleep_impl(&mut self, sleep_impl: SharedAsyncSleep) -> &mut Self {
130        self.sleep_impl = Some(sleep_impl);
131        self
132    }
133
134    /// Timeout for identity resolution.
135    ///
136    /// When not set, the timeout is derived from the configured `RetryConfig` and
137    /// `TimeoutConfig` to ensure the inner provider's retry strategy has enough time
138    /// to complete. Setting this explicitly overrides that computation.
139    ///
140    /// With default settings (3 attempts, 3.1s connect timeout), the derived timeout
141    /// is approximately 22 seconds.
142    pub fn load_timeout(mut self, timeout: Duration) -> Self {
143        self.set_load_timeout(Some(timeout));
144        self
145    }
146
147    /// Timeout for identity resolution.
148    ///
149    /// When not set, the timeout is derived from the configured `RetryConfig` and
150    /// `TimeoutConfig` to ensure the inner provider's retry strategy has enough time
151    /// to complete. Setting this explicitly overrides that computation.
152    ///
153    /// With default settings (3 attempts, 3.1s connect timeout), the derived timeout
154    /// is approximately 22 seconds.
155    pub fn set_load_timeout(&mut self, timeout: Option<Duration>) -> &mut Self {
156        self.load_timeout = timeout;
157        self
158    }
159
160    /// Amount of time before the actual identity expiration time where the identity is considered expired.
161    ///
162    /// For example, if the identity are expiring in 15 minutes, and the buffer time is 10 seconds,
163    /// then any requests made after 14 minutes and 50 seconds will load a new identity.
164    ///
165    /// Note: random jitter value between [0.0, 0.5] is multiplied to this buffer time.
166    ///
167    /// Defaults to 10 seconds.
168    pub fn buffer_time(mut self, buffer_time: Duration) -> Self {
169        self.set_buffer_time(Some(buffer_time));
170        self
171    }
172
173    /// Amount of time before the actual identity expiration time where the identity is considered expired.
174    ///
175    /// For example, if the identity are expiring in 15 minutes, and the buffer time is 10 seconds,
176    /// then any requests made after 14 minutes and 50 seconds will load a new identity.
177    ///
178    /// Note: random jitter value between [0.0, 0.5] is multiplied to this buffer time.
179    ///
180    /// Defaults to 10 seconds.
181    pub fn set_buffer_time(&mut self, buffer_time: Option<Duration>) -> &mut Self {
182        self.buffer_time = buffer_time;
183        self
184    }
185
186    /// A random percentage by which buffer time is jittered for randomization.
187    ///
188    /// For example, if the identity is expiring in 15 minutes, the buffer time is 10 seconds,
189    /// and buffer time jitter fraction is 0.2, then buffer time is adjusted to 8 seconds.
190    /// Therefore, any requests made after 14 minutes and 52 seconds will load a new identity.
191    ///
192    /// Defaults to a randomly generated value between 0.0 and 0.5. This setter is for testing only.
193    #[allow(unused)]
194    #[cfg(test)]
195    fn buffer_time_jitter_fraction(mut self, buffer_time_jitter_fraction: fn() -> f64) -> Self {
196        self.set_buffer_time_jitter_fraction(Some(buffer_time_jitter_fraction));
197        self
198    }
199
200    /// A random percentage by which buffer time is jittered for randomization.
201    ///
202    /// For example, if the identity is expiring in 15 minutes, the buffer time is 10 seconds,
203    /// and buffer time jitter fraction is 0.2, then buffer time is adjusted to 8 seconds.
204    /// Therefore, any requests made after 14 minutes and 52 seconds will load a new identity.
205    ///
206    /// Defaults to a randomly generated value between 0.0 and 0.5. This setter is for testing only.
207    #[allow(unused)]
208    #[cfg(test)]
209    fn set_buffer_time_jitter_fraction(
210        &mut self,
211        buffer_time_jitter_fraction: Option<fn() -> f64>,
212    ) -> &mut Self {
213        self.buffer_time_jitter_fraction = buffer_time_jitter_fraction;
214        self
215    }
216
217    /// Default expiration time to set on an identity if it doesn't have an expiration time.
218    ///
219    /// This is only used if the resolved identity doesn't have an expiration time set.
220    /// This must be at least 15 minutes.
221    ///
222    /// Defaults to 15 minutes.
223    pub fn default_expiration(mut self, duration: Duration) -> Self {
224        self.set_default_expiration(Some(duration));
225        self
226    }
227
228    /// Default expiration time to set on an identity if it doesn't have an expiration time.
229    ///
230    /// This is only used if the resolved identity doesn't have an expiration time set.
231    /// This must be at least 15 minutes.
232    ///
233    /// Defaults to 15 minutes.
234    pub fn set_default_expiration(&mut self, duration: Option<Duration>) -> &mut Self {
235        self.default_expiration = duration;
236        self
237    }
238
239    /// Maximum number of identity cache partitions before eviction occurs.
240    ///
241    /// A normally functioning application should not have more than 5-10
242    /// credential providers active at any given time. This limit acts as
243    /// a safety net against memory leaks.
244    ///
245    /// Defaults to 64.
246    ///
247    /// # Panics
248    ///
249    /// Panics if `max` is 0.
250    pub fn max_partitions(mut self, max: usize) -> Self {
251        self.set_max_partitions(Some(max));
252        self
253    }
254
255    /// Maximum number of identity cache partitions before eviction occurs.
256    ///
257    /// A normally functioning application should not have more than 5-10
258    /// credential providers active at any given time. This limit acts as
259    /// a safety net against memory leaks.
260    ///
261    /// Defaults to 64.
262    ///
263    /// # Panics
264    ///
265    /// Panics if `max` is `Some(0)`.
266    pub fn set_max_partitions(&mut self, max: Option<usize>) -> &mut Self {
267        if let Some(0) = max {
268            panic!("max_partitions must be greater than 0");
269        }
270        self.max_partitions = max;
271        self
272    }
273
274    /// Builds a [`SharedIdentityCache`] from this builder.
275    ///
276    /// # Panics
277    ///
278    /// This builder will panic if required fields are not given, or if given values are not valid.
279    pub fn build(self) -> SharedIdentityCache {
280        let default_expiration = self.default_expiration.unwrap_or(DEFAULT_EXPIRATION);
281        assert!(
282            default_expiration >= DEFAULT_EXPIRATION,
283            "default_expiration must be at least 15 minutes"
284        );
285        LazyCache::new(
286            self.load_timeout,
287            self.buffer_time.unwrap_or(DEFAULT_BUFFER_TIME),
288            self.buffer_time_jitter_fraction
289                .unwrap_or(DEFAULT_BUFFER_TIME_JITTER_FRACTION),
290            default_expiration,
291            self.max_partitions.unwrap_or(DEFAULT_MAX_PARTITIONS),
292        )
293        .into_shared()
294    }
295}
296
297#[derive(Debug)]
298struct CachePartitions {
299    partitions: RwLock<HashMap<IdentityCachePartition, ExpiringCache<Identity, BoxError>>>,
300    buffer_time: Duration,
301    max_partitions: usize,
302}
303
304impl CachePartitions {
305    fn new(buffer_time: Duration, max_partitions: usize) -> Self {
306        Self {
307            partitions: RwLock::new(HashMap::new()),
308            buffer_time,
309            max_partitions,
310        }
311    }
312
313    fn partition(&self, key: IdentityCachePartition) -> ExpiringCache<Identity, BoxError> {
314        // Fast path: read lock for cache hits
315        if let Some(partition) = self.partitions.read().unwrap().get(&key).cloned() {
316            return partition;
317        }
318        // Slow path: write lock for cache misses
319        let mut partitions = self.partitions.write().unwrap();
320        // Another thread may have inserted while we waited for the write lock
321        if let Some(partition) = partitions.get(&key).cloned() {
322            return partition;
323        }
324        // Evict an arbitrary entry if at capacity. Eviction order doesn't matter
325        // because a normally functioning application should not have more than
326        // 5-10 credential providers active at any given time, well under the cap.
327        if partitions.len() >= self.max_partitions {
328            if let Some(&evict_key) = partitions.keys().next() {
329                partitions.remove(&evict_key);
330            }
331        }
332        let partition = ExpiringCache::new(self.buffer_time);
333        partitions.insert(key, partition.clone());
334        tracing::debug!(
335            partition_count = partitions.len(),
336            "identity cache partition created"
337        );
338        partition
339    }
340}
341
342#[derive(Debug)]
343struct LazyCache {
344    partitions: CachePartitions,
345    /// Explicit load timeout override. If `None`, derived from `RetryConfig` + `TimeoutConfig`
346    /// in the `ConfigBag` at resolution time.
347    load_timeout: Option<Duration>,
348    buffer_time: Duration,
349    buffer_time_jitter_fraction: fn() -> f64,
350    default_expiration: Duration,
351}
352
353impl LazyCache {
354    fn new(
355        load_timeout: Option<Duration>,
356        buffer_time: Duration,
357        buffer_time_jitter_fraction: fn() -> f64,
358        default_expiration: Duration,
359        max_partitions: usize,
360    ) -> Self {
361        Self {
362            partitions: CachePartitions::new(buffer_time, max_partitions),
363            load_timeout,
364            buffer_time,
365            buffer_time_jitter_fraction,
366            default_expiration,
367        }
368    }
369}
370
371macro_rules! required_err {
372    ($thing:literal, $how:literal) => {
373        BoxError::from(concat!(
374            "Lazy identity caching requires ",
375            $thing,
376            " to be configured. ",
377            $how,
378            " If this isn't possible, then disable identity caching by calling ",
379            "the `identity_cache` method on config with `IdentityCache::no_cache()`",
380        ))
381    };
382}
383macro_rules! validate_components {
384    ($components:ident) => {
385        let _ = $components.time_source().ok_or_else(|| {
386            required_err!(
387                "a time source",
388                "Set a time source using the `time_source` method on config."
389            )
390        })?;
391        let _ = $components.sleep_impl().ok_or_else(|| {
392            required_err!(
393                "an async sleep implementation",
394                "Set a sleep impl using the `sleep_impl` method on config."
395            )
396        })?;
397    };
398}
399
400impl ResolveCachedIdentity for LazyCache {
401    fn validate_base_client_config(
402        &self,
403        runtime_components: &aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder,
404        _cfg: &ConfigBag,
405    ) -> Result<(), BoxError> {
406        validate_components!(runtime_components);
407        Ok(())
408    }
409
410    fn validate_final_config(
411        &self,
412        runtime_components: &RuntimeComponents,
413        _cfg: &ConfigBag,
414    ) -> Result<(), BoxError> {
415        validate_components!(runtime_components);
416        Ok(())
417    }
418
419    fn resolve_cached_identity<'a>(
420        &'a self,
421        resolver: SharedIdentityResolver,
422        runtime_components: &'a RuntimeComponents,
423        config_bag: &'a ConfigBag,
424    ) -> IdentityFuture<'a> {
425        let (time_source, sleep_impl) = (
426            runtime_components.time_source().expect("validated"),
427            runtime_components.sleep_impl().expect("validated"),
428        );
429
430        let now = time_source.now();
431        let load_timeout = self
432            .load_timeout
433            .unwrap_or_else(|| pessimistic_load_timeout(config_bag));
434        tracing::debug!(
435            load_timeout=?load_timeout,
436            explicitly_configured=self.load_timeout.is_some(),
437            "identity cache load timeout"
438        );
439        let timeout_future = sleep_impl.sleep(load_timeout);
440        let partition = resolver.cache_partition();
441        let cache = self.partitions.partition(partition);
442        let default_expiration = self.default_expiration;
443
444        IdentityFuture::new(async move {
445            // Attempt to get cached identity, or clear the cache if they're expired
446            if let Some(identity) = cache.yield_or_clear_if_expired(now).await {
447                tracing::debug!(
448                    buffer_time=?self.buffer_time,
449                    cached_expiration=?identity.expiration(),
450                    now=?now,
451                    "loaded identity from cache"
452                );
453                Ok(identity)
454            } else {
455                // If we didn't get identity from the cache, then we need to try and load.
456                // There may be other threads also loading simultaneously, but this is OK
457                // since the futures are not eagerly executed, and the cache will only run one
458                // of them.
459                let start_time = time_source.now();
460                let result = cache
461                    .get_or_load(|| {
462                        let span = tracing::debug_span!("lazy_load_identity");
463                        async move {
464                            let fut = Timeout::new(
465                                resolver.resolve_identity(runtime_components, config_bag),
466                                timeout_future,
467                            );
468                            let identity = match fut.await {
469                                Ok(result) => result?,
470                                Err(_err) => match resolver.fallback_on_interrupt() {
471                                    Some(identity) => identity,
472                                    None => {
473                                        return Err(BoxError::from(TimedOutError(load_timeout)))
474                                    }
475                                },
476                            };
477                            // If the identity don't have an expiration time, then create a default one
478                            let expiration =
479                                identity.expiration().unwrap_or(now + default_expiration);
480
481                            let jitter = self
482                                .buffer_time
483                                .mul_f64((self.buffer_time_jitter_fraction)());
484
485                            // Logging for cache miss should be emitted here as opposed to after the call to
486                            // `cache.get_or_load` above. In the case of multiple threads concurrently executing
487                            // `cache.get_or_load`, logging inside `cache.get_or_load` ensures that it is emitted
488                            // only once for the first thread that succeeds in populating a cache value.
489                            let printable = DateTime::from(expiration);
490                            tracing::debug!(
491                                new_expiration=%printable,
492                                valid_for=?expiration.duration_since(time_source.now()).unwrap_or_default(),
493                                partition=?partition,
494                                "identity cache miss occurred; added new identity (took {:?})",
495                                time_source.now().duration_since(start_time).unwrap_or_default()
496                            );
497
498                            Ok((identity, expiration + jitter))
499                        }
500                        // Only instrument the the actual load future so that no span
501                        // is opened if the cache decides not to execute it.
502                        .instrument(span)
503                    })
504                    .await;
505                tracing::debug!("loaded identity");
506                result
507            }
508        })
509    }
510}
511
512#[derive(Debug)]
513struct TimedOutError(Duration);
514
515impl std::error::Error for TimedOutError {}
516
517impl fmt::Display for TimedOutError {
518    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
519        write!(f, "identity resolver timed out after {:?}", self.0)
520    }
521}
522
523#[cfg(all(test, feature = "client", feature = "http-auth"))]
524mod tests {
525    use super::*;
526    use aws_smithy_async::rt::sleep::TokioSleep;
527    use aws_smithy_async::test_util::{instant_time_and_sleep, ManualTimeSource};
528    use aws_smithy_async::time::TimeSource;
529    use aws_smithy_runtime_api::client::identity::http::Token;
530    use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder;
531    use std::sync::atomic::{AtomicUsize, Ordering};
532    use std::sync::{Arc, Mutex};
533    use std::time::{Duration, SystemTime, UNIX_EPOCH};
534    use tracing::info;
535
536    const LOAD_TIMEOUT_FOR_TESTS: Duration = Duration::from_secs(5);
537
538    const BUFFER_TIME_NO_JITTER: fn() -> f64 = || 0_f64;
539
540    struct ResolverFn<F>(F);
541    impl<F> fmt::Debug for ResolverFn<F> {
542        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543            f.write_str("ResolverFn")
544        }
545    }
546    impl<F> ResolveIdentity for ResolverFn<F>
547    where
548        F: Fn() -> IdentityFuture<'static> + Send + Sync,
549    {
550        fn resolve_identity<'a>(
551            &'a self,
552            _: &'a RuntimeComponents,
553            _config_bag: &'a ConfigBag,
554        ) -> IdentityFuture<'a> {
555            (self.0)()
556        }
557    }
558
559    fn resolver_fn<F>(f: F) -> SharedIdentityResolver
560    where
561        F: Fn() -> IdentityFuture<'static> + Send + Sync + 'static,
562    {
563        SharedIdentityResolver::new(ResolverFn(f))
564    }
565
566    fn test_cache(
567        buffer_time_jitter_fraction: fn() -> f64,
568        load_list: Vec<Result<Identity, BoxError>>,
569    ) -> (LazyCache, SharedIdentityResolver) {
570        #[derive(Debug)]
571        struct Resolver(Mutex<Vec<Result<Identity, BoxError>>>);
572        impl ResolveIdentity for Resolver {
573            fn resolve_identity<'a>(
574                &'a self,
575                _: &'a RuntimeComponents,
576                _config_bag: &'a ConfigBag,
577            ) -> IdentityFuture<'a> {
578                let mut list = self.0.lock().unwrap();
579                if list.len() > 0 {
580                    let next = list.remove(0);
581                    info!("refreshing the identity to {:?}", next);
582                    IdentityFuture::ready(next)
583                } else {
584                    drop(list);
585                    panic!("no more identities")
586                }
587            }
588        }
589
590        let identity_resolver = SharedIdentityResolver::new(Resolver(Mutex::new(load_list)));
591        let cache = LazyCache::new(
592            Some(LOAD_TIMEOUT_FOR_TESTS),
593            DEFAULT_BUFFER_TIME,
594            buffer_time_jitter_fraction,
595            DEFAULT_EXPIRATION,
596            DEFAULT_MAX_PARTITIONS,
597        );
598        (cache, identity_resolver)
599    }
600
601    fn epoch_secs(secs: u64) -> SystemTime {
602        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
603    }
604
605    fn test_identity(expired_secs: u64) -> Identity {
606        let expiration = Some(epoch_secs(expired_secs));
607        Identity::new(Token::new("test", expiration), expiration)
608    }
609
610    async fn expect_identity(
611        expired_secs: u64,
612        cache: &LazyCache,
613        components: &RuntimeComponents,
614        resolver: SharedIdentityResolver,
615    ) {
616        let config_bag = ConfigBag::base();
617        let identity = cache
618            .resolve_cached_identity(resolver, components, &config_bag)
619            .await
620            .expect("expected identity");
621        assert_eq!(Some(epoch_secs(expired_secs)), identity.expiration());
622    }
623
624    #[tokio::test]
625    async fn initial_populate_test_identity() {
626        let time = ManualTimeSource::new(UNIX_EPOCH);
627        let components = RuntimeComponentsBuilder::for_tests()
628            .with_time_source(Some(time.clone()))
629            .with_sleep_impl(Some(TokioSleep::new()))
630            .build()
631            .unwrap();
632        let config_bag = ConfigBag::base();
633        let resolver = SharedIdentityResolver::new(resolver_fn(|| {
634            info!("refreshing the test_identity");
635            IdentityFuture::ready(Ok(test_identity(1000)))
636        }));
637        let cache = LazyCache::new(
638            Some(LOAD_TIMEOUT_FOR_TESTS),
639            DEFAULT_BUFFER_TIME,
640            BUFFER_TIME_NO_JITTER,
641            DEFAULT_EXPIRATION,
642            DEFAULT_MAX_PARTITIONS,
643        );
644        assert_eq!(
645            epoch_secs(1000),
646            cache
647                .resolve_cached_identity(resolver, &components, &config_bag)
648                .await
649                .unwrap()
650                .expiration()
651                .unwrap()
652        );
653    }
654
655    #[tokio::test]
656    async fn reload_expired_test_identity() {
657        let time = ManualTimeSource::new(epoch_secs(100));
658        let components = RuntimeComponentsBuilder::for_tests()
659            .with_time_source(Some(time.clone()))
660            .with_sleep_impl(Some(TokioSleep::new()))
661            .build()
662            .unwrap();
663        let (cache, resolver) = test_cache(
664            BUFFER_TIME_NO_JITTER,
665            vec![
666                Ok(test_identity(1000)),
667                Ok(test_identity(2000)),
668                Ok(test_identity(3000)),
669            ],
670        );
671
672        expect_identity(1000, &cache, &components, resolver.clone()).await;
673        expect_identity(1000, &cache, &components, resolver.clone()).await;
674        time.set_time(epoch_secs(1500));
675        expect_identity(2000, &cache, &components, resolver.clone()).await;
676        expect_identity(2000, &cache, &components, resolver.clone()).await;
677        time.set_time(epoch_secs(2500));
678        expect_identity(3000, &cache, &components, resolver.clone()).await;
679        expect_identity(3000, &cache, &components, resolver.clone()).await;
680    }
681
682    #[tokio::test]
683    async fn load_failed_error() {
684        let config_bag = ConfigBag::base();
685        let time = ManualTimeSource::new(epoch_secs(100));
686        let components = RuntimeComponentsBuilder::for_tests()
687            .with_time_source(Some(time.clone()))
688            .with_sleep_impl(Some(TokioSleep::new()))
689            .build()
690            .unwrap();
691        let (cache, resolver) = test_cache(
692            BUFFER_TIME_NO_JITTER,
693            vec![Ok(test_identity(1000)), Err("failed".into())],
694        );
695
696        expect_identity(1000, &cache, &components, resolver.clone()).await;
697        time.set_time(epoch_secs(1500));
698        assert!(cache
699            .resolve_cached_identity(resolver.clone(), &components, &config_bag)
700            .await
701            .is_err());
702    }
703
704    #[test]
705    fn load_contention() {
706        let rt = tokio::runtime::Builder::new_multi_thread()
707            .enable_time()
708            .worker_threads(16)
709            .build()
710            .unwrap();
711
712        let time = ManualTimeSource::new(epoch_secs(0));
713        let components = RuntimeComponentsBuilder::for_tests()
714            .with_time_source(Some(time.clone()))
715            .with_sleep_impl(Some(TokioSleep::new()))
716            .build()
717            .unwrap();
718        let (cache, resolver) = test_cache(
719            BUFFER_TIME_NO_JITTER,
720            vec![
721                Ok(test_identity(500)),
722                Ok(test_identity(1500)),
723                Ok(test_identity(2500)),
724                Ok(test_identity(3500)),
725                Ok(test_identity(4500)),
726            ],
727        );
728        let cache: SharedIdentityCache = cache.into_shared();
729
730        // test_identity are available up until 4500 seconds after the unix epoch
731        // 4*50 = 200 tasks are launched => we can advance time 4500/20 => 225 seconds per advance
732        for _ in 0..4 {
733            let mut tasks = Vec::new();
734            for _ in 0..50 {
735                let resolver = resolver.clone();
736                let cache = cache.clone();
737                let time = time.clone();
738                let components = components.clone();
739                tasks.push(rt.spawn(async move {
740                    let now = time.advance(Duration::from_secs(22));
741
742                    let config_bag = ConfigBag::base();
743                    let identity = cache
744                        .resolve_cached_identity(resolver, &components, &config_bag)
745                        .await
746                        .unwrap();
747                    assert!(
748                        identity.expiration().unwrap() >= now,
749                        "{:?} >= {:?}",
750                        identity.expiration(),
751                        now
752                    );
753                }));
754            }
755            for task in tasks {
756                rt.block_on(task).unwrap();
757            }
758        }
759    }
760
761    #[tokio::test]
762    async fn load_timeout() {
763        let config_bag = ConfigBag::base();
764        let (time, sleep) = instant_time_and_sleep(epoch_secs(100));
765        let components = RuntimeComponentsBuilder::for_tests()
766            .with_time_source(Some(time.clone()))
767            .with_sleep_impl(Some(sleep))
768            .build()
769            .unwrap();
770        let resolver = SharedIdentityResolver::new(resolver_fn(|| {
771            IdentityFuture::new(async {
772                aws_smithy_async::future::never::Never::new().await;
773                Ok(test_identity(1000))
774            })
775        }));
776        let cache = LazyCache::new(
777            Some(Duration::from_secs(5)),
778            DEFAULT_BUFFER_TIME,
779            BUFFER_TIME_NO_JITTER,
780            DEFAULT_EXPIRATION,
781            DEFAULT_MAX_PARTITIONS,
782        );
783
784        let err: BoxError = cache
785            .resolve_cached_identity(resolver, &components, &config_bag)
786            .await
787            .expect_err("it should return an error");
788        let downcasted = err.downcast_ref::<TimedOutError>();
789        assert!(
790            downcasted.is_some(),
791            "expected a BoxError of TimedOutError, but was {err:?}"
792        );
793        assert_eq!(time.now(), epoch_secs(105));
794    }
795
796    #[tokio::test]
797    async fn buffer_time_jitter() {
798        let time = ManualTimeSource::new(epoch_secs(100));
799        let components = RuntimeComponentsBuilder::for_tests()
800            .with_time_source(Some(time.clone()))
801            .with_sleep_impl(Some(TokioSleep::new()))
802            .build()
803            .unwrap();
804        let buffer_time_jitter_fraction = || 0.5_f64;
805        let (cache, resolver) = test_cache(
806            buffer_time_jitter_fraction,
807            vec![Ok(test_identity(1000)), Ok(test_identity(2000))],
808        );
809
810        expect_identity(1000, &cache, &components, resolver.clone()).await;
811        let buffer_time_with_jitter =
812            (DEFAULT_BUFFER_TIME.as_secs_f64() * buffer_time_jitter_fraction()) as u64;
813        assert_eq!(buffer_time_with_jitter, 5);
814        // Advance time to the point where the first test_identity are about to expire (but haven't).
815        let almost_expired_secs = 1000 - buffer_time_with_jitter - 1;
816        time.set_time(epoch_secs(almost_expired_secs));
817        // We should still use the first test_identity.
818        expect_identity(1000, &cache, &components, resolver.clone()).await;
819        // Now let the first test_identity expire.
820        let expired_secs = almost_expired_secs + 1;
821        time.set_time(epoch_secs(expired_secs));
822        // Now that the first test_identity have been expired, the second test_identity will be retrieved.
823        expect_identity(2000, &cache, &components, resolver.clone()).await;
824    }
825
826    #[tokio::test]
827    async fn cache_partitioning() {
828        let time = ManualTimeSource::new(epoch_secs(0));
829        let components = RuntimeComponentsBuilder::for_tests()
830            .with_time_source(Some(time.clone()))
831            .with_sleep_impl(Some(TokioSleep::new()))
832            .build()
833            .unwrap();
834        let (cache, _) = test_cache(BUFFER_TIME_NO_JITTER, Vec::new());
835
836        #[allow(clippy::disallowed_methods)]
837        let far_future = SystemTime::now() + Duration::from_secs(10_000);
838
839        // Resolver A and B both return an identical identity type with different tokens with an expiration
840        // time that should NOT be hit within this test. They each have their own partition key.
841        let resolver_a_calls = Arc::new(AtomicUsize::new(0));
842        let resolver_b_calls = Arc::new(AtomicUsize::new(0));
843        let resolver_a = resolver_fn({
844            let calls = resolver_a_calls.clone();
845            move || {
846                calls.fetch_add(1, Ordering::Relaxed);
847                IdentityFuture::ready(Ok(Identity::new(
848                    Token::new("A", Some(far_future)),
849                    Some(far_future),
850                )))
851            }
852        });
853        let resolver_b = resolver_fn({
854            let calls = resolver_b_calls.clone();
855            move || {
856                calls.fetch_add(1, Ordering::Relaxed);
857                IdentityFuture::ready(Ok(Identity::new(
858                    Token::new("B", Some(far_future)),
859                    Some(far_future),
860                )))
861            }
862        });
863        assert_ne!(
864            resolver_a.cache_partition(),
865            resolver_b.cache_partition(),
866            "pre-condition: they should have different partition keys"
867        );
868
869        let config_bag = ConfigBag::base();
870
871        // Loading the identity twice with resolver A should result in a single call
872        // to the underlying identity resolver since the result gets cached.
873        let identity = cache
874            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
875            .await
876            .unwrap();
877        assert_eq!("A", identity.data::<Token>().unwrap().token());
878        let identity = cache
879            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
880            .await
881            .unwrap();
882        assert_eq!("A", identity.data::<Token>().unwrap().token());
883        assert_eq!(1, resolver_a_calls.load(Ordering::Relaxed));
884
885        // Now, loading an identity from B will use a separate cache partition
886        // and return a different result.
887        let identity = cache
888            .resolve_cached_identity(resolver_b.clone(), &components, &config_bag)
889            .await
890            .unwrap();
891        assert_eq!("B", identity.data::<Token>().unwrap().token());
892        let identity = cache
893            .resolve_cached_identity(resolver_b.clone(), &components, &config_bag)
894            .await
895            .unwrap();
896        assert_eq!("B", identity.data::<Token>().unwrap().token());
897        assert_eq!(1, resolver_a_calls.load(Ordering::Relaxed));
898        assert_eq!(1, resolver_b_calls.load(Ordering::Relaxed));
899
900        // Finally, loading with resolver A again should return the original cached A value
901        let identity = cache
902            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
903            .await
904            .unwrap();
905        assert_eq!("A", identity.data::<Token>().unwrap().token());
906        assert_eq!(1, resolver_a_calls.load(Ordering::Relaxed));
907        assert_eq!(1, resolver_b_calls.load(Ordering::Relaxed));
908    }
909
910    #[tokio::test]
911    async fn eviction_when_at_capacity() {
912        let time = ManualTimeSource::new(epoch_secs(0));
913        let components = RuntimeComponentsBuilder::for_tests()
914            .with_time_source(Some(time.clone()))
915            .with_sleep_impl(Some(TokioSleep::new()))
916            .build()
917            .unwrap();
918        // Create a cache with max_partitions=2
919        let cache = LazyCache::new(
920            Some(LOAD_TIMEOUT_FOR_TESTS),
921            DEFAULT_BUFFER_TIME,
922            BUFFER_TIME_NO_JITTER,
923            DEFAULT_EXPIRATION,
924            2,
925        );
926
927        #[allow(clippy::disallowed_methods)]
928        let far_future = SystemTime::now() + Duration::from_secs(10_000);
929
930        let resolver_a_calls = Arc::new(AtomicUsize::new(0));
931        let resolver_b_calls = Arc::new(AtomicUsize::new(0));
932        let resolver_c_calls = Arc::new(AtomicUsize::new(0));
933
934        let resolver_a = resolver_fn({
935            let calls = resolver_a_calls.clone();
936            move || {
937                calls.fetch_add(1, Ordering::Relaxed);
938                IdentityFuture::ready(Ok(Identity::new(
939                    Token::new("A", Some(far_future)),
940                    Some(far_future),
941                )))
942            }
943        });
944        let resolver_b = resolver_fn({
945            let calls = resolver_b_calls.clone();
946            move || {
947                calls.fetch_add(1, Ordering::Relaxed);
948                IdentityFuture::ready(Ok(Identity::new(
949                    Token::new("B", Some(far_future)),
950                    Some(far_future),
951                )))
952            }
953        });
954        let resolver_c = resolver_fn({
955            let calls = resolver_c_calls.clone();
956            move || {
957                calls.fetch_add(1, Ordering::Relaxed);
958                IdentityFuture::ready(Ok(Identity::new(
959                    Token::new("C", Some(far_future)),
960                    Some(far_future),
961                )))
962            }
963        });
964
965        let config_bag = ConfigBag::base();
966
967        // Fill the cache with A and B
968        cache
969            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
970            .await
971            .unwrap();
972        cache
973            .resolve_cached_identity(resolver_b.clone(), &components, &config_bag)
974            .await
975            .unwrap();
976        assert_eq!(1, resolver_a_calls.load(Ordering::Relaxed));
977        assert_eq!(1, resolver_b_calls.load(Ordering::Relaxed));
978
979        // Adding C should evict one of A or B (arbitrary eviction order)
980        cache
981            .resolve_cached_identity(resolver_c.clone(), &components, &config_bag)
982            .await
983            .unwrap();
984        assert_eq!(1, resolver_c_calls.load(Ordering::Relaxed));
985
986        // Resolve all three again — at least one of A or B must be re-resolved because
987        // the cache only holds 2 partitions. Depending on HashMap iteration order, re-inserting
988        // the evicted entry may cascade-evict the other, leading to 4 or 5 total calls.
989        cache
990            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
991            .await
992            .unwrap();
993        cache
994            .resolve_cached_identity(resolver_b.clone(), &components, &config_bag)
995            .await
996            .unwrap();
997        let total_calls = resolver_a_calls.load(Ordering::Relaxed)
998            + resolver_b_calls.load(Ordering::Relaxed)
999            + resolver_c_calls.load(Ordering::Relaxed);
1000        // Initial: 3 calls (A, B, C). At least one of A or B was evicted and re-resolved (+1).
1001        // If re-inserting the evicted entry cascade-evicts the other, both need re-resolution (+2).
1002        assert!(
1003            (4..=5).contains(&total_calls),
1004            "expected 4 or 5 total calls (3 initial + 1 or 2 re-resolutions), got {total_calls}"
1005        );
1006    }
1007
1008    #[tokio::test]
1009    async fn single_partition_cache() {
1010        let time = ManualTimeSource::new(epoch_secs(0));
1011        let components = RuntimeComponentsBuilder::for_tests()
1012            .with_time_source(Some(time.clone()))
1013            .with_sleep_impl(Some(TokioSleep::new()))
1014            .build()
1015            .unwrap();
1016        // Mimics the operation-scoped cache used for config overrides
1017        let cache = LazyCache::new(
1018            Some(LOAD_TIMEOUT_FOR_TESTS),
1019            DEFAULT_BUFFER_TIME,
1020            BUFFER_TIME_NO_JITTER,
1021            DEFAULT_EXPIRATION,
1022            1,
1023        );
1024
1025        #[allow(clippy::disallowed_methods)]
1026        let far_future = SystemTime::now() + Duration::from_secs(10_000);
1027
1028        let resolver_a_calls = Arc::new(AtomicUsize::new(0));
1029        let resolver_b_calls = Arc::new(AtomicUsize::new(0));
1030
1031        let resolver_a = resolver_fn({
1032            let calls = resolver_a_calls.clone();
1033            move || {
1034                calls.fetch_add(1, Ordering::Relaxed);
1035                IdentityFuture::ready(Ok(Identity::new(
1036                    Token::new("A", Some(far_future)),
1037                    Some(far_future),
1038                )))
1039            }
1040        });
1041        let resolver_b = resolver_fn({
1042            let calls = resolver_b_calls.clone();
1043            move || {
1044                calls.fetch_add(1, Ordering::Relaxed);
1045                IdentityFuture::ready(Ok(Identity::new(
1046                    Token::new("B", Some(far_future)),
1047                    Some(far_future),
1048                )))
1049            }
1050        });
1051
1052        let config_bag = ConfigBag::base();
1053
1054        // First call resolves A
1055        let identity = cache
1056            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
1057            .await
1058            .unwrap();
1059        assert_eq!("A", identity.data::<Token>().unwrap().token());
1060        assert_eq!(1, resolver_a_calls.load(Ordering::Relaxed));
1061
1062        // Second call with same resolver is cached
1063        let identity = cache
1064            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
1065            .await
1066            .unwrap();
1067        assert_eq!("A", identity.data::<Token>().unwrap().token());
1068        assert_eq!(1, resolver_a_calls.load(Ordering::Relaxed));
1069
1070        // Resolving B evicts A (only 1 partition)
1071        let identity = cache
1072            .resolve_cached_identity(resolver_b.clone(), &components, &config_bag)
1073            .await
1074            .unwrap();
1075        assert_eq!("B", identity.data::<Token>().unwrap().token());
1076        assert_eq!(1, resolver_b_calls.load(Ordering::Relaxed));
1077
1078        // A must be re-resolved
1079        let identity = cache
1080            .resolve_cached_identity(resolver_a.clone(), &components, &config_bag)
1081            .await
1082            .unwrap();
1083        assert_eq!("A", identity.data::<Token>().unwrap().token());
1084        assert_eq!(2, resolver_a_calls.load(Ordering::Relaxed));
1085    }
1086
1087    #[test]
1088    #[should_panic(expected = "max_partitions must be greater than 0")]
1089    fn max_partitions_zero_panics() {
1090        LazyCacheBuilder::new().max_partitions(0);
1091    }
1092
1093    #[test]
1094    fn pessimistic_load_timeout_default_config() {
1095        // Default: 3 attempts, 1s initial_backoff, 20s max_backoff, 3.1s connect
1096        let mut config_bag = ConfigBag::base();
1097        config_bag
1098            .interceptor_state()
1099            .store_put(RetryConfig::standard());
1100        config_bag.interceptor_state().store_put(
1101            TimeoutConfig::builder()
1102                .connect_timeout(crate::client::defaults::DEFAULT_CONNECT_TIMEOUT)
1103                .build(),
1104        );
1105        let timeout = pessimistic_load_timeout(&config_bag);
1106        // backoff: 1+2 = 3s, attempts: 3 * 6.2 = 18.6s, total = 21.6s
1107        assert!(
1108            timeout > Duration::from_secs(21) && timeout < Duration::from_secs(22),
1109            "expected ~21.6s, got {:?}",
1110            timeout
1111        );
1112    }
1113
1114    #[test]
1115    fn pessimistic_load_timeout_five_attempts() {
1116        let mut config_bag = ConfigBag::base();
1117        config_bag
1118            .interceptor_state()
1119            .store_put(RetryConfig::standard().with_max_attempts(5));
1120        config_bag.interceptor_state().store_put(
1121            TimeoutConfig::builder()
1122                .connect_timeout(crate::client::defaults::DEFAULT_CONNECT_TIMEOUT)
1123                .build(),
1124        );
1125        let timeout = pessimistic_load_timeout(&config_bag);
1126        // backoff: 1+2+4+8 = 15s, attempts: 5 * 6.2 = 31s, total = 46s
1127        assert!(
1128            timeout > Duration::from_secs(45) && timeout < Duration::from_secs(47),
1129            "expected ~46s, got {:?}",
1130            timeout
1131        );
1132    }
1133
1134    #[test]
1135    fn pessimistic_load_timeout_zero_attempts_has_floor() {
1136        let mut config_bag = ConfigBag::base();
1137        config_bag
1138            .interceptor_state()
1139            .store_put(RetryConfig::standard().with_max_attempts(0));
1140        config_bag.interceptor_state().store_put(
1141            TimeoutConfig::builder()
1142                .connect_timeout(crate::client::defaults::DEFAULT_CONNECT_TIMEOUT)
1143                .build(),
1144        );
1145        let timeout = pessimistic_load_timeout(&config_bag);
1146        // Floor: per_attempt = 3.1 * 2 = 6.2s
1147        assert!(
1148            timeout >= Duration::from_secs(6) && timeout < Duration::from_secs(7),
1149            "expected ~6.2s floor, got {:?}",
1150            timeout
1151        );
1152    }
1153
1154    #[test]
1155    fn pessimistic_load_timeout_no_config_in_bag_uses_defaults() {
1156        // Empty config bag — falls back to RetryConfig::standard() and TimeoutConfig::disabled()
1157        let config_bag = ConfigBag::base();
1158        let timeout = pessimistic_load_timeout(&config_bag);
1159        // standard = 3 attempts, disabled timeout → uses DEFAULT_CONNECT_TIMEOUT (3.1s)
1160        // Same as default config: ~21.6s
1161        assert!(
1162            timeout > Duration::from_secs(21) && timeout < Duration::from_secs(22),
1163            "expected ~21.6s, got {:?}",
1164            timeout
1165        );
1166    }
1167
1168    #[test]
1169    fn pessimistic_load_timeout_low_connect_is_floored_at_default() {
1170        // An aggressively low connect_timeout must not shrink the safety net below the
1171        // default-derived value: the connect base is floored at DEFAULT_CONNECT_TIMEOUT (3.1s).
1172        let mut config_bag = ConfigBag::base();
1173        config_bag
1174            .interceptor_state()
1175            .store_put(RetryConfig::standard());
1176        config_bag.interceptor_state().store_put(
1177            TimeoutConfig::builder()
1178                .connect_timeout(Duration::from_secs(1))
1179                .build(),
1180        );
1181        let timeout = pessimistic_load_timeout(&config_bag);
1182        // Floored connect = 3.1s → per_attempt = 6.2s; backoff 1+2 = 3s; 3 * 6.2 = 18.6s → ~21.6s
1183        assert!(
1184            timeout > Duration::from_secs(21) && timeout < Duration::from_secs(22),
1185            "expected ~21.6s (floored), got {:?}",
1186            timeout
1187        );
1188    }
1189
1190    #[test]
1191    fn pessimistic_load_timeout_uses_operation_attempt_timeout_when_larger() {
1192        // operation_attempt_timeout is a full-attempt ceiling enforced by the inner orchestrator;
1193        // when larger than connect × 2 it becomes the per-attempt term (used as-is, NOT doubled)
1194        // so the cache doesn't fire before the inner per-attempt timeout could.
1195        let mut config_bag = ConfigBag::base();
1196        config_bag
1197            .interceptor_state()
1198            .store_put(RetryConfig::standard());
1199        config_bag.interceptor_state().store_put(
1200            TimeoutConfig::builder()
1201                .connect_timeout(crate::client::defaults::DEFAULT_CONNECT_TIMEOUT)
1202                .operation_attempt_timeout(Duration::from_secs(30))
1203                .build(),
1204        );
1205        let timeout = pessimistic_load_timeout(&config_bag);
1206        // per_attempt = max(6.2, 30) = 30s; backoff 1+2 = 3s; 3 * 30 = 90s → ~93s
1207        assert!(
1208            timeout > Duration::from_secs(92) && timeout < Duration::from_secs(94),
1209            "expected ~93s, got {:?}",
1210            timeout
1211        );
1212    }
1213
1214    #[test]
1215    fn pessimistic_load_timeout_ignores_operation_attempt_timeout_when_smaller() {
1216        // A small operation_attempt_timeout must not shrink the per-attempt term below the
1217        // connect-based estimate; connect × 2 wins.
1218        let mut config_bag = ConfigBag::base();
1219        config_bag
1220            .interceptor_state()
1221            .store_put(RetryConfig::standard());
1222        config_bag.interceptor_state().store_put(
1223            TimeoutConfig::builder()
1224                .connect_timeout(crate::client::defaults::DEFAULT_CONNECT_TIMEOUT)
1225                .operation_attempt_timeout(Duration::from_secs(1))
1226                .build(),
1227        );
1228        let timeout = pessimistic_load_timeout(&config_bag);
1229        // per_attempt = max(6.2, 1) = 6.2s → ~21.6s
1230        assert!(
1231            timeout > Duration::from_secs(21) && timeout < Duration::from_secs(22),
1232            "expected ~21.6s, got {:?}",
1233            timeout
1234        );
1235    }
1236}