Skip to main content

ai_usagebar/deeprelay/
fetch.rs

1use std::time::Duration;
2
3use serde::Deserialize;
4
5use crate::cache::{Cache, acquire_lock_async};
6use crate::error::{AppError, Result};
7use crate::usage::DeepRelaySnapshot;
8use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
9
10use super::types::{Balance, CachedPayload, Subscription};
11
12pub const BASE_URL: &str = "https://api.deeprelay.ai/v1";
13const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
14const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
15
16pub type FetchOutcome = crate::outcome::Outcome<DeepRelaySnapshot>;
17
18#[derive(Debug, Clone)]
19pub struct Endpoints {
20    pub subscription: String,
21    pub balance: String,
22}
23
24impl Default for Endpoints {
25    fn default() -> Self {
26        Self {
27            subscription: format!("{BASE_URL}/billing/subscription"),
28            balance: format!("{BASE_URL}/billing/balance"),
29        }
30    }
31}
32
33pub async fn fetch_snapshot(
34    client: &reqwest::Client,
35    api_key: &str,
36    cache: &Cache,
37    endpoints: &Endpoints,
38    cache_ttl: Duration,
39) -> Result<FetchOutcome> {
40    cache.ensure_dir()?;
41    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
42    let parse_cache = |bytes: &[u8]| -> Result<DeepRelaySnapshot> {
43        let payload: CachedPayload = serde_json::from_slice(bytes)
44            .map_err(|_| AppError::Schema("DeepRelay billing cache is invalid".into()))?;
45        payload.snapshot()
46    };
47
48    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
49        && let Ok(snapshot) = parse_cache(&bytes)
50    {
51        return Ok(crate::outcome::Outcome::cached(snapshot, cache, false));
52    }
53    // An unparseable cache still must not bypass an active 429 backoff.
54    if let Some(remaining) = cache.backoff_remaining() {
55        return Err(AppError::Http {
56            status: 429,
57            body: format!(
58                "rate limited; next attempt in {}",
59                crate::cache::human_backoff(remaining)
60            ),
61        });
62    }
63
64    match request(client, api_key, endpoints).await {
65        Ok((payload, snapshot)) => {
66            cache.write_payload(&serde_json::to_vec(&payload)?)?;
67            Ok(crate::outcome::Outcome::fresh(snapshot))
68        }
69        Err(error) if error.is_transient() => {
70            crate::outcome::fallback(cache, None, error, parse_cache)
71        }
72        Err(error) => {
73            let code = match &error {
74                AppError::Http { status, .. } => *status,
75                _ => 0,
76            };
77            cache.mark_stale();
78            let diagnostic = cache.write_last_error(code, &error.to_string());
79            crate::outcome::fallback(cache, Some(diagnostic), error, parse_cache)
80        }
81    }
82}
83
84/// The wallet is only worth a second request when it can change what the user
85/// does: a pay-as-you-go account *is* its balance, and a subscriber only needs
86/// it once something has started billing past the plan.
87fn wants_balance(subscription: &Subscription) -> bool {
88    if !subscription.subscribed {
89        return true;
90    }
91    subscription
92        .usage
93        .as_ref()
94        .and_then(|usage| usage.recent_payg_calls_24h)
95        .is_some_and(|calls| calls > 0)
96}
97
98async fn request(
99    client: &reqwest::Client,
100    api_key: &str,
101    endpoints: &Endpoints,
102) -> Result<(CachedPayload, DeepRelaySnapshot)> {
103    let subscription: Subscription =
104        get_json(client, api_key, &endpoints.subscription, "subscription").await?;
105    // A wallet failure must leave the plan figures up: the subscription is the
106    // primary reading, the balance is supplementary.
107    let balance = if wants_balance(&subscription) {
108        get_json::<Balance>(client, api_key, &endpoints.balance, "balance")
109            .await
110            .ok()
111    } else {
112        None
113    };
114    let payload = CachedPayload {
115        subscription,
116        balance,
117    };
118    let snapshot = payload.snapshot()?;
119    Ok((payload, snapshot))
120}
121
122async fn get_json<T: serde::de::DeserializeOwned>(
123    client: &reqwest::Client,
124    api_key: &str,
125    url: &str,
126    what: &str,
127) -> Result<T> {
128    let response = tokio::time::timeout(
129        HTTP_TIMEOUT,
130        client
131            .get(url)
132            .bearer_auth(api_key)
133            .header(reqwest::header::ACCEPT, "application/json")
134            .send(),
135    )
136    .await
137    .map_err(|_| AppError::Transport(format!("DeepRelay {what} request timed out")))??;
138    let status = response.status();
139    let bytes = read_body_capped(response, MAX_BODY_BYTES).await?;
140    if !status.is_success() {
141        return Err(http_error(status.as_u16(), &bytes));
142    }
143    serde_json::from_slice(&bytes)
144        .map_err(|_| AppError::Schema(format!("DeepRelay {what} response schema mismatch")))
145}
146
147/// RFC 9457 problem body. `code` is the stable field — `detail` is prose and
148/// must be sanitized before it can reach a tooltip.
149#[derive(Deserialize)]
150struct Problem {
151    #[serde(default)]
152    detail: Option<String>,
153    #[serde(default)]
154    code: Option<String>,
155}
156
157fn http_error(status: u16, bytes: &[u8]) -> AppError {
158    let problem: Option<Problem> = serde_json::from_slice(bytes).ok();
159    let (code, detail) = match problem {
160        Some(Problem { code, detail }) => (code, detail),
161        None => (None, None),
162    };
163    let code = code.as_deref();
164    let detail =
165        detail.unwrap_or_else(|| String::from_utf8_lossy(bytes).chars().take(200).collect());
166    let detail = crate::display::sanitize_untrusted_line(&detail);
167    let body = match code {
168        // The only billing failure a user can act on: the key is valid but not
169        // allowed to read the plan.
170        Some("insufficient_scope") => format!(
171            "{detail} — this key needs the `billing:read` scope; create one with that scope at deeprelay.ai/cloud/api-keys"
172        ),
173        Some("unauthenticated" | "invalid_api_key") => {
174            format!("{detail} — check the key, or create a new one at deeprelay.ai/cloud/api-keys")
175        }
176        _ => detail,
177    };
178    AppError::Http { status, body }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use tempfile::TempDir;
185
186    fn cache() -> (TempDir, Cache) {
187        let dir = TempDir::new().unwrap();
188        let cache = Cache::at(dir.path().join("deeprelay"));
189        cache.ensure_dir().unwrap();
190        (dir, cache)
191    }
192
193    const SUBSCRIPTION: &str = include_str!("../../tests/fixtures/deeprelay/subscription.json");
194
195    /// The cache holds the subscription *and* the wallet, so a seeded cache is
196    /// the wrapped shape, not the bare response.
197    fn cached_body() -> Vec<u8> {
198        format!(r#"{{"subscription":{SUBSCRIPTION}}}"#).into_bytes()
199    }
200    const BALANCE: &str = r#"{"balance_cents":1234,"auto_pay_enabled":false,"auto_pay_threshold_cents":0,"auto_pay_amount_cents":0,"burn_cents_per_hour":0}"#;
201
202    fn endpoints(server: &mockito::Server) -> Endpoints {
203        Endpoints {
204            subscription: format!("{}/billing/subscription", server.url()),
205            balance: format!("{}/billing/balance", server.url()),
206        }
207    }
208
209    #[tokio::test]
210    async fn sends_bearer_key_and_parses_the_plan() {
211        let mut server = mockito::Server::new_async().await;
212        let subscription = server
213            .mock("GET", "/billing/subscription")
214            .match_header("authorization", "Bearer drl-test")
215            .match_header("accept", "application/json")
216            .with_status(200)
217            .with_body(SUBSCRIPTION)
218            .create_async()
219            .await;
220        // A subscriber with no wallet activity must not cost a second request.
221        let balance = server
222            .mock("GET", "/billing/balance")
223            .expect(0)
224            .create_async()
225            .await;
226        let (_dir, cache) = cache();
227
228        let outcome = fetch_snapshot(
229            &reqwest::Client::new(),
230            "drl-test",
231            &cache,
232            &endpoints(&server),
233            Duration::ZERO,
234        )
235        .await
236        .unwrap();
237
238        assert_eq!(outcome.snapshot.plan, "Plus");
239        assert_eq!(
240            outcome.snapshot.monthly_input.and_then(|m| m.pct()),
241            Some(32)
242        );
243        assert_eq!(outcome.snapshot.weekly_window().unwrap().resets_at, None);
244        assert_eq!(outcome.snapshot.balance_usd, None);
245        assert!(outcome.off_the_wire());
246        subscription.assert_async().await;
247        balance.assert_async().await;
248    }
249
250    #[tokio::test]
251    async fn reads_the_wallet_for_pay_as_you_go_accounts() {
252        let mut server = mockito::Server::new_async().await;
253        server
254            .mock("GET", "/billing/subscription")
255            .with_status(200)
256            .with_body(
257                r#"{"subscribed":false,"plan":null,"quota":null,
258                    "usage":null}"#,
259            )
260            .create_async()
261            .await;
262        server
263            .mock("GET", "/billing/balance")
264            .with_status(200)
265            .with_body(BALANCE)
266            .create_async()
267            .await;
268        let (_dir, cache) = cache();
269
270        let outcome = fetch_snapshot(
271            &reqwest::Client::new(),
272            "drl-test",
273            &cache,
274            &endpoints(&server),
275            Duration::ZERO,
276        )
277        .await
278        .unwrap();
279        assert_eq!(outcome.snapshot.plan, "Pay as you go");
280        assert_eq!(outcome.snapshot.balance_usd, Some(12.34));
281    }
282
283    #[tokio::test]
284    async fn a_failed_balance_call_keeps_the_plan_figures() {
285        let mut server = mockito::Server::new_async().await;
286        server
287            .mock("GET", "/billing/subscription")
288            .with_status(200)
289            .with_body(SUBSCRIPTION)
290            .create_async()
291            .await;
292        server
293            .mock("GET", "/billing/balance")
294            .with_status(500)
295            .with_body("{}")
296            .create_async()
297            .await;
298        let (_dir, cache) = cache();
299
300        let outcome = fetch_snapshot(
301            &reqwest::Client::new(),
302            "drl-test",
303            &cache,
304            &endpoints(&server),
305            Duration::ZERO,
306        )
307        .await
308        .unwrap();
309        assert_eq!(outcome.snapshot.plan, "Plus");
310        assert_eq!(outcome.snapshot.balance_usd, None);
311        assert!(!outcome.stale);
312    }
313
314    #[tokio::test]
315    async fn a_warm_cache_serves_the_previous_plan_without_a_request() {
316        let mut server = mockito::Server::new_async().await;
317        let request = server
318            .mock("GET", "/billing/subscription")
319            .expect(0)
320            .create_async()
321            .await;
322        let (_dir, cache) = cache();
323        cache.write_payload(&cached_body()).unwrap();
324
325        let outcome = fetch_snapshot(
326            &reqwest::Client::new(),
327            "drl-test",
328            &cache,
329            &endpoints(&server),
330            Duration::from_secs(60),
331        )
332        .await
333        .unwrap();
334        assert_eq!(outcome.snapshot.plan, "Plus");
335        assert!(!outcome.off_the_wire());
336        assert!(!outcome.stale);
337        request.assert_async().await;
338    }
339
340    #[tokio::test]
341    async fn an_insufficient_scope_key_says_which_scope_to_add() {
342        let mut server = mockito::Server::new_async().await;
343        server
344            .mock("GET", "/billing/subscription")
345            .with_status(403)
346            .with_body(
347                r#"{"type":"https://api.deeprelay.ai/errors/insufficient_scope",
348                    "title":"Forbidden","status":403,
349                    "detail":"This key is not allowed to read billing",
350                    "code":"insufficient_scope","request_id":"abc"}"#,
351            )
352            .create_async()
353            .await;
354        let (_dir, cache) = cache();
355
356        let error = fetch_snapshot(
357            &reqwest::Client::new(),
358            "drl-test",
359            &cache,
360            &endpoints(&server),
361            Duration::ZERO,
362        )
363        .await
364        .expect_err("scope failures have no cache to fall back on");
365        assert!(matches!(
366            &error,
367            AppError::Http { status: 403, body } if body.contains("billing:read")
368        ));
369    }
370
371    #[tokio::test]
372    async fn malformed_wallet_bodies_are_a_schema_mismatch() {
373        let mut server = mockito::Server::new_async().await;
374        server
375            .mock("GET", "/billing/subscription")
376            .with_status(200)
377            .with_body(r#"{"error":"unauthorized"}"#)
378            .create_async()
379            .await;
380        let (_dir, cache) = cache();
381
382        let error = fetch_snapshot(
383            &reqwest::Client::new(),
384            "drl-test",
385            &cache,
386            &endpoints(&server),
387            Duration::ZERO,
388        )
389        .await
390        .expect_err("a body that is not a subscription must not project one");
391        assert!(matches!(error, AppError::Schema(_)));
392    }
393
394    #[tokio::test]
395    async fn auth_failure_keeps_the_previous_plan_stale_and_redacted() {
396        let mut server = mockito::Server::new_async().await;
397        server
398            .mock("GET", "/billing/subscription")
399            .with_status(401)
400            .with_body(r#"{"detail":"bad key canary","code":"unauthenticated"}"#)
401            .create_async()
402            .await;
403        let (_dir, cache) = cache();
404        cache.write_payload(&cached_body()).unwrap();
405
406        let outcome = fetch_snapshot(
407            &reqwest::Client::new(),
408            "drl-test",
409            &cache,
410            &endpoints(&server),
411            Duration::ZERO,
412        )
413        .await
414        .unwrap();
415        assert!(outcome.stale);
416        let (_, message) = outcome.last_error.expect("auth diagnostic");
417        assert_eq!(message, crate::error::AUTH_FAILURE_MESSAGE);
418        assert!(!message.contains("canary"));
419    }
420
421    #[tokio::test]
422    async fn an_unparseable_cache_does_not_bypass_the_429_backoff() {
423        let mut server = mockito::Server::new_async().await;
424        let request = server
425            .mock("GET", "/billing/subscription")
426            .expect(0)
427            .create_async()
428            .await;
429        let (_dir, cache) = cache();
430        cache.write_payload(br#"{"unexpected":true}"#).unwrap();
431        cache.write_last_error(429, "rate limited");
432
433        let error = fetch_snapshot(
434            &reqwest::Client::new(),
435            "drl-test",
436            &cache,
437            &endpoints(&server),
438            Duration::ZERO,
439        )
440        .await
441        .expect_err("an invalid cache must not skip the backoff");
442        assert!(matches!(
443            error,
444            AppError::Http { status: 429, body } if body.contains("next attempt in")
445        ));
446        request.assert_async().await;
447    }
448}