1use 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
27pub 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 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 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 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 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
151fn 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 #[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}