1use std::time::Duration;
4
5use crate::cache::{Cache, acquire_lock_async};
6use crate::error::{AppError, Result};
7use crate::usage::DeepInfraSnapshot;
8
9use super::types::{Checklist, UsageResponse, combine};
10
11pub const BASE_URL: &str = "https://api.deepinfra.com";
12const HTTP_TIMEOUT: Duration = Duration::from_secs(20);
13const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14
15#[derive(Debug, Clone)]
16pub struct Endpoints {
17 pub checklist: String,
18 pub usage: String,
19}
20
21impl Default for Endpoints {
22 fn default() -> Self {
23 Self {
24 checklist: format!("{BASE_URL}/payment/checklist"),
25 usage: format!("{BASE_URL}/payment/usage?from=current"),
26 }
27 }
28}
29
30pub type FetchOutcome = crate::outcome::Outcome<DeepInfraSnapshot>;
31
32pub async fn fetch_snapshot(
33 client: &reqwest::Client,
34 api_key: &str,
35 cache: &Cache,
36 endpoints: &Endpoints,
37 cache_ttl: Duration,
38) -> Result<FetchOutcome> {
39 cache.ensure_dir()?;
40 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
41
42 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
43 && let Ok(outcome) = reuse_cache(bytes, cache, false)
44 {
45 return Ok(outcome);
46 }
47
48 match fetch_live(client, endpoints, api_key)
49 .await
50 .and_then(|(checklist, usage)| combine(checklist, usage))
51 {
52 Ok(snapshot) => {
53 let bytes = serde_json::to_vec(&serde_json::json!({
54 "snapshot": serde_repr(&snapshot),
55 }))?;
56 cache.write_payload(&bytes)?;
57 Ok(crate::outcome::Outcome::fresh(snapshot))
58 }
59 Err(error) if error.is_transient() => fallback_silent(cache, error),
60 Err(AppError::Http { status, body }) => {
61 cache.mark_stale();
62 let last_error = Some(cache.write_last_error(status, &body));
63 fallback_with_error(cache, last_error, AppError::Http { status, body })
64 }
65 Err(error) => {
66 cache.mark_stale();
67 let last_error = Some(cache.write_last_error(0, &error.to_string()));
68 fallback_with_error(cache, last_error, error)
69 }
70 }
71}
72
73fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
74 crate::outcome::fallback(cache, None, original, parse_cache)
75}
76
77fn fallback_with_error(
78 cache: &Cache,
79 last_error: Option<(u16, String)>,
80 original: AppError,
81) -> Result<FetchOutcome> {
82 crate::outcome::fallback(cache, last_error, original, parse_cache)
83}
84
85fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
86 let snapshot = parse_cache(&bytes)?;
87 Ok(crate::outcome::Outcome::cached(snapshot, cache, stale))
88}
89
90fn parse_cache(bytes: &[u8]) -> Result<DeepInfraSnapshot> {
91 let value: serde_json::Value = serde_json::from_slice(bytes)?;
92 let snapshot = value
93 .get("snapshot")
94 .ok_or_else(|| AppError::Schema("deepinfra cache missing 'snapshot' field".into()))?;
95 let money = |name: &str, nonnegative: bool| -> Result<f64> {
96 let number = snapshot
97 .get(name)
98 .and_then(serde_json::Value::as_f64)
99 .ok_or_else(|| AppError::Schema(format!("deepinfra cache missing '{name}'")))?;
100 if number.is_finite() && (!nonnegative || number >= 0.0) {
101 Ok(number)
102 } else {
103 Err(AppError::Schema(format!(
104 "deepinfra cache '{name}' is outside its valid range"
105 )))
106 }
107 };
108 let monthly_limit = match snapshot.get("monthly_limit") {
109 None | Some(serde_json::Value::Null) => None,
110 Some(_) => Some(money("monthly_limit", true)?),
111 };
112 let period = snapshot
113 .get("period")
114 .and_then(serde_json::Value::as_str)
115 .filter(|period| !period.trim().is_empty())
116 .ok_or_else(|| AppError::Schema("deepinfra cache missing 'period'".into()))?;
117
118 Ok(DeepInfraSnapshot {
119 balance: money("balance", false)?,
120 monthly_spend: money("monthly_spend", true)?,
121 monthly_limit,
122 period: period.to_string(),
123 })
124}
125
126fn serde_repr(snapshot: &DeepInfraSnapshot) -> serde_json::Value {
127 serde_json::json!({
128 "balance": snapshot.balance,
129 "monthly_spend": snapshot.monthly_spend,
130 "monthly_limit": snapshot.monthly_limit,
131 "period": snapshot.period,
132 })
133}
134
135async fn fetch_live(
136 client: &reqwest::Client,
137 endpoints: &Endpoints,
138 api_key: &str,
139) -> Result<(Checklist, UsageResponse)> {
140 let checklist = fetch_one::<Checklist>(client, &endpoints.checklist, api_key);
141 let usage = fetch_one::<UsageResponse>(client, &endpoints.usage, api_key);
142 let (checklist, usage) = tokio::join!(checklist, usage);
143 Ok((checklist?, usage?))
144}
145
146async fn fetch_one<T: for<'de> serde::Deserialize<'de>>(
147 client: &reqwest::Client,
148 url: &str,
149 api_key: &str,
150) -> Result<T> {
151 let response = tokio::time::timeout(HTTP_TIMEOUT, client.get(url).bearer_auth(api_key).send())
152 .await
153 .map_err(|_| AppError::Transport(format!("deepinfra timeout: {url}")))??;
154 let status = response.status();
155 let bytes = crate::vendor::read_body_capped(response, crate::vendor::MAX_BODY_BYTES).await?;
156
157 if !status.is_success() {
158 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
159 return Err(AppError::Http {
160 status: status.as_u16(),
161 body,
162 });
163 }
164
165 serde_json::from_slice(&bytes)
166 .map_err(|error| AppError::Schema(format!("deepinfra response: {error}")))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use tempfile::TempDir;
173
174 fn cache_fixture() -> (TempDir, Cache) {
175 let directory = TempDir::new().unwrap();
176 let cache = Cache::at(directory.path().join("deepinfra"));
177 cache.ensure_dir().unwrap();
178 (directory, cache)
179 }
180
181 #[tokio::test]
182 async fn live_fetch_combines_balance_and_usage() {
183 let mut server = mockito::Server::new_async().await;
184 let checklist = server
185 .mock("GET", "/payment/checklist")
186 .match_header("authorization", "Bearer test-key")
187 .with_status(200)
188 .with_body(r#"{"stripe_balance":-5.0,"recent":2.08,"limit":null}"#)
189 .create_async()
190 .await;
191 let usage = server
192 .mock("GET", "/payment/usage?from=current")
193 .match_header("authorization", "Bearer test-key")
194 .with_status(200)
195 .with_body(r#"{"months":[{"period":"2026.09","total_cost":208}]}"#)
196 .create_async()
197 .await;
198
199 let (_directory, cache) = cache_fixture();
200 let endpoints = Endpoints {
201 checklist: format!("{}/payment/checklist", server.url()),
202 usage: format!("{}/payment/usage?from=current", server.url()),
203 };
204 let outcome = fetch_snapshot(
205 &reqwest::Client::new(),
206 "test-key",
207 &cache,
208 &endpoints,
209 Duration::ZERO,
210 )
211 .await
212 .unwrap();
213
214 checklist.assert_async().await;
215 usage.assert_async().await;
216 assert!((outcome.snapshot.balance - 2.92).abs() < 1e-9);
217 assert!((outcome.snapshot.monthly_spend - 2.08).abs() < 1e-9);
218 assert!(!outcome.stale);
219 }
220
221 #[tokio::test]
222 async fn an_http_error_falls_back_to_cache() {
223 let mut server = mockito::Server::new_async().await;
224 server
225 .mock("GET", "/payment/checklist")
226 .with_status(401)
227 .with_body(r#"{"detail":"unauthorized"}"#)
228 .create_async()
229 .await;
230
231 let (_directory, cache) = cache_fixture();
232 cache
233 .write_payload(
234 &serde_json::to_vec(&serde_json::json!({
235 "snapshot": {
236 "balance": 2.92,
237 "monthly_spend": 2.08,
238 "monthly_limit": null,
239 "period": "2026.09"
240 }
241 }))
242 .unwrap(),
243 )
244 .unwrap();
245 let endpoints = Endpoints {
246 checklist: format!("{}/payment/checklist", server.url()),
247 usage: format!("{}/payment/usage?from=current", server.url()),
248 };
249 let outcome = fetch_snapshot(
250 &reqwest::Client::new(),
251 "revoked-key",
252 &cache,
253 &endpoints,
254 Duration::ZERO,
255 )
256 .await
257 .unwrap();
258
259 assert!(outcome.stale);
260 assert_eq!(outcome.last_error.as_ref().map(|error| error.0), Some(401));
261 assert!((outcome.snapshot.balance - 2.92).abs() < 1e-9);
262 }
263
264 #[tokio::test]
265 async fn fresh_cache_skips_both_endpoints() {
266 let (_directory, cache) = cache_fixture();
267 cache
268 .write_payload(
269 &serde_json::to_vec(&serde_json::json!({
270 "snapshot": {
271 "balance": 2.92,
272 "monthly_spend": 2.08,
273 "monthly_limit": null,
274 "period": "2026.09"
275 }
276 }))
277 .unwrap(),
278 )
279 .unwrap();
280 let endpoints = Endpoints {
281 checklist: "http://127.0.0.1:1/payment/checklist".into(),
282 usage: "http://127.0.0.1:1/payment/usage?from=current".into(),
283 };
284
285 let outcome = fetch_snapshot(
286 &reqwest::Client::new(),
287 "test-key",
288 &cache,
289 &endpoints,
290 Duration::from_secs(60),
291 )
292 .await
293 .unwrap();
294
295 assert!(!outcome.stale);
296 assert!((outcome.snapshot.balance - 2.92).abs() < 1e-9);
297 }
298}