1use std::collections::HashMap;
32use std::future::Future;
33use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
34use std::time::Duration;
35
36use super::account::SeatIdentity;
37use super::broker::BrokerError;
38use super::quota_policy::ObservationFailure;
39use super::usage::{UsageError, UsageSnapshot};
40
41pub const NEGATIVE_BACKOFF_START: Duration = Duration::from_secs(2 * 60);
43pub const NEGATIVE_BACKOFF_CAP: Duration = Duration::from_secs(10 * 60);
45pub const USAGE_READ_GRACE: Duration = Duration::from_secs(30 * 60);
49pub const USAGE_READ_TARGET: &str = "agent_core::auth::usage_read";
51
52#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct TransientReadFailure {
56 pub kind: &'static str,
59 pub status: Option<u16>,
60 pub retry_after_s: Option<u64>,
61 error: UsageError,
64}
65
66impl TransientReadFailure {
67 pub fn from_usage_error(e: &UsageError) -> Option<Self> {
69 let (status, retry_after_s) = match e {
70 UsageError::RateLimited { retry_after_s } => (Some(429), *retry_after_s),
71 UsageError::UpstreamStatus {
72 status,
73 retry_after_s,
74 } if *status >= 500 => (Some(*status), *retry_after_s),
75 UsageError::Timeout | UsageError::Transport { .. } => (None, None),
76 _ => return None,
77 };
78 Some(Self {
79 kind: e.kind(),
80 status,
81 retry_after_s,
82 error: e.clone(),
83 })
84 }
85
86 pub fn is_rate_limited(&self) -> bool {
87 self.status == Some(429)
88 }
89
90 pub fn to_broker_error(&self) -> BrokerError {
92 self.error.clone().into_broker_error()
93 }
94
95 pub fn cause(&self) -> ObservationFailure {
97 self.error.failure()
98 }
99}
100
101#[allow(clippy::large_enum_variant)]
105pub enum Fetched {
106 Ok(UsageSnapshot, SeatIdentity),
107 Usage(UsageError),
109 Failed {
112 error: BrokerError,
113 cause: ObservationFailure,
114 },
115}
116
117#[derive(Debug, Clone)]
118pub enum ReadError {
119 Transient {
121 failure: TransientReadFailure,
122 retry_at_ms: u64,
123 },
124 Other {
125 error: BrokerError,
126 cause: ObservationFailure,
127 },
128}
129
130impl ReadError {
131 pub fn to_broker_error(&self) -> BrokerError {
132 match self {
133 Self::Transient { failure, .. } => failure.to_broker_error(),
134 Self::Other { error, .. } => error.clone(),
135 }
136 }
137
138 pub fn cause(&self) -> ObservationFailure {
140 match self {
141 Self::Transient { failure, .. } => failure.cause(),
142 Self::Other { cause, .. } => cause.clone(),
143 }
144 }
145}
146
147pub type ReadResult = Result<(UsageSnapshot, SeatIdentity), ReadError>;
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum ReadPath {
152 Network,
153 Joined,
154 NegativeCacheHit,
155}
156
157#[derive(Debug, Clone)]
158struct Hold {
159 failure: TransientReadFailure,
160 until_ms: u64,
161 backoff_ms: u64,
162}
163
164#[derive(Default)]
165struct Entry {
166 generation: u64,
168 last: Option<ReadResult>,
169 hold: Option<Hold>,
170 last_good: Option<(UsageSnapshot, SeatIdentity)>,
172}
173
174fn same_account(read_from: &SeatIdentity, now: &SeatIdentity) -> bool {
179 match (read_from.seat_fingerprint(), now.seat_fingerprint()) {
180 (Some(a), Some(b)) => a == b,
181 (None, None) => read_from == now,
182 _ => false,
183 }
184}
185
186#[derive(Default)]
188pub struct UsageReads {
189 entries: Mutex<HashMap<String, Entry>>,
190 gates: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
191}
192
193pub fn process_table() -> Arc<UsageReads> {
195 static TABLE: OnceLock<Arc<UsageReads>> = OnceLock::new();
196 TABLE.get_or_init(|| Arc::new(UsageReads::default())).clone()
197}
198
199fn ms(d: Duration) -> u64 {
200 d.as_millis() as u64
201}
202
203impl UsageReads {
204 fn entries(&self) -> MutexGuard<'_, HashMap<String, Entry>> {
205 self.entries.lock().unwrap_or_else(|e| e.into_inner())
206 }
207
208 pub fn held_at(&self, key: &str, now_ms: u64) -> Option<(TransientReadFailure, u64)> {
210 self.entries()
211 .get(key)
212 .and_then(|e| e.hold.as_ref())
213 .filter(|h| h.until_ms > now_ms)
214 .map(|h| (h.failure.clone(), h.until_ms))
215 }
216
217 #[allow(clippy::result_large_err)]
221 pub fn record_at(&self, key: &str, fetched: Fetched, now_ms: u64) -> ReadResult {
222 let mut entries = self.entries();
223 let entry = entries.entry(key.to_string()).or_default();
224 let result = match fetched {
225 Fetched::Ok(snapshot, seat) => {
226 entry.hold = None;
227 entry.last_good = Some((snapshot.clone(), seat.clone()));
228 Ok((snapshot, seat))
229 }
230 Fetched::Usage(e) => match TransientReadFailure::from_usage_error(&e) {
231 Some(failure) => {
232 let cap = ms(NEGATIVE_BACKOFF_CAP);
233 let backoff_ms = match &entry.hold {
234 Some(h) => h.backoff_ms.saturating_mul(2).min(cap),
235 None => ms(NEGATIVE_BACKOFF_START),
236 };
237 let asked = failure.retry_after_s.map_or(0, |s| s.saturating_mul(1000));
238 let until_ms = now_ms.saturating_add(backoff_ms.max(asked).min(cap));
239 entry.hold = Some(Hold {
240 failure: failure.clone(),
241 until_ms,
242 backoff_ms,
243 });
244 Err(ReadError::Transient {
245 failure,
246 retry_at_ms: until_ms,
247 })
248 }
249 None => {
250 if e.is_auth_failure() {
251 entry.last_good = None;
252 }
253 entry.hold = None;
254 Err(ReadError::Other {
255 cause: e.failure(),
256 error: e.into_broker_error(),
257 })
258 }
259 },
260 Fetched::Failed { error, cause } => {
261 if cause.kind.needs_login() {
263 entry.last_good = None;
264 }
265 entry.hold = None;
266 Err(ReadError::Other { error, cause })
267 }
268 };
269 entry.generation = entry.generation.wrapping_add(1);
270 entry.last = Some(result.clone());
271 result
272 }
273
274 pub fn last_good_for(&self, key: &str, current: &SeatIdentity) -> Option<UsageSnapshot> {
277 self.entries()
278 .get(key)
279 .and_then(|e| e.last_good.as_ref())
280 .filter(|(_, read_from)| same_account(read_from, current))
281 .map(|(snapshot, _)| snapshot.clone())
282 }
283
284 pub fn forget_last_good(&self, key: &str) {
286 if let Some(e) = self.entries().get_mut(key) {
287 e.last_good = None;
288 }
289 }
290
291 pub fn forget(&self, key: &str) {
296 if let Some(e) = self.entries().get_mut(key) {
297 e.hold = None;
298 e.last_good = None;
299 }
300 }
301
302 fn generation(&self, key: &str) -> u64 {
303 self.entries().get(key).map_or(0, |e| e.generation)
304 }
305
306 fn gate(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
307 self.gates
308 .lock()
309 .unwrap_or_else(|e| e.into_inner())
310 .entry(key.to_string())
311 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
312 .clone()
313 }
314
315 pub async fn read<F, Fut>(&self, key: &str, fetch: F) -> (ReadResult, ReadPath)
319 where
320 F: FnOnce() -> Fut,
321 Fut: Future<Output = Fetched>,
322 {
323 if let Some((failure, retry_at_ms)) = self.held_at(key, crate::epoch_millis()) {
324 return (
325 Err(ReadError::Transient {
326 failure,
327 retry_at_ms,
328 }),
329 ReadPath::NegativeCacheHit,
330 );
331 }
332 let seen = self.generation(key);
333 let gate = self.gate(key);
334 let _flight = gate.lock().await;
335 let joined = self
336 .entries()
337 .get(key)
338 .filter(|e| e.generation != seen)
339 .and_then(|e| e.last.clone());
340 if let Some(result) = joined {
341 return (result, ReadPath::Joined);
342 }
343 let fetched = fetch().await;
344 (
345 self.record_at(key, fetched, crate::epoch_millis()),
346 ReadPath::Network,
347 )
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 const MIN: u64 = 60_000;
356
357 fn limited(retry_after_s: Option<u64>) -> Fetched {
358 Fetched::Usage(UsageError::RateLimited { retry_after_s })
359 }
360
361 fn held_for(t: &UsageReads, fetched: Fetched, now: u64) -> u64 {
362 match t.record_at("k", fetched, now) {
363 Err(ReadError::Transient { retry_at_ms, .. }) => {
364 assert_eq!(
365 t.held_at("k", now + 1).map(|(_, until)| until),
366 Some(retry_at_ms),
367 "a read inside the hold is answered from the table"
368 );
369 assert!(t.held_at("k", retry_at_ms).is_none(), "the hold ends on time");
370 retry_at_ms - now
371 }
372 other => panic!("expected a held failure, got {other:?}"),
373 }
374 }
375
376 fn seat(refresh: &str, account_id: Option<&str>) -> SeatIdentity {
377 let creds = super::super::OAuthCredentials {
378 auth_type: "oauth".into(),
379 refresh: refresh.into(),
380 access: "a".into(),
381 expires: 0,
382 account_id: account_id.map(Into::into),
383 };
384 SeatIdentity::of(super::super::OAuthProviderId::Anthropic, &creds)
385 }
386
387 fn good_from(read_from: SeatIdentity) -> Fetched {
388 let snapshot = super::super::usage::parse_anthropic_usage(
389 r#"{"seven_day":{"utilization":10,"resets_at":"2099-01-01T00:00:00Z"}}"#,
390 "a",
391 1,
392 )
393 .unwrap();
394 Fetched::Ok(snapshot, read_from)
395 }
396
397 fn good() -> Fetched {
398 good_from(seat("r", None))
399 }
400
401 #[test]
402 fn failed_reads_back_off_two_four_eight_ten_minutes_and_a_success_clears() {
403 let t = UsageReads::default();
404 let mut now = 1_000_000;
405 let mut holds = Vec::new();
406 for _ in 0..5 {
407 let held = held_for(&t, limited(None), now);
408 holds.push(held / MIN);
409 now += held;
410 }
411 assert_eq!(holds, vec![2, 4, 8, 10, 10]);
412 assert!(t.record_at("k", good(), now).is_ok());
413 assert!(t.held_at("k", now).is_none(), "a success clears the hold");
414 assert!(t.last_good_for("k", &seat("r", None)).is_some());
415 assert_eq!(held_for(&t, limited(None), now), 2 * MIN, "and restarts the backoff");
416
417 let t = UsageReads::default();
419 assert_eq!(held_for(&t, limited(Some(300)), now), 5 * MIN);
420 assert_eq!(held_for(&t, limited(Some(3600)), now + 5 * MIN), 10 * MIN);
421 let t = UsageReads::default();
422 assert_eq!(held_for(&t, limited(Some(30)), now), 2 * MIN, "a shorter one does not");
423
424 for e in [
427 UsageError::UpstreamStatus {
428 status: 503,
429 retry_after_s: None,
430 },
431 UsageError::Timeout,
432 UsageError::Transport { detail: "connect".into() },
433 ] {
434 let t = UsageReads::default();
435 assert_eq!(held_for(&t, Fetched::Usage(e), now), 2 * MIN);
436 }
437 let t = UsageReads::default();
438 let slow = UsageError::UpstreamStatus {
439 status: 503,
440 retry_after_s: Some(300),
441 };
442 assert_eq!(held_for(&t, Fetched::Usage(slow), now), 5 * MIN);
443 let t = UsageReads::default();
446 assert!(t.record_at("k", good(), now).is_ok());
447 for e in [
448 UsageError::Unauthorized { status: 401 },
449 UsageError::UpstreamStatus {
450 status: 404,
451 retry_after_s: None,
452 },
453 UsageError::Malformed { detail: "x".into() },
454 ] {
455 assert!(matches!(
456 t.record_at("k", Fetched::Usage(e), now),
457 Err(ReadError::Other { .. })
458 ));
459 assert!(t.held_at("k", now).is_none());
460 }
461 assert!(
462 t.last_good_for("k", &seat("r", None)).is_none(),
463 "the 401 dropped the last good reading"
464 );
465 }
466
467 #[test]
472 fn last_good_counts_only_for_the_account_it_was_read_from() {
473 let t = UsageReads::default();
474 assert!(t.record_at("k", good_from(seat("r1", Some("acct-1"))), 1).is_ok());
475 assert!(t.last_good_for("k", &seat("r1", Some("acct-1"))).is_some());
476 assert!(
477 t.last_good_for("k", &seat("r2-rotated", Some("acct-1"))).is_some(),
478 "a refresh rotation keeps the account"
479 );
480 assert!(
481 t.last_good_for("k", &seat("r1", Some("acct-2"))).is_none(),
482 "another account in the slot is never graced on this reading"
483 );
484 assert!(t.last_good_for("k", &seat("r1", None)).is_none());
485 assert!(t.last_good_for("other", &seat("r1", Some("acct-1"))).is_none());
486
487 let t = UsageReads::default();
488 assert!(t.record_at("k", good_from(seat("r1", None)), 1).is_ok());
489 assert!(t.last_good_for("k", &seat("r1", None)).is_some());
490 assert!(
491 t.last_good_for("k", &seat("r2", None)).is_none(),
492 "without ids, other refresh material may be another account"
493 );
494 assert!(t.last_good_for("k", &seat("r1", Some("acct-1"))).is_none());
495 }
496}