use std::time::{Duration, Instant};
use async_trait::async_trait;
use tokio::{sync::watch, task::JoinHandle};
use crate::token_source::{TokenSource, TokenSourceError};
const DEFAULT_REFRESH_RETRY_DELAY: Duration = Duration::from_secs(5);
const DEFAULT_REFRESH_THRESHOLD: Duration = Duration::from_secs(60);
const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_MIN_TOKEN_LIFETIME: Duration = Duration::from_secs(10);
pub struct RefreshTokenSourceBuilder<T: TokenRefresher> {
name: String,
token_refresher: T,
refresh_retry_delay: Duration,
refresh_threshold: Duration,
refresh_timeout: Duration,
min_token_lifetime: Duration,
initial_token: Option<TokenWithExpiry>,
}
impl<T: TokenRefresher> RefreshTokenSourceBuilder<T> {
pub fn new(name: String, token_refresher: T) -> Self {
Self {
name,
token_refresher,
refresh_retry_delay: DEFAULT_REFRESH_RETRY_DELAY,
refresh_threshold: DEFAULT_REFRESH_THRESHOLD,
refresh_timeout: DEFAULT_REFRESH_TIMEOUT,
min_token_lifetime: DEFAULT_MIN_TOKEN_LIFETIME,
initial_token: None,
}
}
pub fn with_initial_token(mut self, token: TokenWithExpiry) -> Self {
self.initial_token = Some(token);
self
}
pub fn min_token_lifetime(mut self, duration: Duration) -> Self {
self.min_token_lifetime = duration;
self
}
pub fn refresh_retry_delay(mut self, duration: Duration) -> Self {
self.refresh_retry_delay = duration;
self
}
pub fn refresh_threshold(mut self, duration: Duration) -> Self {
self.refresh_threshold = duration;
self
}
pub fn refresh_timeout(mut self, duration: Duration) -> Self {
self.refresh_timeout = duration;
self
}
pub fn build(self) -> RefreshTokenSource {
RefreshTokenSource::new(
self.name,
self.token_refresher,
self.refresh_retry_delay,
self.refresh_threshold,
self.min_token_lifetime,
self.initial_token,
)
}
}
pub struct RefreshTokenSource {
watch_rx: watch::Receiver<Option<Result<String, TokenSourceError>>>,
#[allow(unused)]
task_handle: RefreshingTokenSourceTaskHandle,
}
impl RefreshTokenSource {
pub fn builder<T: TokenRefresher>(
name: impl Into<String>,
token_refresher: T,
) -> RefreshTokenSourceBuilder<T> {
RefreshTokenSourceBuilder::new(name.into(), token_refresher)
}
pub fn new(
name: String,
token_refresher: impl TokenRefresher,
refresh_retry_delay: Duration,
refresh_threshold: Duration,
min_token_lifetime: Duration,
initial_token: Option<TokenWithExpiry>,
) -> Self {
let (watch_tx, watch_rx) = tokio::sync::watch::channel(None);
let inner = RefreshTokenSourceTask {
name,
watch_tx,
refresh_retry_delay,
refresh_threshold,
min_token_lifetime,
token_refresher: Box::new(token_refresher),
initial_token,
};
let task_handle = inner.run();
Self {
watch_rx,
task_handle,
}
}
}
#[async_trait]
impl TokenSource for RefreshTokenSource {
fn watch(&self) -> watch::Receiver<Option<Result<String, TokenSourceError>>> {
self.watch_rx.clone()
}
}
#[derive(Clone, Debug)]
pub struct TokenWithExpiry {
pub token: String,
pub expires_at: Instant,
}
struct RefreshingTokenSourceTaskHandle {
handle: JoinHandle<()>,
}
impl Drop for RefreshingTokenSourceTaskHandle {
fn drop(&mut self) {
self.handle.abort();
}
}
struct RefreshTokenSourceTask {
name: String,
watch_tx: watch::Sender<Option<Result<String, TokenSourceError>>>,
refresh_retry_delay: Duration,
refresh_threshold: Duration,
#[allow(clippy::type_complexity)]
token_refresher: Box<dyn TokenRefresher>,
min_token_lifetime: Duration,
initial_token: Option<TokenWithExpiry>,
}
impl RefreshTokenSourceTask {
fn run(self) -> RefreshingTokenSourceTaskHandle {
let handle = tokio::spawn(async move {
let mut fail_count = 0;
let mut current_token: Option<TokenWithExpiry> = if let Some(tok) = self.initial_token {
let token_ttl_secs = tok
.expires_at
.saturating_duration_since(Instant::now())
.as_secs();
tracing::debug!(
name = %self.name,
token_ttl_secs,
"Published initial token without calling refresh"
);
self.watch_tx.send_replace(Some(Ok(tok.token.clone())));
Some(tok)
} else {
None
};
loop {
let token_expiry = match current_token {
Some(ref token) => token.expires_at,
_ => Instant::now(),
};
let refresh_deadline = token_expiry
.checked_sub(self.refresh_threshold)
.unwrap_or_else(Instant::now);
tokio::time::sleep_until(refresh_deadline.into()).await;
let new_token = self.token_refresher.refresh().await;
match new_token {
Ok(token) => {
let token_ttl_secs = token
.expires_at
.saturating_duration_since(Instant::now())
.as_secs();
if token.expires_at <= Instant::now() + self.min_token_lifetime {
tracing::error!(
name = %self.name,
token_ttl_secs,
"Refreshed token is already expired or too close to expiry, ignoring"
);
tokio::time::sleep(self.refresh_retry_delay).await;
continue;
}
fail_count = 0;
tracing::info!(
name = %self.name,
token_ttl_secs,
"Refreshed token"
);
current_token = Some(token.clone());
self.watch_tx.send_replace(Some(Ok(token.token)));
}
Err(e) => {
fail_count += 1;
tracing::error!(
name = %self.name,
ttl_secs = token_expiry.saturating_duration_since(Instant::now()).as_secs(),
retry_secs = self.refresh_retry_delay.as_secs(),
fail_count,
error = %e,
"Failed to refresh token"
);
if token_expiry <= Instant::now() + self.min_token_lifetime {
current_token = None;
self.watch_tx.send_replace(Some(Err(e)));
}
tokio::time::sleep(self.refresh_retry_delay).await;
continue;
}
}
}
});
RefreshingTokenSourceTaskHandle { handle }
}
}
#[async_trait]
pub trait TokenRefresher: Send + Sync + 'static {
async fn refresh(&self) -> Result<TokenWithExpiry, TokenSourceError>;
}
#[async_trait]
impl<AsyncFn, FnFuture> TokenRefresher for AsyncFn
where
AsyncFn: Fn() -> FnFuture + Send + Sync + 'static,
FnFuture: Future<Output = Result<TokenWithExpiry, TokenSourceError>> + Send,
{
async fn refresh(&self) -> Result<TokenWithExpiry, TokenSourceError> {
(self)().await
}
}
#[cfg(test)]
mod tests {
use std::{
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use tokio::sync::Notify;
use super::*;
#[tokio::test]
async fn initial_token_is_published_without_calling_refresh() {
let refresh_count = Arc::new(AtomicUsize::new(0));
let refresh_count_clone = Arc::clone(&refresh_count);
let source = RefreshTokenSource::builder("test", move || {
refresh_count_clone.fetch_add(1, Ordering::SeqCst);
let token = TokenWithExpiry {
token: "refreshed-token".to_string(),
expires_at: Instant::now() + Duration::from_secs(3600),
};
async move { Ok::<_, TokenSourceError>(token) }
})
.with_initial_token(TokenWithExpiry {
token: "initial-token".to_string(),
expires_at: Instant::now() + Duration::from_secs(3600),
})
.refresh_threshold(Duration::from_secs(60))
.build();
tokio::task::yield_now().await;
let mut rx = source.watch();
let borrow = rx.borrow_and_update();
match borrow.as_ref() {
Some(Ok(token)) => assert_eq!(token, "initial-token"),
other => panic!("expected initial token, got {other:?}"),
}
drop(borrow);
assert_eq!(
refresh_count.load(Ordering::SeqCst),
0,
"refresh() should not be called when an initial token is provided"
);
}
#[tokio::test]
async fn initial_token_expiry_triggers_refresh() {
let notify = Arc::new(Notify::new());
let notify_clone = Arc::clone(¬ify);
let _source = RefreshTokenSource::builder("test", move || {
notify_clone.notify_one();
let token = TokenWithExpiry {
token: "refreshed-token".to_string(),
expires_at: Instant::now() + Duration::from_secs(3600),
};
async move { Ok::<_, TokenSourceError>(token) }
})
.with_initial_token(TokenWithExpiry {
token: "initial-token".to_string(),
expires_at: Instant::now() + Duration::from_millis(10),
})
.refresh_threshold(Duration::ZERO)
.build();
tokio::task::yield_now().await;
tokio::time::timeout(Duration::from_millis(500), notify.notified())
.await
.expect("refresh() should be called after the initial token expires");
let token = tokio::time::timeout(Duration::from_millis(500), _source.get_token())
.await
.expect("get_token() should not timeout")
.expect("get_token() should succeed");
assert_eq!(token, "refreshed-token");
}
}