use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::Duration;
use super::account::SeatIdentity;
use super::broker::BrokerError;
use super::quota_policy::ObservationFailure;
use super::usage::{UsageError, UsageSnapshot};
pub const NEGATIVE_BACKOFF_START: Duration = Duration::from_secs(2 * 60);
pub const NEGATIVE_BACKOFF_CAP: Duration = Duration::from_secs(10 * 60);
pub const USAGE_READ_GRACE: Duration = Duration::from_secs(30 * 60);
pub const USAGE_READ_TARGET: &str = "agent_core::auth::usage_read";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransientReadFailure {
pub kind: &'static str,
pub status: Option<u16>,
pub retry_after_s: Option<u64>,
error: UsageError,
}
impl TransientReadFailure {
pub fn from_usage_error(e: &UsageError) -> Option<Self> {
let (status, retry_after_s) = match e {
UsageError::RateLimited { retry_after_s } => (Some(429), *retry_after_s),
UsageError::UpstreamStatus {
status,
retry_after_s,
} if *status >= 500 => (Some(*status), *retry_after_s),
UsageError::Timeout | UsageError::Transport { .. } => (None, None),
_ => return None,
};
Some(Self {
kind: e.kind(),
status,
retry_after_s,
error: e.clone(),
})
}
pub fn is_rate_limited(&self) -> bool {
self.status == Some(429)
}
pub fn to_broker_error(&self) -> BrokerError {
self.error.clone().into_broker_error()
}
pub fn cause(&self) -> ObservationFailure {
self.error.failure()
}
}
#[allow(clippy::large_enum_variant)]
pub enum Fetched {
Ok(UsageSnapshot, SeatIdentity),
Usage(UsageError),
Failed {
error: BrokerError,
cause: ObservationFailure,
},
}
#[derive(Debug, Clone)]
pub enum ReadError {
Transient {
failure: TransientReadFailure,
retry_at_ms: u64,
},
Other {
error: BrokerError,
cause: ObservationFailure,
},
}
impl ReadError {
pub fn to_broker_error(&self) -> BrokerError {
match self {
Self::Transient { failure, .. } => failure.to_broker_error(),
Self::Other { error, .. } => error.clone(),
}
}
pub fn cause(&self) -> ObservationFailure {
match self {
Self::Transient { failure, .. } => failure.cause(),
Self::Other { cause, .. } => cause.clone(),
}
}
}
pub type ReadResult = Result<(UsageSnapshot, SeatIdentity), ReadError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadPath {
Network,
Joined,
NegativeCacheHit,
}
#[derive(Debug, Clone)]
struct Hold {
failure: TransientReadFailure,
until_ms: u64,
backoff_ms: u64,
}
#[derive(Default)]
struct Entry {
generation: u64,
last: Option<ReadResult>,
hold: Option<Hold>,
last_good: Option<(UsageSnapshot, SeatIdentity)>,
}
fn same_account(read_from: &SeatIdentity, now: &SeatIdentity) -> bool {
match (read_from.seat_fingerprint(), now.seat_fingerprint()) {
(Some(a), Some(b)) => a == b,
(None, None) => read_from == now,
_ => false,
}
}
#[derive(Default)]
pub struct UsageReads {
entries: Mutex<HashMap<String, Entry>>,
gates: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
}
pub fn process_table() -> Arc<UsageReads> {
static TABLE: OnceLock<Arc<UsageReads>> = OnceLock::new();
TABLE.get_or_init(|| Arc::new(UsageReads::default())).clone()
}
fn ms(d: Duration) -> u64 {
d.as_millis() as u64
}
impl UsageReads {
fn entries(&self) -> MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn held_at(&self, key: &str, now_ms: u64) -> Option<(TransientReadFailure, u64)> {
self.entries()
.get(key)
.and_then(|e| e.hold.as_ref())
.filter(|h| h.until_ms > now_ms)
.map(|h| (h.failure.clone(), h.until_ms))
}
#[allow(clippy::result_large_err)]
pub fn record_at(&self, key: &str, fetched: Fetched, now_ms: u64) -> ReadResult {
let mut entries = self.entries();
let entry = entries.entry(key.to_string()).or_default();
let result = match fetched {
Fetched::Ok(snapshot, seat) => {
entry.hold = None;
entry.last_good = Some((snapshot.clone(), seat.clone()));
Ok((snapshot, seat))
}
Fetched::Usage(e) => match TransientReadFailure::from_usage_error(&e) {
Some(failure) => {
let cap = ms(NEGATIVE_BACKOFF_CAP);
let backoff_ms = match &entry.hold {
Some(h) => h.backoff_ms.saturating_mul(2).min(cap),
None => ms(NEGATIVE_BACKOFF_START),
};
let asked = failure.retry_after_s.map_or(0, |s| s.saturating_mul(1000));
let until_ms = now_ms.saturating_add(backoff_ms.max(asked).min(cap));
entry.hold = Some(Hold {
failure: failure.clone(),
until_ms,
backoff_ms,
});
Err(ReadError::Transient {
failure,
retry_at_ms: until_ms,
})
}
None => {
if e.is_auth_failure() {
entry.last_good = None;
}
entry.hold = None;
Err(ReadError::Other {
cause: e.failure(),
error: e.into_broker_error(),
})
}
},
Fetched::Failed { error, cause } => {
if cause.kind.needs_login() {
entry.last_good = None;
}
entry.hold = None;
Err(ReadError::Other { error, cause })
}
};
entry.generation = entry.generation.wrapping_add(1);
entry.last = Some(result.clone());
result
}
pub fn last_good_for(&self, key: &str, current: &SeatIdentity) -> Option<UsageSnapshot> {
self.entries()
.get(key)
.and_then(|e| e.last_good.as_ref())
.filter(|(_, read_from)| same_account(read_from, current))
.map(|(snapshot, _)| snapshot.clone())
}
pub fn forget_last_good(&self, key: &str) {
if let Some(e) = self.entries().get_mut(key) {
e.last_good = None;
}
}
pub fn forget(&self, key: &str) {
if let Some(e) = self.entries().get_mut(key) {
e.hold = None;
e.last_good = None;
}
}
fn generation(&self, key: &str) -> u64 {
self.entries().get(key).map_or(0, |e| e.generation)
}
fn gate(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
self.gates
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(key.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub async fn read<F, Fut>(&self, key: &str, fetch: F) -> (ReadResult, ReadPath)
where
F: FnOnce() -> Fut,
Fut: Future<Output = Fetched>,
{
if let Some((failure, retry_at_ms)) = self.held_at(key, crate::epoch_millis()) {
return (
Err(ReadError::Transient {
failure,
retry_at_ms,
}),
ReadPath::NegativeCacheHit,
);
}
let seen = self.generation(key);
let gate = self.gate(key);
let _flight = gate.lock().await;
let joined = self
.entries()
.get(key)
.filter(|e| e.generation != seen)
.and_then(|e| e.last.clone());
if let Some(result) = joined {
return (result, ReadPath::Joined);
}
let fetched = fetch().await;
(
self.record_at(key, fetched, crate::epoch_millis()),
ReadPath::Network,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MIN: u64 = 60_000;
fn limited(retry_after_s: Option<u64>) -> Fetched {
Fetched::Usage(UsageError::RateLimited { retry_after_s })
}
fn held_for(t: &UsageReads, fetched: Fetched, now: u64) -> u64 {
match t.record_at("k", fetched, now) {
Err(ReadError::Transient { retry_at_ms, .. }) => {
assert_eq!(
t.held_at("k", now + 1).map(|(_, until)| until),
Some(retry_at_ms),
"a read inside the hold is answered from the table"
);
assert!(t.held_at("k", retry_at_ms).is_none(), "the hold ends on time");
retry_at_ms - now
}
other => panic!("expected a held failure, got {other:?}"),
}
}
fn seat(refresh: &str, account_id: Option<&str>) -> SeatIdentity {
let creds = super::super::OAuthCredentials {
auth_type: "oauth".into(),
refresh: refresh.into(),
access: "a".into(),
expires: 0,
account_id: account_id.map(Into::into),
};
SeatIdentity::of(super::super::OAuthProviderId::Anthropic, &creds)
}
fn good_from(read_from: SeatIdentity) -> Fetched {
let snapshot = super::super::usage::parse_anthropic_usage(
r#"{"seven_day":{"utilization":10,"resets_at":"2099-01-01T00:00:00Z"}}"#,
"a",
1,
)
.unwrap();
Fetched::Ok(snapshot, read_from)
}
fn good() -> Fetched {
good_from(seat("r", None))
}
#[test]
fn failed_reads_back_off_two_four_eight_ten_minutes_and_a_success_clears() {
let t = UsageReads::default();
let mut now = 1_000_000;
let mut holds = Vec::new();
for _ in 0..5 {
let held = held_for(&t, limited(None), now);
holds.push(held / MIN);
now += held;
}
assert_eq!(holds, vec![2, 4, 8, 10, 10]);
assert!(t.record_at("k", good(), now).is_ok());
assert!(t.held_at("k", now).is_none(), "a success clears the hold");
assert!(t.last_good_for("k", &seat("r", None)).is_some());
assert_eq!(held_for(&t, limited(None), now), 2 * MIN, "and restarts the backoff");
let t = UsageReads::default();
assert_eq!(held_for(&t, limited(Some(300)), now), 5 * MIN);
assert_eq!(held_for(&t, limited(Some(3600)), now + 5 * MIN), 10 * MIN);
let t = UsageReads::default();
assert_eq!(held_for(&t, limited(Some(30)), now), 2 * MIN, "a shorter one does not");
for e in [
UsageError::UpstreamStatus {
status: 503,
retry_after_s: None,
},
UsageError::Timeout,
UsageError::Transport { detail: "connect".into() },
] {
let t = UsageReads::default();
assert_eq!(held_for(&t, Fetched::Usage(e), now), 2 * MIN);
}
let t = UsageReads::default();
let slow = UsageError::UpstreamStatus {
status: 503,
retry_after_s: Some(300),
};
assert_eq!(held_for(&t, Fetched::Usage(slow), now), 5 * MIN);
let t = UsageReads::default();
assert!(t.record_at("k", good(), now).is_ok());
for e in [
UsageError::Unauthorized { status: 401 },
UsageError::UpstreamStatus {
status: 404,
retry_after_s: None,
},
UsageError::Malformed { detail: "x".into() },
] {
assert!(matches!(
t.record_at("k", Fetched::Usage(e), now),
Err(ReadError::Other { .. })
));
assert!(t.held_at("k", now).is_none());
}
assert!(
t.last_good_for("k", &seat("r", None)).is_none(),
"the 401 dropped the last good reading"
);
}
#[test]
fn last_good_counts_only_for_the_account_it_was_read_from() {
let t = UsageReads::default();
assert!(t.record_at("k", good_from(seat("r1", Some("acct-1"))), 1).is_ok());
assert!(t.last_good_for("k", &seat("r1", Some("acct-1"))).is_some());
assert!(
t.last_good_for("k", &seat("r2-rotated", Some("acct-1"))).is_some(),
"a refresh rotation keeps the account"
);
assert!(
t.last_good_for("k", &seat("r1", Some("acct-2"))).is_none(),
"another account in the slot is never graced on this reading"
);
assert!(t.last_good_for("k", &seat("r1", None)).is_none());
assert!(t.last_good_for("other", &seat("r1", Some("acct-1"))).is_none());
let t = UsageReads::default();
assert!(t.record_at("k", good_from(seat("r1", None)), 1).is_ok());
assert!(t.last_good_for("k", &seat("r1", None)).is_some());
assert!(
t.last_good_for("k", &seat("r2", None)).is_none(),
"without ids, other refresh material may be another account"
);
assert!(t.last_good_for("k", &seat("r1", Some("acct-1"))).is_none());
}
}