use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use tower::{Layer, Service};
use super::cache::{CacheStore, CachedResponse, InMemoryStore, strategy_key};
use super::types::{LlmRequest, LlmResponse};
use crate::cache_key::{CacheKeyStrategy, ExactHashStrategy};
use crate::client::BoxFuture;
use crate::error::{LiterLlmError, Result};
#[cfg_attr(alef, alef(skip))]
pub trait NegativeCachePolicy: Send + Sync + 'static {
fn cache_for(&self, error: &LiterLlmError) -> Option<Duration>;
}
#[cfg_attr(alef, alef(skip))]
pub struct FixedWindowNegativeCache {
window: Duration,
retryable_only: bool,
}
impl FixedWindowNegativeCache {
#[must_use]
pub fn new(window: Duration, retryable_only: bool) -> Self {
Self { window, retryable_only }
}
}
impl Default for FixedWindowNegativeCache {
fn default() -> Self {
Self {
window: Duration::from_secs(5),
retryable_only: true,
}
}
}
impl NegativeCachePolicy for FixedWindowNegativeCache {
fn cache_for(&self, error: &LiterLlmError) -> Option<Duration> {
let eligible = if self.retryable_only {
error.is_transient()
} else {
true
};
eligible.then_some(self.window)
}
}
#[cfg_attr(alef, alef(skip))]
pub struct NegativeCacheLayer<P: NegativeCachePolicy = FixedWindowNegativeCache> {
store: Arc<dyn CacheStore>,
policy: Arc<P>,
key_strategy: Arc<dyn CacheKeyStrategy>,
}
impl NegativeCacheLayer<FixedWindowNegativeCache> {
#[must_use]
pub fn default_in_memory() -> Self {
use crate::tower::cache::CacheConfig;
Self {
store: Arc::new(InMemoryStore::new(&CacheConfig::default())),
policy: Arc::new(FixedWindowNegativeCache::default()),
key_strategy: Arc::new(ExactHashStrategy),
}
}
}
impl Default for NegativeCacheLayer<FixedWindowNegativeCache> {
fn default() -> Self {
Self::default_in_memory()
}
}
impl<P: NegativeCachePolicy> NegativeCacheLayer<P> {
#[must_use]
pub fn new(store: Arc<dyn CacheStore>, policy: Arc<P>) -> Self {
Self {
store,
policy,
key_strategy: Arc::new(ExactHashStrategy),
}
}
#[must_use]
pub fn with_key_strategy(mut self, strategy: Arc<dyn CacheKeyStrategy>) -> Self {
self.key_strategy = strategy;
self
}
}
impl<P: NegativeCachePolicy, S> Layer<S> for NegativeCacheLayer<P> {
type Service = NegativeCacheService<P, S>;
fn layer(&self, inner: S) -> Self::Service {
NegativeCacheService {
store: Arc::clone(&self.store),
policy: Arc::clone(&self.policy),
key_strategy: Arc::clone(&self.key_strategy),
inner,
}
}
}
#[cfg_attr(alef, alef(skip))]
pub struct NegativeCacheService<P: NegativeCachePolicy, S> {
store: Arc<dyn CacheStore>,
policy: Arc<P>,
key_strategy: Arc<dyn CacheKeyStrategy>,
inner: S,
}
impl<P: NegativeCachePolicy, S: Clone> Clone for NegativeCacheService<P, S> {
fn clone(&self) -> Self {
Self {
store: Arc::clone(&self.store),
policy: Arc::clone(&self.policy),
key_strategy: Arc::clone(&self.key_strategy),
inner: self.inner.clone(),
}
}
}
impl<P, S> Service<LlmRequest> for NegativeCacheService<P, S>
where
P: NegativeCachePolicy,
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Send + 'static,
S::Future: Send + 'static,
{
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = BoxFuture<'static, Result<LlmResponse>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: LlmRequest) -> Self::Future {
let key_and_body = strategy_key(self.key_strategy.as_ref(), &req);
let store = Arc::clone(&self.store);
let policy = Arc::clone(&self.policy);
let fut = self.inner.call(req);
Box::pin(async move {
let result = fut.await;
if let Err(ref err) = result
&& let Some(window) = policy.cache_for(err)
&& let Some((key, body, _tenant_id)) = key_and_body
{
let already_cached = matches!(store.get(key, &body).await, Some(CachedResponse::Error { .. }));
if !already_cached {
let expires_at = Instant::now() + window;
let cached_err = CachedResponse::Error {
error: Arc::new(err.to_singleflight_error()),
expires_at,
};
store.put(key, body, cached_err).await;
}
}
result
})
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt as _;
use super::*;
use crate::tower::cache::{CacheConfig, CacheLayer, InMemoryStore};
use crate::tower::service::LlmService;
use crate::tower::tests_common::{MockClient, chat_req};
use crate::tower::types::LlmRequest;
fn build_stack(
client: MockClient,
policy: FixedWindowNegativeCache,
) -> (
Arc<InMemoryStore>,
impl Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError>,
) {
let store = Arc::new(InMemoryStore::new(&CacheConfig {
max_entries: 64,
ttl: Duration::from_secs(60),
..Default::default()
}));
let cache_layer = CacheLayer::with_store(Arc::clone(&store) as Arc<dyn CacheStore>);
let neg_layer = NegativeCacheLayer::new(Arc::clone(&store) as Arc<dyn CacheStore>, Arc::new(policy));
let inner = LlmService::new(client);
let svc = neg_layer.layer(cache_layer.layer(inner));
(store, svc)
}
#[tokio::test]
async fn negative_cache_skips_non_transient_errors_by_default() {
let client = MockClient::failing_auth();
let policy = FixedWindowNegativeCache::default();
let (store, mut svc) = build_stack(client, policy);
let req = LlmRequest::Chat(chat_req("gpt-4"));
let _ = svc.call(req).await;
let hit = store.get(0, "").await;
assert!(hit.is_none(), "non-transient error must not be cached");
let (key, body, _tenant_id) =
strategy_key(&ExactHashStrategy, &LlmRequest::Chat(chat_req("gpt-4"))).expect("chat request is cacheable");
let hit = store.get(key, &body).await;
assert!(hit.is_none(), "non-transient error must not be stored");
}
#[tokio::test]
async fn negative_cache_stores_rate_limited_for_window() {
let client = MockClient::failing_rate_limited();
let policy = FixedWindowNegativeCache::new(Duration::from_secs(30), true);
let (store, mut svc) = build_stack(client, policy);
let req_body = chat_req("gpt-4");
let first = svc
.ready()
.await
.unwrap()
.call(LlmRequest::Chat(req_body.clone()))
.await;
assert!(first.is_err(), "first call should propagate the upstream error");
let (key, serialized, _tenant_id) =
strategy_key(&ExactHashStrategy, &LlmRequest::Chat(req_body.clone())).expect("chat request is cacheable");
let cached = store.get(key, &serialized).await;
assert!(cached.is_some(), "RateLimited error must be written to store");
assert!(
matches!(cached.unwrap(), CachedResponse::Error { .. }),
"stored entry must be CachedResponse::Error"
);
let second = svc.ready().await.unwrap().call(LlmRequest::Chat(req_body)).await;
assert!(second.is_err(), "second call must also return an error (cached)");
}
#[tokio::test]
async fn negative_cache_returns_to_normal_after_window() {
let client = MockClient::failing_rate_limited();
let call_count = Arc::clone(&client.call_count);
let policy = FixedWindowNegativeCache::new(Duration::from_millis(50), true);
let (_, mut svc) = build_stack(client, policy);
let req_body = chat_req("gpt-4");
let _ = svc
.ready()
.await
.unwrap()
.call(LlmRequest::Chat(req_body.clone()))
.await;
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
tokio::time::sleep(Duration::from_millis(100)).await;
let _ = svc.ready().await.unwrap().call(LlmRequest::Chat(req_body)).await;
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
2,
"after negative-cache window, inner must be called again"
);
}
#[tokio::test]
async fn negative_cache_replay_preserves_the_error_variant_and_retry_after() {
let client = MockClient::failing_rate_limited();
let policy = FixedWindowNegativeCache::new(Duration::from_secs(30), true);
let (_, mut svc) = build_stack(client, policy);
let req_body = chat_req("gpt-4");
let first = svc
.ready()
.await
.unwrap()
.call(LlmRequest::Chat(req_body.clone()))
.await;
assert!(first.is_err(), "first call propagates the upstream error");
let replayed = svc.ready().await.unwrap().call(LlmRequest::Chat(req_body)).await;
let err = replayed.expect_err("replay must still be an error");
assert!(
matches!(err, LiterLlmError::RateLimited { .. }),
"replayed error must preserve the RateLimited variant, got: {err:?}"
);
assert!(
err.is_transient(),
"a replayed RateLimited error must still report as transient so callers keep retrying"
);
}
#[tokio::test]
async fn negative_cache_replay_does_not_refresh_the_window() {
let client = MockClient::failing_rate_limited();
let call_count = Arc::clone(&client.call_count);
let policy = FixedWindowNegativeCache::new(Duration::from_millis(60), true);
let (_, mut svc) = build_stack(client, policy);
let req_body = chat_req("gpt-4");
let first = svc
.ready()
.await
.unwrap()
.call(LlmRequest::Chat(req_body.clone()))
.await;
assert!(first.is_err());
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
tokio::time::sleep(Duration::from_millis(20)).await;
let replay_1 = svc
.ready()
.await
.unwrap()
.call(LlmRequest::Chat(req_body.clone()))
.await;
assert!(
replay_1.is_err(),
"replay well within the window must still be an error"
);
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"replay at ~20ms (window is 60ms) must be served from the negative cache, not upstream"
);
tokio::time::sleep(Duration::from_millis(20)).await;
let replay_2 = svc
.ready()
.await
.unwrap()
.call(LlmRequest::Chat(req_body.clone()))
.await;
assert!(
replay_2.is_err(),
"second replay still within the window must still be an error"
);
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"replay at ~40ms elapsed must still be served from the negative cache"
);
tokio::time::sleep(Duration::from_millis(35)).await;
let _ = svc.ready().await.unwrap().call(LlmRequest::Chat(req_body)).await;
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
2,
"upstream must be contacted again once the ORIGINAL 60ms window elapses (~120ms total \
elapsed here); if replays had refreshed expires_at on every poll, this would still be 1 \
and the entry would never expire under continuous polling"
);
}
#[tokio::test]
async fn negative_cache_round_trip_short_circuits_upstream_before_window_elapses() {
let client = MockClient::failing_rate_limited();
let call_count = Arc::clone(&client.call_count);
let policy = FixedWindowNegativeCache::new(Duration::from_secs(30), true);
let (_, mut svc) = build_stack(client, policy);
let req_body = chat_req("gpt-4");
let first = svc
.ready()
.await
.unwrap()
.call(LlmRequest::Chat(req_body.clone()))
.await;
assert!(first.is_err(), "first call propagates the upstream error");
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
let second = svc.ready().await.unwrap().call(LlmRequest::Chat(req_body)).await;
assert!(
second.is_err(),
"second call must also return an error (served from negative cache)"
);
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"second call within the negative-cache window must be served from the cache, \
not re-dispatched to upstream — this fails if the write-path and read-path keys disagree"
);
}
}