Skip to main content

ai_usagebar/supergrok/
fetch.rs

1//! SuperGrok fetch/cache orchestration around Grok Build's billing ACP method.
2
3use std::future::Future;
4use std::path::Path;
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::cache::{Cache, MAX_STALE, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::{SuperGrokPeriod, SuperGrokSnapshot};
13
14use super::scope::ScopePaths;
15use super::{acp, scope, types};
16
17const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
18const CACHE_SCHEMA: u8 = 2;
19
20#[derive(Debug, Clone)]
21pub struct FetchOutcome {
22    pub snapshot: SuperGrokSnapshot,
23    pub stale: bool,
24    pub last_error: Option<(u16, String)>,
25    pub cache_age: Option<Duration>,
26}
27
28pub async fn fetch_snapshot(
29    grok_binary: &Path,
30    scope_paths: &ScopePaths,
31    cache: &Cache,
32    cache_ttl: Duration,
33) -> Result<FetchOutcome> {
34    fetch_snapshot_with(
35        cache,
36        cache_ttl,
37        Utc::now(),
38        || scope::fingerprint(scope_paths),
39        || acp::fetch_billing(grok_binary),
40    )
41    .await
42}
43
44async fn fetch_snapshot_with<S, F, Fut>(
45    cache: &Cache,
46    cache_ttl: Duration,
47    now: DateTime<Utc>,
48    read_scope: S,
49    fetch_billing: F,
50) -> Result<FetchOutcome>
51where
52    S: Fn() -> Option<String>,
53    F: FnOnce() -> Fut,
54    Fut: Future<Output = Result<types::BillingResponse>>,
55{
56    cache.ensure_dir()?;
57    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
58    let scope_before = read_scope();
59
60    if let Some(account_scope) = scope_before.as_deref()
61        && let Some(bytes) = cache.fresh_payload(cache_ttl)?
62        && let Ok(outcome) = reuse_cache(&bytes, cache, false, account_scope)
63        && !period_has_ended(&outcome.snapshot, now)
64    {
65        return Ok(outcome);
66    }
67
68    match fetch_billing().await {
69        Ok(response) => {
70            let account_scope = scope_before.as_deref().unwrap_or("uncached");
71            let mut snapshot = match types::to_snapshot(response, account_scope) {
72                Ok(snapshot) => snapshot,
73                Err(error) => return fallback(cache, scope_before.as_deref(), now, error),
74            };
75            let scope_after = read_scope();
76
77            // A concurrent login, config change, or token rotation means the
78            // account identity was not stable across the request. Return the
79            // live result, but do not bind it to either cache scope.
80            if scope_before.is_some() && scope_before == scope_after {
81                let account_scope = scope_before.as_deref().expect("checked Some");
82                snapshot.account = account_scope.to_string();
83                let bytes =
84                    serde_json::to_vec(&CachedEnvelope::from_snapshot(account_scope, &snapshot))?;
85                cache.write_payload(&bytes)?;
86            } else {
87                snapshot.account = "uncached".into();
88            }
89
90            Ok(FetchOutcome {
91                snapshot,
92                stale: false,
93                last_error: None,
94                cache_age: Some(Duration::ZERO),
95            })
96        }
97        Err(error) => fallback(cache, scope_before.as_deref(), now, error),
98    }
99}
100
101fn period_has_ended(snapshot: &SuperGrokSnapshot, now: DateTime<Utc>) -> bool {
102    snapshot.reset_at.is_some_and(|reset| reset <= now)
103}
104
105#[derive(Debug, Serialize, Deserialize)]
106struct CachedEnvelope {
107    schema: u8,
108    scope: String,
109    snapshot: CachedSnapshot,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
113struct CachedSnapshot {
114    plan: String,
115    percent: i32,
116    period: String,
117    reset_at: Option<DateTime<Utc>>,
118    prepaid_balance: Option<f64>,
119}
120
121impl CachedEnvelope {
122    fn from_snapshot(scope: &str, snapshot: &SuperGrokSnapshot) -> Self {
123        let period = match snapshot.period {
124            SuperGrokPeriod::Weekly => "weekly",
125            SuperGrokPeriod::Monthly => "monthly",
126            SuperGrokPeriod::Unknown => "unknown",
127        };
128        Self {
129            schema: CACHE_SCHEMA,
130            scope: scope.to_string(),
131            snapshot: CachedSnapshot {
132                plan: snapshot.plan.clone(),
133                percent: snapshot.weekly_pct,
134                period: period.to_string(),
135                reset_at: snapshot.reset_at,
136                prepaid_balance: snapshot.prepaid_balance,
137            },
138        }
139    }
140}
141
142fn parse_cache(bytes: &[u8], account_scope: &str) -> Result<SuperGrokSnapshot> {
143    let cached: CachedEnvelope = serde_json::from_slice(bytes)?;
144    if cached.schema != CACHE_SCHEMA {
145        return Err(AppError::Schema(
146            "SuperGrok cache schema is obsolete; refetching".into(),
147        ));
148    }
149    if cached.scope != account_scope {
150        return Err(AppError::Schema(
151            "SuperGrok cache belongs to a different login; refetching".into(),
152        ));
153    }
154    if !(0..=100).contains(&cached.snapshot.percent) {
155        return Err(AppError::Schema(
156            "SuperGrok cached percentage is out of range".into(),
157        ));
158    }
159    if cached.snapshot.plan.chars().count() > 128
160        || cached.snapshot.plan.chars().any(char::is_control)
161    {
162        return Err(AppError::Schema(
163            "SuperGrok cached plan label is invalid".into(),
164        ));
165    }
166    let period = match cached.snapshot.period.as_str() {
167        "weekly" => SuperGrokPeriod::Weekly,
168        "monthly" => SuperGrokPeriod::Monthly,
169        "unknown" => SuperGrokPeriod::Unknown,
170        _ => {
171            return Err(AppError::Schema(
172                "SuperGrok cached period kind is invalid".into(),
173            ));
174        }
175    };
176    if cached
177        .snapshot
178        .prepaid_balance
179        .is_some_and(|balance| !balance.is_finite() || balance < 0.0)
180    {
181        return Err(AppError::Schema(
182            "SuperGrok cached prepaid balance is invalid".into(),
183        ));
184    }
185
186    Ok(SuperGrokSnapshot {
187        plan: cached.snapshot.plan,
188        account: account_scope.to_string(),
189        weekly_pct: cached.snapshot.percent,
190        period,
191        reset_at: cached.snapshot.reset_at,
192        prepaid_balance: cached.snapshot.prepaid_balance,
193    })
194}
195
196fn reuse_cache(
197    bytes: &[u8],
198    cache: &Cache,
199    stale: bool,
200    account_scope: &str,
201) -> Result<FetchOutcome> {
202    Ok(FetchOutcome {
203        snapshot: parse_cache(bytes, account_scope)?,
204        stale,
205        last_error: cache.read_last_error(),
206        cache_age: cache.payload_age(),
207    })
208}
209
210fn fallback(
211    cache: &Cache,
212    account_scope: Option<&str>,
213    now: DateTime<Utc>,
214    original: AppError,
215) -> Result<FetchOutcome> {
216    let Some(account_scope) = account_scope else {
217        return Err(original);
218    };
219    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
220        return Err(original);
221    };
222    match reuse_cache(&bytes, cache, true, account_scope) {
223        Ok(mut outcome) if !period_has_ended(&outcome.snapshot, now) => {
224            let error = error_to_pair(&original);
225            cache.mark_stale();
226            cache.write_last_error(error.0, &error.1);
227            outcome.last_error = Some(error);
228            Ok(outcome)
229        }
230        _ => Err(original),
231    }
232}
233
234fn error_to_pair(error: &AppError) -> (u16, String) {
235    match error {
236        AppError::Http { status, body } => (*status, body.clone()),
237        other => (0, other.to_string()),
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use chrono::TimeZone;
245    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
246    use tempfile::TempDir;
247
248    fn now() -> DateTime<Utc> {
249        Utc.with_ymd_and_hms(2026, 8, 7, 0, 0, 0).unwrap()
250    }
251
252    fn fixture() -> (TempDir, Cache) {
253        let td = TempDir::new().unwrap();
254        let cache = Cache::at(td.path().join("supergrok"));
255        (td, cache)
256    }
257
258    fn weekly_response(percent: f64) -> types::BillingResponse {
259        serde_json::from_value(serde_json::json!({
260            "config": {
261                "creditUsagePercent": percent,
262                "currentPeriod": {
263                    "type": "USAGE_PERIOD_TYPE_WEEKLY",
264                    "end": "2026-08-14T00:00:00Z"
265                }
266            },
267            "subscription_tier": "SuperGrok"
268        }))
269        .unwrap()
270    }
271
272    #[tokio::test]
273    async fn live_fetch_writes_only_an_opaque_scope_to_cache() {
274        let (_td, cache) = fixture();
275        let outcome = fetch_snapshot_with(
276            &cache,
277            Duration::ZERO,
278            now(),
279            || Some("opaque-digest".into()),
280            || async { Ok(weekly_response(12.4)) },
281        )
282        .await
283        .unwrap();
284        assert_eq!(outcome.snapshot.weekly_pct, 12);
285        assert_eq!(outcome.snapshot.period, SuperGrokPeriod::Weekly);
286
287        let cache_text = std::fs::read_to_string(cache.payload_path()).unwrap();
288        assert!(cache_text.contains("opaque-digest"));
289        assert!(!cache_text.contains("access_token"));
290        assert!(!cache_text.contains("user_id"));
291    }
292
293    #[tokio::test]
294    async fn fresh_cache_skips_the_acp_process() {
295        let (_td, cache) = fixture();
296        cache.ensure_dir().unwrap();
297        let snapshot = types::to_snapshot(weekly_response(7.0), "scope-a").unwrap();
298        cache
299            .write_payload(
300                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
301            )
302            .unwrap();
303        let called = AtomicBool::new(false);
304
305        let outcome = fetch_snapshot_with(
306            &cache,
307            Duration::from_secs(3600),
308            now(),
309            || Some("scope-a".into()),
310            || async {
311                called.store(true, Ordering::SeqCst);
312                Ok(weekly_response(99.0))
313            },
314        )
315        .await
316        .unwrap();
317        assert_eq!(outcome.snapshot.weekly_pct, 7);
318        assert!(!called.load(Ordering::SeqCst));
319    }
320
321    #[tokio::test]
322    async fn a_scope_change_during_fetch_returns_live_but_does_not_cache() {
323        let (_td, cache) = fixture();
324        let calls = AtomicUsize::new(0);
325        let outcome = fetch_snapshot_with(
326            &cache,
327            Duration::ZERO,
328            now(),
329            || {
330                let call = calls.fetch_add(1, Ordering::SeqCst);
331                Some(if call == 0 { "before" } else { "after" }.into())
332            },
333            || async { Ok(weekly_response(20.0)) },
334        )
335        .await
336        .unwrap();
337        assert_eq!(outcome.snapshot.account, "uncached");
338        assert!(!cache.payload_path().exists());
339    }
340
341    #[tokio::test]
342    async fn failure_falls_back_only_for_the_same_scope_and_live_period() {
343        let (_td, cache) = fixture();
344        cache.ensure_dir().unwrap();
345        let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
346        cache
347            .write_payload(
348                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
349            )
350            .unwrap();
351
352        let fallback = fetch_snapshot_with(
353            &cache,
354            Duration::ZERO,
355            now(),
356            || Some("scope-a".into()),
357            || async { Err(AppError::Transport("offline".into())) },
358        )
359        .await
360        .unwrap();
361        assert!(fallback.stale);
362        assert_eq!(fallback.snapshot.weekly_pct, 33);
363
364        let other_scope = fetch_snapshot_with(
365            &cache,
366            Duration::ZERO,
367            now(),
368            || Some("scope-b".into()),
369            || async { Err(AppError::Transport("offline".into())) },
370        )
371        .await;
372        assert!(other_scope.is_err());
373    }
374
375    #[tokio::test]
376    async fn malformed_live_billing_preserves_the_last_good_same_scope_cache() {
377        let (_td, cache) = fixture();
378        cache.ensure_dir().unwrap();
379        let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
380        cache
381            .write_payload(
382                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
383            )
384            .unwrap();
385
386        let fallback = fetch_snapshot_with(
387            &cache,
388            Duration::ZERO,
389            now(),
390            || Some("scope-a".into()),
391            || async { Ok(weekly_response(999.0)) },
392        )
393        .await
394        .unwrap();
395        assert!(fallback.stale);
396        assert_eq!(fallback.snapshot.weekly_pct, 33);
397        assert!(
398            fallback
399                .last_error
400                .as_ref()
401                .is_some_and(|(_, message)| message.contains("outside the supported range"))
402        );
403    }
404
405    #[tokio::test]
406    async fn missing_scope_disables_cache_reuse() {
407        let (_td, cache) = fixture();
408        cache.ensure_dir().unwrap();
409        let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
410        cache
411            .write_payload(
412                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
413            )
414            .unwrap();
415        let outcome = fetch_snapshot_with(
416            &cache,
417            Duration::from_secs(3600),
418            now(),
419            || None,
420            || async { Err(AppError::Transport("offline".into())) },
421        )
422        .await;
423        assert!(outcome.is_err());
424    }
425
426    #[tokio::test]
427    async fn an_ended_period_is_never_resurrected_on_failure() {
428        let (_td, cache) = fixture();
429        cache.ensure_dir().unwrap();
430        let snapshot = types::to_snapshot(weekly_response(88.0), "scope-a").unwrap();
431        cache
432            .write_payload(
433                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
434            )
435            .unwrap();
436        let after_reset = Utc.with_ymd_and_hms(2026, 8, 15, 0, 0, 0).unwrap();
437        let outcome = fetch_snapshot_with(
438            &cache,
439            Duration::from_secs(3600),
440            after_reset,
441            || Some("scope-a".into()),
442            || async { Err(AppError::Transport("offline".into())) },
443        )
444        .await;
445        assert!(outcome.is_err());
446    }
447
448    #[test]
449    fn cached_percentages_and_periods_are_strictly_validated() {
450        let base = serde_json::json!({
451            "schema": CACHE_SCHEMA,
452            "scope": "scope-a",
453            "snapshot": {
454                "plan": "SuperGrok",
455                "percent": 5,
456                "period": "weekly",
457                "reset_at": null,
458                "prepaid_balance": null
459            }
460        });
461        for (field, value) in [
462            ("percent", serde_json::json!(101)),
463            ("period", serde_json::json!("yearly")),
464            ("prepaid_balance", serde_json::json!(-1.0)),
465        ] {
466            let mut malformed = base.clone();
467            malformed["snapshot"][field] = value;
468            assert!(
469                parse_cache(malformed.to_string().as_bytes(), "scope-a").is_err(),
470                "field: {field}"
471            );
472        }
473
474        let mut obsolete = base;
475        obsolete["schema"] = serde_json::json!(1);
476        obsolete["account"] = serde_json::json!("person@example.test");
477        assert!(parse_cache(obsolete.to_string().as_bytes(), "scope-a").is_err());
478    }
479}