1pub 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
43const DEFAULT_MANDATORY_WINDOW: Duration = Duration::from_secs(60);
45const CACHING_ONLY_BUFFER_TIME: Duration = Duration::from_secs(10);
47const BACKOFF_MIN_SECS: u64 = 300;
49const BACKOFF_JITTER_SECS: u64 = 300;
51const ERROR_CACHE_MIN_SECS: u64 = 1;
53const ERROR_CACHE_JITTER_SECS: u64 = 4;
54
55const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_millis(3100);
56
57fn 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 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 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 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
103pub struct StaticStabilityCache {
107 partitions: RwLock<HashMap<IdentityCachePartition, Arc<Partition>>>,
108 load_timeout: Option<Duration>,
109 non_recoverable: Option<NonRecoverablePredicate>,
110 backoff_override: Option<Duration>,
112 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 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 #[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 fn partition(&self, key: IdentityCachePartition) -> Arc<Partition> {
159 if let Some(p) = self.partitions.read().unwrap().get(&key).cloned() {
160 return p;
161 }
162 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 async fn refresh(
181 &self,
182 part: &Partition,
183 resolver: &SharedIdentityResolver,
184 runtime_components: &RuntimeComponents,
185 config_bag: &ConfigBag,
186 ) -> Result<Identity, BoxError> {
187 {
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 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 let now = runtime_components.time_source().expect("validated").now();
226
227 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 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 let at = sub(expiry, CACHING_ONLY_BUFFER_TIME);
254 st.advisory_at = Some(at);
255 st.mandatory_at = Some(at);
256 }
257 }
258 None => {
262 st.expiration = None;
263 st.advisory_at = None;
264 st.mandatory_at = None;
265 }
266 }
267 st.next_refresh_allowed_at = None; st.cached_error = None; st.cached_error_expires_at = None;
270 Ok(id)
271 }
272 Err(err) => {
273 if self.non_recoverable.as_ref().is_some_and(|p| p(&err)) {
276 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 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 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 let decision = { classify(&part.state.lock().unwrap(), now) };
342
343 match decision {
344 Decision::Valid(id) | Decision::RateLimited(id) => Ok(id),
346 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 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 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 st.expiration = Some(SystemTime::UNIX_EPOCH);
378 st.advisory_at = Some(SystemTime::UNIX_EPOCH);
379 st.mandatory_at = Some(SystemTime::UNIX_EPOCH);
380 break; }
382 }
383 }
384}
385
386#[derive(Debug, Default)]
388struct Partition {
389 state: Mutex<CachedState>,
391 refresh_gate: tokio::sync::Mutex<()>,
393}
394
395impl Partition {
396 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 cached: Option<Identity>,
410 expiration: Option<SystemTime>,
411 advisory_at: Option<SystemTime>,
413 mandatory_at: Option<SystemTime>,
415 next_refresh_allowed_at: Option<SystemTime>,
417 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 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 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, }
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
469fn 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#[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
507fn 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#[derive(Clone, Debug, Default)]
538pub struct StaticStabilityCacheBuilder {
539 load_timeout: Option<Duration>,
540}
541
542impl StaticStabilityCacheBuilder {
543 pub fn load_timeout(mut self, load_timeout: Duration) -> Self {
546 self.load_timeout = Some(load_timeout);
547 self
548 }
549
550 pub fn build(self) -> SharedIdentityCache {
552 StaticStabilityCache::new(self.load_timeout).into_shared()
553 }
554}
555
556#[cfg(test)]
557impl StaticStabilityCache {
558 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 #[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 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 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 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))); }
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 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 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 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 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 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 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 #[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 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 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 #[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)); 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)); assert_eq!(advisory_window_for(m(6 * 60)), m(60));
910 }
911
912 #[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 #[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 #[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); 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 #[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); 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); 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); 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 #[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 let h = Harness::new(vec![Ok(no_expiry)]);
1003 assert_eq!(id_of(&h.get().await.0.unwrap()), 1);
1004
1005 h.advance_to(100 * 3600); 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 #[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 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 #[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 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 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 cache.invalidate(&served_b);
1082
1083 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 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 #[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; next
1123 })
1124 }
1125 }
1126
1127 #[tokio::test]
1130 async fn advisory_concurrent_single_flight() {
1131 let time = ManualTimeSource::new(epoch(0));
1132 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 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)); 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 #[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)); 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 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 #[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)); 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 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}