Skip to main content

ai_usagebar/custom/
fetch.rs

1//! Fetch a custom provider: one GET with a static token, projected through
2//! the user's pointers.
3//!
4//! The cache holds the *projected* snapshot, not the response body. Every
5//! built-in vendor caches what the wire returned and re-parses it on fallback,
6//! which is fine because the vendor owns the schema. Here the schema is
7//! whatever the user pointed at: caching the body would keep every field they
8//! did not ask for — an account e-mail, an org id — on disk for `MAX_STALE`,
9//! and a later config edit would re-project a stale body through new
10//! pointers. The projection is small, holds only what the user chose, and
11//! round-trips through serde, so the fallback parse is `serde_json::from_slice`.
12
13use std::time::Duration;
14
15use reqwest::header::{HeaderName, HeaderValue};
16
17use crate::cache::{Cache, acquire_lock_async};
18use crate::config::CustomProviderConfig;
19use crate::display::sanitize_untrusted_line;
20use crate::error::{AppError, Result};
21
22use super::types::CustomSnapshot;
23
24const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
25const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
26
27/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
28/// specialised to its snapshot.
29pub type FetchOutcome = crate::outcome::Outcome<CustomSnapshot>;
30
31pub async fn fetch_snapshot(
32    client: &reqwest::Client,
33    spec: &CustomProviderConfig,
34    api_key: &str,
35    cache: &Cache,
36    cache_ttl: Duration,
37) -> Result<FetchOutcome> {
38    cache.ensure_dir()?;
39    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
40
41    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
42        && let Ok(outcome) = reuse_cache(bytes, cache, false)
43    {
44        return Ok(outcome);
45    }
46    // A fresh cache that will not parse is a projection written by an older
47    // build or a different pointer set: refetch rather than show it.
48
49    match fetch_live(client, spec, api_key).await {
50        Ok(snap) => {
51            let bytes = serde_json::to_vec(&snap)?;
52            cache.write_payload(&bytes)?;
53            Ok(crate::outcome::Outcome::fresh(snap))
54        }
55        Err(e) if e.is_transient() => fallback_silent(cache, e),
56        Err(AppError::Http { status, body }) => {
57            cache.mark_stale();
58            let last_error = Some(cache.write_last_error(status, &body));
59            fallback_with_error(cache, last_error, AppError::Http { status, body })
60        }
61        Err(e) => {
62            cache.mark_stale();
63            let last_error = Some(cache.write_last_error(0, &e.to_string()));
64            fallback_with_error(cache, last_error, e)
65        }
66    }
67}
68
69fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
70    crate::outcome::fallback(cache, None, original, parse_cache)
71}
72
73fn fallback_with_error(
74    cache: &Cache,
75    last_error: Option<(u16, String)>,
76    original: AppError,
77) -> Result<FetchOutcome> {
78    crate::outcome::fallback(cache, last_error, original, parse_cache)
79}
80
81fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
82    let snap = parse_cache(&bytes)?;
83    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
84}
85
86fn parse_cache(bytes: &[u8]) -> Result<CustomSnapshot> {
87    Ok(serde_json::from_slice(bytes)?)
88}
89
90async fn fetch_live(
91    client: &reqwest::Client,
92    spec: &CustomProviderConfig,
93    api_key: &str,
94) -> Result<CustomSnapshot> {
95    let id = spec.id.as_str();
96    let mut request = client
97        .get(spec.url.as_str())
98        .header("Accept", "application/json");
99    for (name, value) in &spec.headers {
100        request = request.header(header_name(id, name)?, header_value(id, name, value)?);
101    }
102    // Marked sensitive so reqwest's own debug output redacts it, the same way
103    // it treats `Authorization`, whatever header name the user chose.
104    let mut auth = header_value(
105        id,
106        &spec.auth_header,
107        &auth_value(&spec.auth_scheme, api_key),
108    )?;
109    auth.set_sensitive(true);
110    request = request.header(header_name(id, &spec.auth_header)?, auth);
111
112    // The timeout message deliberately omits the URL: a user may have put a
113    // query-string token in it, and this string ends up in `.last_error`.
114    let resp = tokio::time::timeout(HTTP_TIMEOUT, request.send())
115        .await
116        .map_err(|_| AppError::Transport(format!("custom {id}: request timed out")))??;
117
118    let status = resp.status();
119    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
120
121    if !status.is_success() {
122        let body = sanitize_untrusted_line(&String::from_utf8_lossy(&bytes))
123            .chars()
124            .take(200)
125            .collect();
126        return Err(AppError::Http {
127            status: status.as_u16(),
128            body,
129        });
130    }
131
132    // serde_json's message carries a line/column, never the text it failed on.
133    let body: serde_json::Value = serde_json::from_slice(&bytes)
134        .map_err(|e| AppError::Schema(format!("custom {id}: response is not JSON: {e}")))?;
135    super::mapping::project(&body, spec)
136}
137
138fn auth_value(scheme: &str, api_key: &str) -> String {
139    if scheme.is_empty() {
140        api_key.to_string()
141    } else {
142        format!("{scheme} {api_key}")
143    }
144}
145
146fn header_name(id: &str, name: &str) -> Result<HeaderName> {
147    HeaderName::from_bytes(name.as_bytes())
148        .map_err(|_| AppError::Other(format!("custom {id}: {name:?} is not a valid header name")))
149}
150
151/// The value is never part of the error: for the auth header it is the key.
152fn header_value(id: &str, name: &str, value: &str) -> Result<HeaderValue> {
153    HeaderValue::from_str(value).map_err(|_| {
154        AppError::Other(format!(
155            "custom {id}: header {name:?} has a value that is not valid in an HTTP header"
156        ))
157    })
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::config::CustomMetricSpec;
164    use crate::custom::types::{CustomMetric, CustomText};
165    use tempfile::TempDir;
166
167    const KEY: &str = "sk-custom-test-3f9a";
168
169    fn cache_fixture() -> (TempDir, Cache) {
170        let td = TempDir::new().unwrap();
171        let cache = Cache::at(td.path().join("custom").join("mytool"));
172        cache.ensure_dir().unwrap();
173        (td, cache)
174    }
175
176    fn spec_for(server: &mockito::ServerGuard) -> CustomProviderConfig {
177        CustomProviderConfig {
178            id: "mytool".into(),
179            name: "My Tool".into(),
180            short_name: "myt".into(),
181            enabled: true,
182            url: format!("{}/v1/usage", server.url()),
183            allow_http: true,
184            metrics: vec![CustomMetricSpec {
185                label: "Requests".into(),
186                used: Some("/requests/used".into()),
187                limit: Some("/requests/limit".into()),
188                ..CustomMetricSpec::default()
189            }],
190            ..CustomProviderConfig::default()
191        }
192    }
193
194    fn body() -> &'static str {
195        r#"{"requests": {"used": 25, "limit": 100}}"#
196    }
197
198    fn warm_snapshot() -> CustomSnapshot {
199        CustomSnapshot {
200            plan: Some("Pro".into()),
201            metrics: vec![CustomMetric {
202                label: "Requests".into(),
203                pct: 40,
204                footnote: "40 of 100".into(),
205                resets_at: None,
206                window_secs: None,
207            }],
208            texts: vec![CustomText {
209                label: "Tier".into(),
210                value: "gold".into(),
211            }],
212        }
213    }
214
215    async fn fetch(
216        spec: &CustomProviderConfig,
217        key: &str,
218        cache: &Cache,
219        ttl: Duration,
220    ) -> Result<FetchOutcome> {
221        fetch_snapshot(&reqwest::Client::new(), spec, key, cache, ttl).await
222    }
223
224    #[tokio::test]
225    async fn sends_the_bearer_token_and_returns_a_fresh_projection() {
226        let mut server = mockito::Server::new_async().await;
227        let mock = server
228            .mock("GET", "/v1/usage")
229            .match_header("authorization", format!("Bearer {KEY}").as_str())
230            .match_header("accept", "application/json")
231            .with_status(200)
232            .with_body(body())
233            .create_async()
234            .await;
235
236        let (_td, cache) = cache_fixture();
237        let out = fetch(&spec_for(&server), KEY, &cache, Duration::ZERO)
238            .await
239            .unwrap();
240        mock.assert_async().await;
241        assert!(!out.stale);
242        assert_eq!(out.last_error, None);
243        assert_eq!(out.snapshot.metrics[0].pct, 25);
244        assert_eq!(out.snapshot.metrics[0].footnote, "25 of 100");
245    }
246
247    #[tokio::test]
248    async fn an_empty_scheme_sends_the_raw_key() {
249        let mut server = mockito::Server::new_async().await;
250        let mock = server
251            .mock("GET", "/v1/usage")
252            .match_header("authorization", KEY)
253            .with_status(200)
254            .with_body(body())
255            .create_async()
256            .await;
257
258        let (_td, cache) = cache_fixture();
259        let mut spec = spec_for(&server);
260        spec.auth_scheme = String::new();
261        fetch(&spec, KEY, &cache, Duration::ZERO).await.unwrap();
262        mock.assert_async().await;
263    }
264
265    #[tokio::test]
266    async fn a_custom_auth_header_name_and_extra_headers_are_sent() {
267        let mut server = mockito::Server::new_async().await;
268        let mock = server
269            .mock("GET", "/v1/usage")
270            .match_header("x-api-key", KEY)
271            .match_header("x-org-id", "org_1")
272            .match_header("authorization", mockito::Matcher::Missing)
273            .with_status(200)
274            .with_body(body())
275            .create_async()
276            .await;
277
278        let (_td, cache) = cache_fixture();
279        let mut spec = spec_for(&server);
280        spec.auth_header = "x-api-key".into();
281        spec.auth_scheme = String::new();
282        spec.headers.insert("X-Org-Id".into(), "org_1".into());
283        fetch(&spec, KEY, &cache, Duration::ZERO).await.unwrap();
284        mock.assert_async().await;
285    }
286
287    #[tokio::test]
288    async fn a_fresh_cache_short_circuits_the_network() {
289        let mut server = mockito::Server::new_async().await;
290        let mock = server
291            .mock("GET", "/v1/usage")
292            .expect(0)
293            .create_async()
294            .await;
295
296        let (_td, cache) = cache_fixture();
297        cache
298            .write_payload(&serde_json::to_vec(&warm_snapshot()).unwrap())
299            .unwrap();
300        let out = fetch(&spec_for(&server), KEY, &cache, Duration::from_secs(3600))
301            .await
302            .unwrap();
303        mock.assert_async().await;
304        assert!(!out.stale);
305        assert_eq!(out.snapshot, warm_snapshot());
306    }
307
308    #[tokio::test]
309    async fn a_500_with_a_warm_cache_serves_the_cached_snapshot_with_the_error() {
310        let mut server = mockito::Server::new_async().await;
311        server
312            .mock("GET", "/v1/usage")
313            .with_status(500)
314            .with_body("upstream exploded")
315            .create_async()
316            .await;
317
318        let (_td, cache) = cache_fixture();
319        cache
320            .write_payload(&serde_json::to_vec(&warm_snapshot()).unwrap())
321            .unwrap();
322        let out = fetch(&spec_for(&server), KEY, &cache, Duration::ZERO)
323            .await
324            .unwrap();
325        assert!(out.stale);
326        assert_eq!(out.snapshot, warm_snapshot());
327        let (code, msg) = out.last_error.expect("the 500 must be reported");
328        assert_eq!(code, 500);
329        assert_eq!(msg, "upstream exploded");
330    }
331
332    #[tokio::test]
333    async fn a_401_with_no_cache_is_an_http_error_that_never_names_the_key() {
334        let mut server = mockito::Server::new_async().await;
335        server
336            .mock("GET", "/v1/usage")
337            .with_status(401)
338            .with_body("PANCEA denied \u{1b}[31m<credential>")
339            .create_async()
340            .await;
341
342        let (_td, cache) = cache_fixture();
343        let err = fetch(&spec_for(&server), KEY, &cache, Duration::ZERO)
344            .await
345            .unwrap_err();
346        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err:?}");
347        let shown = err.to_string();
348        assert!(!shown.contains(KEY), "{shown}");
349        assert!(!shown.contains('\u{1b}'), "{shown}");
350        assert!(
351            !err.user_message().contains("PANCEA"),
352            "{}",
353            err.user_message()
354        );
355        assert_eq!(
356            cache.read_last_error(),
357            Some((401, crate::error::AUTH_FAILURE_MESSAGE.to_string()))
358        );
359    }
360
361    /// The module header promises the cache holds the *projected* snapshot and
362    /// not the response body, because here the schema is whatever the user
363    /// pointed at — a body would park every field they did not select (an
364    /// e-mail, an org id) on disk for `MAX_STALE`. That is a property of what
365    /// `fetch` writes, so nothing but a test keeps a later refactor from
366    /// caching the body "to save a re-parse".
367    #[tokio::test]
368    async fn the_cache_holds_only_the_projection_never_the_body_or_the_key() {
369        let mut server = mockito::Server::new_async().await;
370        server
371            .mock("GET", "/v1/usage")
372            .with_status(200)
373            .with_body(
374                r#"{"requests": {"used": 25, "limit": 100},
375                    "account": {"email": "someone@example.com", "org_id": "org_1a2b"}}"#,
376            )
377            .create_async()
378            .await;
379
380        let (_td, cache) = cache_fixture();
381        fetch(&spec_for(&server), KEY, &cache, Duration::ZERO)
382            .await
383            .unwrap();
384
385        let raw = String::from_utf8(std::fs::read(cache.payload_path()).unwrap()).unwrap();
386        assert!(raw.contains("Requests"), "the projection is there: {raw}");
387        for leaked in [
388            KEY,
389            "someone@example.com",
390            "org_1a2b",
391            "account",
392            &server.url(),
393        ] {
394            assert!(!raw.contains(leaked), "cache leaked {leaked:?}: {raw}");
395        }
396    }
397
398    #[tokio::test]
399    async fn a_non_json_body_is_a_schema_error() {
400        let mut server = mockito::Server::new_async().await;
401        server
402            .mock("GET", "/v1/usage")
403            .with_status(200)
404            .with_body("<html>sign in</html>")
405            .create_async()
406            .await;
407
408        let (_td, cache) = cache_fixture();
409        let err = fetch(&spec_for(&server), KEY, &cache, Duration::ZERO)
410            .await
411            .unwrap_err();
412        assert!(matches!(err, AppError::Schema(_)), "{err:?}");
413        assert!(!err.to_string().contains("sign in"), "{err}");
414    }
415
416    #[tokio::test]
417    async fn a_body_that_misses_a_pointer_is_a_schema_error_naming_the_pointer() {
418        let mut server = mockito::Server::new_async().await;
419        server
420            .mock("GET", "/v1/usage")
421            .with_status(200)
422            .with_body(r#"{"requests": {"used": 1}}"#)
423            .create_async()
424            .await;
425
426        let (_td, cache) = cache_fixture();
427        let err = fetch(&spec_for(&server), KEY, &cache, Duration::ZERO)
428            .await
429            .unwrap_err();
430        assert!(
431            err.to_string().contains("/requests/limit is missing"),
432            "{err}"
433        );
434    }
435}