1use std::fmt::Write as _;
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use serde_json::Value;
13use sha2::{Digest, Sha256};
14
15use crate::cache::{Cache, acquire_lock_async};
16use crate::error::{AUTH_FAILURE_MESSAGE, AppError, Result};
17use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
18
19use super::creds::Credential;
20use super::types::{Plan, Snapshot, Window, parse_limits, parse_plan};
21
22pub const BASE_URL: &str = "https://api.cline.bot";
23
24const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
25const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
26const SCHEMA_ERROR: &str = "Cline response schema mismatch";
27
28#[derive(Debug, Clone)]
29pub struct Endpoints {
30 pub base: String,
31}
32
33impl Default for Endpoints {
34 fn default() -> Self {
35 Self {
36 base: BASE_URL.to_string(),
37 }
38 }
39}
40
41impl Endpoints {
42 fn me(&self) -> String {
43 format!("{}/api/v1/users/me", self.base)
44 }
45
46 fn plan(&self) -> String {
47 format!("{}/api/v1/users/me/plan", self.base)
48 }
49
50 fn usage_limits(&self) -> String {
51 format!("{}/api/v1/users/me/plan/usage-limits", self.base)
52 }
53}
54
55pub type FetchOutcome = crate::outcome::Outcome<Snapshot>;
57
58#[derive(Debug, Clone, Default)]
61struct State {
62 plan: Plan,
63 windows: [Option<Window>; 3],
64}
65
66impl State {
67 fn snapshot(&self, now: DateTime<Utc>) -> Snapshot {
68 let [five_hour, seven_day, thirty_day] = self
69 .windows
70 .clone()
71 .map(|window| window.map(|window| window.aged(now)));
72 Snapshot {
73 plan: self.plan.name.clone(),
74 five_hour,
75 seven_day,
76 thirty_day,
77 period_end: self.plan.period_end,
78 }
79 }
80}
81
82pub async fn fetch_snapshot(
83 client: &reqwest::Client,
84 credential: &Credential,
85 cache: &Cache,
86 endpoints: &Endpoints,
87 ttl: Duration,
88 now: DateTime<Utc>,
89) -> Result<FetchOutcome> {
90 cache.ensure_dir()?;
91 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
92
93 let credential_fingerprint = sha256_hex(&credential.token);
94 let cached_payload = cache.maybe_payload()?;
95 let mut target = credential
96 .account_id
97 .as_deref()
98 .map(|id| target_key(endpoints, id))
99 .or_else(|| {
100 cached_payload
101 .as_deref()
102 .and_then(|body| cached_target(body, endpoints, &credential_fingerprint))
103 });
104
105 if credential.expired {
109 let Some(target) = target.as_deref() else {
110 return Err(AppError::Credentials(super::creds::EXPIRED.to_string()));
111 };
112 cache.mark_stale();
113 let recorded = cache.write_last_error(401, super::creds::EXPIRED);
114 return fallback_or_error(
115 cache,
116 Some(recorded),
117 target,
118 AppError::Credentials(super::creds::EXPIRED.to_string()),
119 now,
120 );
121 }
122
123 let fresh_payload = cache.fresh_payload(ttl)?;
124 if let (Some(target), Some(bytes)) = (target.as_deref(), fresh_payload.as_deref())
125 && let Ok(state) = parse_cache(bytes, target)
126 {
127 return Ok(crate::outcome::Outcome::cached(
128 state.snapshot(now),
129 cache,
130 false,
131 ));
132 }
133
134 if target.is_none() && cache.backoff_remaining().is_some() {
138 return Err(AppError::Http {
139 status: 429,
140 body: status_message(429).to_string(),
141 });
142 }
143
144 if target.is_none() {
145 let user_id = match fetch_user_id(client, endpoints, &credential.token).await {
146 Ok(id) => id,
147 Err(error) => return handle_error(cache, None, error, now),
148 };
149 target = Some(target_key(endpoints, &user_id));
150 }
151 let target = target.expect("identity resolution set the cache target");
152 if let Some(bytes) = fresh_payload.as_deref()
155 && let Ok(state) = parse_cache(bytes, &target)
156 {
157 return Ok(crate::outcome::Outcome::cached(
158 state.snapshot(now),
159 cache,
160 false,
161 ));
162 }
163
164 match fetch_live(client, endpoints, &credential.token).await {
165 Ok(state) => {
166 cache.write_payload(&serde_json::to_vec(&state_repr(
167 &target,
168 &credential_fingerprint,
169 &state,
170 ))?)?;
171 Ok(crate::outcome::Outcome::fresh(state.snapshot(now)))
172 }
173 Err(error) => handle_error(cache, Some(&target), error, now),
174 }
175}
176
177async fn fetch_live(client: &reqwest::Client, endpoints: &Endpoints, token: &str) -> Result<State> {
178 let schema = |_| AppError::Schema(SCHEMA_ERROR.to_string());
179 let plan = parse_plan(&get_json(client, &endpoints.plan(), token).await?).map_err(schema)?;
180 let windows =
181 parse_limits(&get_json(client, &endpoints.usage_limits(), token).await?).map_err(schema)?;
182 Ok(State { plan, windows })
183}
184
185async fn fetch_user_id(
187 client: &reqwest::Client,
188 endpoints: &Endpoints,
189 token: &str,
190) -> Result<String> {
191 let value = get_json(client, &endpoints.me(), token).await?;
192 value
193 .get("data")
194 .unwrap_or(&value)
195 .get("id")
196 .and_then(Value::as_str)
197 .map(str::to_string)
198 .ok_or_else(|| AppError::Schema(SCHEMA_ERROR.to_string()))
199}
200
201async fn get_json(client: &reqwest::Client, url: &str, token: &str) -> Result<Value> {
202 let response = tokio::time::timeout(
203 HTTP_TIMEOUT,
204 client
205 .get(url)
206 .bearer_auth(token)
207 .header(reqwest::header::ACCEPT, "application/json")
208 .send(),
209 )
210 .await
211 .map_err(|_| AppError::Transport("Cline request timed out".to_string()))??;
212
213 let status = response.status();
214 let body = read_body_capped(response, MAX_BODY_BYTES).await?;
215 if !status.is_success() {
216 return Err(AppError::Http {
217 status: status.as_u16(),
218 body: status_message(status.as_u16()).to_string(),
219 });
220 }
221 serde_json::from_slice(&body).map_err(|_| AppError::Schema(SCHEMA_ERROR.to_string()))
222}
223
224fn target_key(endpoints: &Endpoints, user_id: &str) -> String {
228 format!("{}|user:{}|v3", endpoints.base, sha256_hex(user_id))
231}
232
233fn sha256_hex(value: &str) -> String {
234 let digest = Sha256::digest(value.as_bytes());
235 let mut fingerprint = String::with_capacity(digest.len() * 2);
236 for byte in digest {
237 let _ = write!(fingerprint, "{byte:02x}");
238 }
239 fingerprint
240}
241
242fn state_repr(target: &str, credential_fingerprint: &str, state: &State) -> Value {
243 let window = |window: &Option<Window>| {
244 window.as_ref().map(|window| {
245 serde_json::json!({
246 "percent": window.percent,
247 "reset": window.reset.map(|at| at.to_rfc3339()),
248 })
249 })
250 };
251 serde_json::json!({
252 "target": target,
253 "credential": credential_fingerprint,
254 "plan": {
255 "name": state.plan.name,
256 "period_end": state.plan.period_end.map(|at| at.to_rfc3339()),
257 },
258 "windows": state.windows.iter().map(window).collect::<Vec<_>>(),
259 })
260}
261
262fn cached_target(
266 body: &[u8],
267 endpoints: &Endpoints,
268 credential_fingerprint: &str,
269) -> Option<String> {
270 let value: Value = serde_json::from_slice(body).ok()?;
271 if value.get("credential").and_then(Value::as_str) != Some(credential_fingerprint) {
272 return None;
273 }
274 let target = value.get("target")?.as_str()?;
275 let prefix = format!("{}|user:", endpoints.base);
276 (target.starts_with(&prefix) && target.ends_with("|v3")).then(|| target.to_string())
277}
278
279fn parse_cache(body: &[u8], target: &str) -> Result<State> {
280 let invalid = || AppError::Schema("Cline cache is invalid".to_string());
281 let timestamp = |value: Option<&Value>| {
282 value
283 .and_then(Value::as_str)
284 .and_then(|at| at.parse::<DateTime<chrono::FixedOffset>>().ok())
285 .map(|at| at.with_timezone(&Utc))
286 };
287 let value: Value = serde_json::from_slice(body).map_err(|_| invalid())?;
288 if value.get("target").and_then(Value::as_str) != Some(target) {
289 return Err(AppError::Schema(
290 "Cline cache belongs to a different account".to_string(),
291 ));
292 }
293 let plan = value.get("plan").ok_or_else(invalid)?;
294 let rows = value
295 .get("windows")
296 .and_then(Value::as_array)
297 .filter(|rows| rows.len() == 3)
298 .ok_or_else(invalid)?;
299
300 let mut windows: [Option<Window>; 3] = [None, None, None];
303 for (slot, row) in windows.iter_mut().zip(rows) {
304 if row.is_null() {
305 continue;
306 }
307 *slot = Some(Window {
308 percent: row
309 .get("percent")
310 .and_then(Value::as_f64)
311 .filter(|percent| percent.is_finite())
312 .ok_or_else(invalid)?,
313 reset: timestamp(row.get("reset")),
314 });
315 }
316
317 Ok(State {
318 plan: Plan {
319 name: plan.get("name").and_then(Value::as_str).map(str::to_string),
320 period_end: timestamp(plan.get("period_end")),
321 },
322 windows,
323 })
324}
325
326fn status_message(status: u16) -> &'static str {
327 match status {
328 401 | 403 => AUTH_FAILURE_MESSAGE,
329 429 => "Cline request was rate limited",
330 500..=599 => "Cline service is temporarily unavailable",
331 _ => "Cline request failed",
332 }
333}
334
335fn fallback_or_error(
336 cache: &Cache,
337 last_error: Option<(u16, String)>,
338 target: &str,
339 error: AppError,
340 now: DateTime<Utc>,
341) -> Result<FetchOutcome> {
342 crate::outcome::fallback(cache, last_error, error, |body| {
343 parse_cache(body, target).map(|state| state.snapshot(now))
344 })
345}
346
347fn handle_error(
348 cache: &Cache,
349 target: Option<&str>,
350 error: AppError,
351 now: DateTime<Utc>,
352) -> Result<FetchOutcome> {
353 let (error, last_error) = match error {
354 AppError::Http { status, .. } => {
355 let message = status_message(status).to_string();
356 cache.mark_stale();
357 cache.write_last_error(status, &message);
358 (
359 AppError::Http {
360 status,
361 body: message.clone(),
362 },
363 Some((status, message)),
364 )
365 }
366 AppError::Schema(_) => {
367 let message = SCHEMA_ERROR.to_string();
368 cache.mark_stale();
369 cache.write_last_error(0, &message);
370 (AppError::Schema(message.clone()), Some((0, message)))
371 }
372 error => (error, None),
373 };
374
375 match target {
376 Some(target) => fallback_or_error(cache, last_error, target, error, now),
377 None => Err(error),
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use tempfile::TempDir;
384
385 use super::*;
386
387 const PLAN_BODY: &str = include_str!("../../tests/fixtures/cline/plan.json");
388 const LIMITS_BODY: &str = include_str!("../../tests/fixtures/cline/usage-limits.json");
389
390 fn now() -> DateTime<Utc> {
391 "2026-10-09T10:00:00Z".parse().expect("timestamp")
392 }
393
394 fn credential() -> Credential {
395 Credential {
396 token: "workos:test-token".to_string(),
397 account_id: Some("usr-1".to_string()),
398 source: "test".to_string(),
399 expired: false,
400 }
401 }
402
403 fn cache_fixture() -> (TempDir, Cache) {
404 let dir = TempDir::new().expect("temporary cache directory");
405 let cache = Cache::at(dir.path().join("cline"));
406 cache.ensure_dir().expect("cache directory");
407 (dir, cache)
408 }
409
410 async fn refresh(
411 cache: &Cache,
412 endpoints: &Endpoints,
413 credential: &Credential,
414 ttl: Duration,
415 at: DateTime<Utc>,
416 ) -> Result<FetchOutcome> {
417 fetch_snapshot(
418 &reqwest::Client::new(),
419 credential,
420 cache,
421 endpoints,
422 ttl,
423 at,
424 )
425 .await
426 }
427
428 #[tokio::test]
429 async fn reports_the_limits_exactly_as_the_server_states_them() {
430 let mut server = mockito::Server::new_async().await;
431 let plan = server
432 .mock("GET", "/api/v1/users/me/plan")
433 .match_header("authorization", "Bearer workos:test-token")
434 .match_header("accept", "application/json")
435 .with_body(PLAN_BODY)
436 .create_async()
437 .await;
438 let limits = server
439 .mock("GET", "/api/v1/users/me/plan/usage-limits")
440 .match_header("authorization", "Bearer workos:test-token")
441 .with_body(LIMITS_BODY)
442 .create_async()
443 .await;
444 let (_dir, cache) = cache_fixture();
445
446 let output = refresh(
447 &cache,
448 &Endpoints { base: server.url() },
449 &credential(),
450 Duration::from_secs(60),
451 now(),
452 )
453 .await
454 .expect("a successful refresh");
455
456 let snapshot = output.snapshot;
457 assert_eq!(snapshot.plan.as_deref(), Some("Cline Pass (Monthly)"));
458 assert_eq!(snapshot.five_hour.as_ref().expect("5h").pct(), 0);
459 assert_eq!(snapshot.seven_day.as_ref().expect("weekly").pct(), 5);
460 assert_eq!(snapshot.thirty_day.as_ref().expect("monthly").pct(), 2);
461 assert!(!output.stale);
462 plan.assert_async().await;
463 limits.assert_async().await;
464 }
465
466 #[tokio::test]
467 async fn a_fresh_cache_is_aged_rather_than_refetched() {
468 let mut server = mockito::Server::new_async().await;
469 let plan = server
470 .mock("GET", "/api/v1/users/me/plan")
471 .with_body(PLAN_BODY)
472 .expect(1)
473 .create_async()
474 .await;
475 let limits = server
476 .mock("GET", "/api/v1/users/me/plan/usage-limits")
477 .with_body(LIMITS_BODY)
478 .expect(1)
479 .create_async()
480 .await;
481 let (_dir, cache) = cache_fixture();
482 let endpoints = Endpoints { base: server.url() };
483 let ttl = Duration::from_secs(600);
484 refresh(&cache, &endpoints, &credential(), ttl, now())
485 .await
486 .expect("first refresh");
487
488 let later = now() + chrono::Duration::days(2);
491 let output = refresh(&cache, &endpoints, &credential(), ttl, later)
492 .await
493 .expect("cached refresh");
494
495 let week = output.snapshot.seven_day.expect("weekly");
496 assert_eq!(week.pct(), 0);
497 assert_eq!(week.resets_at(), None);
498 assert_eq!(output.snapshot.thirty_day.expect("monthly").pct(), 2);
499 plan.assert_async().await;
500 limits.assert_async().await;
501 }
502
503 #[tokio::test]
504 async fn a_lapsed_token_serves_the_cached_answer_aged_to_now() {
505 let mut server = mockito::Server::new_async().await;
506 server
507 .mock("GET", "/api/v1/users/me/plan")
508 .with_body(PLAN_BODY)
509 .expect(1)
510 .create_async()
511 .await;
512 server
513 .mock("GET", "/api/v1/users/me/plan/usage-limits")
514 .with_body(LIMITS_BODY)
515 .expect(1)
516 .create_async()
517 .await;
518 let (_dir, cache) = cache_fixture();
519 let endpoints = Endpoints { base: server.url() };
520 refresh(&cache, &endpoints, &credential(), Duration::ZERO, now())
521 .await
522 .expect("a refresh while the token was live");
523
524 let lapsed = Credential {
525 expired: true,
526 ..credential()
527 };
528 let output = refresh(
529 &cache,
530 &endpoints,
531 &lapsed,
532 Duration::ZERO,
533 now() + chrono::Duration::days(2),
534 )
535 .await
536 .expect("the cached answer is still worth showing");
537
538 assert!(output.stale);
539 assert_eq!(output.snapshot.seven_day.expect("weekly").pct(), 0);
540 assert_eq!(output.snapshot.thirty_day.expect("monthly").pct(), 2);
541 let (code, message) = output.last_error.expect("a recorded reason");
542 assert_eq!(code, 401);
543 assert!(message.contains("expired"));
544 }
545
546 #[tokio::test]
547 async fn a_lapsed_token_with_no_cache_reports_the_sign_in_instead() {
548 let (_dir, cache) = cache_fixture();
549 let lapsed = Credential {
550 expired: true,
551 ..credential()
552 };
553
554 let error = refresh(
555 &cache,
556 &Endpoints {
557 base: "http://127.0.0.1:1".to_string(),
558 },
559 &lapsed,
560 Duration::ZERO,
561 now(),
562 )
563 .await
564 .expect_err("nothing to show and nothing to fetch");
565 assert!(error.to_string().contains("expired"));
566 }
567
568 #[tokio::test]
569 async fn unauthorized_without_cache_returns_a_redacted_error() {
570 let mut server = mockito::Server::new_async().await;
571 server
572 .mock("GET", "/api/v1/users/me/plan")
573 .with_status(401)
574 .with_body("workos:test-token should never be echoed")
575 .create_async()
576 .await;
577 let (_dir, cache) = cache_fixture();
578
579 let error = refresh(
580 &cache,
581 &Endpoints { base: server.url() },
582 &credential(),
583 Duration::ZERO,
584 now(),
585 )
586 .await
587 .expect_err("401 without a cache must fail");
588
589 let rendered = error.to_string();
590 assert!(rendered.contains("401"));
591 assert!(!rendered.contains("test-token"));
592 }
593
594 #[tokio::test]
595 async fn a_malformed_limits_response_is_a_schema_error_not_zero_percent() {
596 let mut server = mockito::Server::new_async().await;
597 server
598 .mock("GET", "/api/v1/users/me/plan")
599 .with_body(PLAN_BODY)
600 .create_async()
601 .await;
602 server
603 .mock("GET", "/api/v1/users/me/plan/usage-limits")
604 .with_body(r#"{"data":{"limits":[{"type":"weekly"}]},"success":true}"#)
605 .create_async()
606 .await;
607 let (_dir, cache) = cache_fixture();
608
609 let error = refresh(
610 &cache,
611 &Endpoints { base: server.url() },
612 &credential(),
613 Duration::ZERO,
614 now(),
615 )
616 .await
617 .expect_err("a limit without a percentage must not render");
618 assert!(matches!(error, AppError::Schema(_)));
619 }
620
621 #[tokio::test]
622 async fn a_cache_written_for_another_account_is_not_served() {
623 let mut server = mockito::Server::new_async().await;
624 server
625 .mock("GET", "/api/v1/users/me/plan")
626 .with_body(PLAN_BODY)
627 .expect(2)
628 .create_async()
629 .await;
630 server
631 .mock("GET", "/api/v1/users/me/plan/usage-limits")
632 .with_body(LIMITS_BODY)
633 .expect(1)
634 .create_async()
635 .await;
636 server
639 .mock("GET", "/api/v1/users/me/plan/usage-limits")
640 .with_status(503)
641 .create_async()
642 .await;
643 let (_dir, cache) = cache_fixture();
644 let endpoints = Endpoints { base: server.url() };
645 refresh(&cache, &endpoints, &credential(), Duration::ZERO, now())
646 .await
647 .expect("first account refresh");
648
649 let other = Credential {
650 account_id: Some("usr-2".to_string()),
651 ..credential()
652 };
653 let error = refresh(&cache, &endpoints, &other, Duration::ZERO, now())
654 .await
655 .expect_err("another account's cache must not be served");
656 assert!(matches!(error, AppError::Http { status: 503, .. }));
657 }
658
659 #[test]
660 fn a_ledger_cache_from_the_previous_schema_is_ignored() {
661 let endpoints = Endpoints::default();
662 let old = format!(
663 r#"{{"target":"{}|user:x|v2","plan":{{}},"ledger":[]}}"#,
664 endpoints.base
665 );
666 assert!(parse_cache(old.as_bytes(), &target_key(&endpoints, "usr-1")).is_err());
667 }
668
669 #[test]
670 fn cached_target_is_bound_to_both_credential_and_endpoint() {
671 let endpoints = Endpoints::default();
672 let state = State::default();
673 let body = serde_json::to_vec(&state_repr(
674 &target_key(&endpoints, "usr-1"),
675 "credential-a",
676 &state,
677 ))
678 .unwrap();
679
680 assert_eq!(
681 cached_target(&body, &endpoints, "credential-a"),
682 Some(target_key(&endpoints, "usr-1"))
683 );
684 assert_eq!(cached_target(&body, &endpoints, "credential-b"), None);
685 assert_eq!(
686 cached_target(
687 &body,
688 &Endpoints {
689 base: "https://other.example".to_string(),
690 },
691 "credential-a",
692 ),
693 None
694 );
695 }
696
697 #[tokio::test]
698 async fn api_key_identity_lookup_obeys_the_cache_ttl() {
699 let mut server = mockito::Server::new_async().await;
700 let me = server
701 .mock("GET", "/api/v1/users/me")
702 .with_body(r#"{"data":{"id":"usr-1"}}"#)
703 .expect(1)
704 .create_async()
705 .await;
706 let plan = server
707 .mock("GET", "/api/v1/users/me/plan")
708 .with_body(PLAN_BODY)
709 .expect(1)
710 .create_async()
711 .await;
712 let limits = server
713 .mock("GET", "/api/v1/users/me/plan/usage-limits")
714 .with_body(LIMITS_BODY)
715 .expect(1)
716 .create_async()
717 .await;
718 let (_dir, cache) = cache_fixture();
719 let endpoints = Endpoints { base: server.url() };
720 let api_key = Credential {
721 account_id: None,
722 ..credential()
723 };
724
725 refresh(
726 &cache,
727 &endpoints,
728 &api_key,
729 Duration::from_secs(600),
730 now(),
731 )
732 .await
733 .expect("first refresh");
734 let cached = refresh(
735 &cache,
736 &endpoints,
737 &api_key,
738 Duration::from_secs(600),
739 now(),
740 )
741 .await
742 .expect("second refresh should be cached");
743
744 assert!(!cached.off_the_wire());
745 me.assert_async().await;
746 plan.assert_async().await;
747 limits.assert_async().await;
748 }
749
750 #[tokio::test]
751 async fn api_key_reuses_a_fresh_cache_from_before_the_fingerprint_field() {
752 let mut server = mockito::Server::new_async().await;
753 let me = server
754 .mock("GET", "/api/v1/users/me")
755 .with_body(r#"{"data":{"id":"usr-1"}}"#)
756 .expect(1)
757 .create_async()
758 .await;
759 let plan = server
760 .mock("GET", "/api/v1/users/me/plan")
761 .expect(0)
762 .create_async()
763 .await;
764 let limits = server
765 .mock("GET", "/api/v1/users/me/plan/usage-limits")
766 .expect(0)
767 .create_async()
768 .await;
769 let (_dir, cache) = cache_fixture();
770 let endpoints = Endpoints { base: server.url() };
771 let mut old = state_repr(
772 &target_key(&endpoints, "usr-1"),
773 "not-present-before-this-fix",
774 &State::default(),
775 );
776 old.as_object_mut().unwrap().remove("credential");
777 cache
778 .write_payload(&serde_json::to_vec(&old).unwrap())
779 .unwrap();
780 let api_key = Credential {
781 account_id: None,
782 ..credential()
783 };
784
785 let cached = refresh(
786 &cache,
787 &endpoints,
788 &api_key,
789 Duration::from_secs(600),
790 now(),
791 )
792 .await
793 .expect("the previous cache schema remains readable");
794
795 assert!(!cached.off_the_wire());
796 me.assert_async().await;
797 plan.assert_async().await;
798 limits.assert_async().await;
799 }
800
801 #[tokio::test]
802 async fn api_key_identity_lookup_obeys_rate_limit_backoff() {
803 let mut server = mockito::Server::new_async().await;
804 let me = server
805 .mock("GET", "/api/v1/users/me")
806 .with_status(429)
807 .expect(1)
808 .create_async()
809 .await;
810 let (_dir, cache) = cache_fixture();
811 let endpoints = Endpoints { base: server.url() };
812 let api_key = Credential {
813 account_id: None,
814 ..credential()
815 };
816
817 for _ in 0..2 {
818 let error = refresh(&cache, &endpoints, &api_key, Duration::ZERO, now())
819 .await
820 .expect_err("rate-limited identity lookup");
821 assert!(matches!(error, AppError::Http { status: 429, .. }));
822 }
823
824 me.assert_async().await;
825 assert!(cache.backoff_remaining().is_some());
826 }
827
828 #[tokio::test]
829 async fn rate_limit_backoff_never_serves_or_probes_another_api_key() {
830 let mut server = mockito::Server::new_async().await;
831 let me = server
832 .mock("GET", "/api/v1/users/me")
833 .expect(0)
834 .create_async()
835 .await;
836 let (_dir, cache) = cache_fixture();
837 let endpoints = Endpoints { base: server.url() };
838 cache
839 .write_payload(
840 &serde_json::to_vec(&state_repr(
841 &target_key(&endpoints, "usr-a"),
842 &sha256_hex("api-key-a"),
843 &State::default(),
844 ))
845 .unwrap(),
846 )
847 .unwrap();
848 cache.write_last_error(429, "rate limited");
849 let other_key = Credential {
850 token: "api-key-b".to_string(),
851 account_id: None,
852 ..credential()
853 };
854
855 let error = refresh(&cache, &endpoints, &other_key, Duration::ZERO, now())
856 .await
857 .expect_err("another key's cache must be refused during backoff");
858
859 assert!(matches!(error, AppError::Http { status: 429, .. }));
860 me.assert_async().await;
861 }
862}