Skip to main content

olai_http/
token.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::future::Future;
19use std::time::{Duration, Instant};
20use tokio::sync::Mutex;
21
22/// A temporary authentication token with an associated expiry
23#[derive(Debug, Clone)]
24pub struct TemporaryToken<T> {
25    /// The temporary credential
26    pub token: T,
27    /// The instant at which this credential is no longer valid
28    /// None means the credential does not expire
29    pub expiry: Option<Instant>,
30}
31
32/// Thread-safe cache for a [`TemporaryToken`] that proactively refreshes before
33/// the token expires.
34///
35/// ## Min-TTL strategy
36///
37/// A cached token is considered *still valid* only when its remaining lifetime
38/// exceeds `min_ttl` (default **5 minutes**).  Once the remaining lifetime
39/// drops below that threshold the next call to [`get_or_insert_with`] will
40/// trigger a synchronous re-fetch — while the lock is held — before returning
41/// the new token.  This ensures callers always receive a token with a
42/// comfortable margin before it expires, avoiding races where a token is used
43/// just as it becomes invalid.
44///
45/// An additional `fetch_backoff` guard (default **100 ms**) prevents a
46/// thundering-herd of re-fetch attempts in the window where the token is below
47/// `min_ttl` but has not yet expired: if the previous fetch happened within
48/// `fetch_backoff` and the token has not actually expired yet, the stale-but-
49/// not-yet-expired token is returned immediately.
50///
51/// [`get_or_insert_with`]: TokenCache::get_or_insert_with
52#[derive(Debug)]
53pub struct TokenCache<T> {
54    cache: Mutex<Option<(TemporaryToken<T>, Instant)>>,
55    min_ttl: Duration,
56    fetch_backoff: Duration,
57}
58
59impl<T> Default for TokenCache<T> {
60    fn default() -> Self {
61        Self {
62            cache: Default::default(),
63            min_ttl: Duration::from_secs(300),
64            // How long to wait before re-attempting a token fetch after receiving one that
65            // is still within the min-ttl
66            fetch_backoff: Duration::from_millis(100),
67        }
68    }
69}
70
71impl<T: Clone + Send> TokenCache<T> {
72    /// Override the minimum remaining TTL for a cached token to be used
73    pub(crate) fn with_min_ttl(self, min_ttl: Duration) -> Self {
74        Self { min_ttl, ..self }
75    }
76
77    pub async fn get_or_insert_with<F, Fut, E>(&self, f: F) -> Result<T, E>
78    where
79        F: FnOnce() -> Fut + Send,
80        Fut: Future<Output = Result<TemporaryToken<T>, E>> + Send,
81    {
82        let now = Instant::now();
83        let mut locked = self.cache.lock().await;
84
85        if let Some((cached, fetched_at)) = locked.as_ref() {
86            match cached.expiry {
87                Some(ttl) => {
88                    if ttl.checked_duration_since(now).unwrap_or_default() > self.min_ttl ||
89                        // if we've recently attempted to fetch this token and it's not actually
90                        // expired, we'll wait to re-fetch it and return the cached one
91                        (fetched_at.elapsed() < self.fetch_backoff && ttl.checked_duration_since(now).is_some())
92                    {
93                        return Ok(cached.token.clone());
94                    }
95                }
96                None => return Ok(cached.token.clone()),
97            }
98        }
99
100        let cached = f().await?;
101        let token = cached.token.clone();
102        *locked = Some((cached, Instant::now()));
103
104        Ok(token)
105    }
106}
107
108#[cfg(test)]
109mod test {
110    use super::*;
111    use std::sync::Arc;
112    use std::sync::atomic::{AtomicU32, Ordering};
113    use std::time::{Duration, Instant};
114
115    // Helper function to create a token with a specific expiry duration from now
116    fn create_token(expiry_duration: Option<Duration>) -> TemporaryToken<String> {
117        TemporaryToken {
118            token: "test_token".to_string(),
119            expiry: expiry_duration.map(|d| Instant::now() + d),
120        }
121    }
122
123    #[tokio::test]
124    async fn test_expired_token_is_refreshed() {
125        let cache = TokenCache::default();
126        static COUNTER: AtomicU32 = AtomicU32::new(0);
127
128        async fn get_token() -> Result<TemporaryToken<String>, String> {
129            COUNTER.fetch_add(1, Ordering::SeqCst);
130            Ok::<_, String>(create_token(Some(Duration::from_secs(0))))
131        }
132
133        // Should fetch initial token
134        let _ = cache.get_or_insert_with(get_token).await.unwrap();
135        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
136
137        tokio::time::sleep(Duration::from_millis(2)).await;
138
139        // Token is expired, so should fetch again
140        let _ = cache.get_or_insert_with(get_token).await.unwrap();
141        assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
142    }
143
144    #[tokio::test]
145    async fn test_min_ttl_causes_refresh() {
146        let cache = TokenCache {
147            cache: Default::default(),
148            min_ttl: Duration::from_secs(1),
149            fetch_backoff: Duration::from_millis(1),
150        };
151
152        static COUNTER: AtomicU32 = AtomicU32::new(0);
153
154        async fn get_token() -> Result<TemporaryToken<String>, String> {
155            COUNTER.fetch_add(1, Ordering::SeqCst);
156            Ok::<_, String>(create_token(Some(Duration::from_millis(100))))
157        }
158
159        // Initial fetch
160        let _ = cache.get_or_insert_with(get_token).await.unwrap();
161        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
162
163        // Should not fetch again since not expired and within fetch_backoff
164        let _ = cache.get_or_insert_with(get_token).await.unwrap();
165        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
166
167        tokio::time::sleep(Duration::from_millis(2)).await;
168
169        // Should fetch, since we've passed fetch_backoff
170        let _ = cache.get_or_insert_with(get_token).await.unwrap();
171        assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
172    }
173
174    #[tokio::test]
175    async fn test_valid_token_within_ttl_is_reused() {
176        // Default min_ttl is 5 minutes; a token valid for an hour is comfortably
177        // within its window and must be served from cache, never re-fetched.
178        let cache = TokenCache::default();
179        static COUNTER: AtomicU32 = AtomicU32::new(0);
180
181        async fn get_token() -> Result<TemporaryToken<String>, String> {
182            COUNTER.fetch_add(1, Ordering::SeqCst);
183            Ok::<_, String>(create_token(Some(Duration::from_secs(3600))))
184        }
185
186        // First call performs the fetch.
187        let _ = cache.get_or_insert_with(get_token).await.unwrap();
188        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
189
190        // Several subsequent calls all hit the cache.
191        for _ in 0..5 {
192            let _ = cache.get_or_insert_with(get_token).await.unwrap();
193        }
194        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
195    }
196
197    #[tokio::test]
198    async fn test_non_expiring_token_is_reused() {
199        // A token with `expiry == None` never expires and must always be served
200        // from cache after the initial fetch.
201        let cache = TokenCache::default();
202        static COUNTER: AtomicU32 = AtomicU32::new(0);
203
204        async fn get_token() -> Result<TemporaryToken<String>, String> {
205            COUNTER.fetch_add(1, Ordering::SeqCst);
206            Ok::<_, String>(create_token(None))
207        }
208
209        let _ = cache.get_or_insert_with(get_token).await.unwrap();
210        let _ = cache.get_or_insert_with(get_token).await.unwrap();
211        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
212    }
213
214    #[tokio::test]
215    async fn test_concurrent_callers_single_flight() {
216        // `get_or_insert_with` holds the cache mutex across the fetch future, so
217        // concurrent callers serialize: the first performs the (slow) fetch and
218        // stores a token comfortably within `min_ttl`, and the rest observe the
219        // freshly-cached token and return it without re-fetching.
220        let cache = Arc::new(TokenCache::default());
221        static COUNTER: AtomicU32 = AtomicU32::new(0);
222
223        async fn get_token() -> Result<TemporaryToken<String>, String> {
224            COUNTER.fetch_add(1, Ordering::SeqCst);
225            // Simulate a slow network fetch so callers genuinely overlap.
226            tokio::time::sleep(Duration::from_millis(20)).await;
227            Ok::<_, String>(create_token(Some(Duration::from_secs(3600))))
228        }
229
230        let mut handles = Vec::new();
231        for _ in 0..10 {
232            let cache = Arc::clone(&cache);
233            handles.push(tokio::spawn(async move {
234                cache.get_or_insert_with(get_token).await.unwrap()
235            }));
236        }
237        for h in handles {
238            h.await.unwrap();
239        }
240
241        // Exactly one fetch despite ten concurrent callers.
242        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
243    }
244}