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::atomic::{AtomicU32, Ordering};
112 use std::time::{Duration, Instant};
113
114 // Helper function to create a token with a specific expiry duration from now
115 fn create_token(expiry_duration: Option<Duration>) -> TemporaryToken<String> {
116 TemporaryToken {
117 token: "test_token".to_string(),
118 expiry: expiry_duration.map(|d| Instant::now() + d),
119 }
120 }
121
122 #[tokio::test]
123 async fn test_expired_token_is_refreshed() {
124 let cache = TokenCache::default();
125 static COUNTER: AtomicU32 = AtomicU32::new(0);
126
127 async fn get_token() -> Result<TemporaryToken<String>, String> {
128 COUNTER.fetch_add(1, Ordering::SeqCst);
129 Ok::<_, String>(create_token(Some(Duration::from_secs(0))))
130 }
131
132 // Should fetch initial token
133 let _ = cache.get_or_insert_with(get_token).await.unwrap();
134 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
135
136 tokio::time::sleep(Duration::from_millis(2)).await;
137
138 // Token is expired, so should fetch again
139 let _ = cache.get_or_insert_with(get_token).await.unwrap();
140 assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
141 }
142
143 #[tokio::test]
144 async fn test_min_ttl_causes_refresh() {
145 let cache = TokenCache {
146 cache: Default::default(),
147 min_ttl: Duration::from_secs(1),
148 fetch_backoff: Duration::from_millis(1),
149 };
150
151 static COUNTER: AtomicU32 = AtomicU32::new(0);
152
153 async fn get_token() -> Result<TemporaryToken<String>, String> {
154 COUNTER.fetch_add(1, Ordering::SeqCst);
155 Ok::<_, String>(create_token(Some(Duration::from_millis(100))))
156 }
157
158 // Initial fetch
159 let _ = cache.get_or_insert_with(get_token).await.unwrap();
160 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
161
162 // Should not fetch again since not expired and within fetch_backoff
163 let _ = cache.get_or_insert_with(get_token).await.unwrap();
164 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
165
166 tokio::time::sleep(Duration::from_millis(2)).await;
167
168 // Should fetch, since we've passed fetch_backoff
169 let _ = cache.get_or_insert_with(get_token).await.unwrap();
170 assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
171 }
172}