1use std::time::Duration;
4
5use crate::cache::{Cache, acquire_lock_async};
6use crate::error::{AppError, Result};
7use crate::usage::DeepseekSnapshot;
8
9use super::types::BalanceResponse;
10
11pub const BASE_URL: &str = "https://api.deepseek.com";
12const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
13const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14
15#[derive(Debug, Clone)]
16pub struct Endpoints {
17 pub balance: String,
18}
19
20impl Default for Endpoints {
21 fn default() -> Self {
22 Self {
23 balance: format!("{BASE_URL}/user/balance"),
24 }
25 }
26}
27
28pub type FetchOutcome = crate::outcome::Outcome<DeepseekSnapshot>;
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 match fetch_live(client, &endpoints.balance, api_key).await {
51 Ok(snap) => {
52 let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
53 cache.write_payload(&bytes)?;
54 Ok(crate::outcome::Outcome::fresh(snap))
55 }
56 Err(e) if e.is_transient() => fallback_silent(cache, e),
57 Err(AppError::Http { status, body }) => {
58 cache.mark_stale();
59 let last_error = Some(cache.write_last_error(status, &body));
60 fallback_with_error(cache, last_error, AppError::Http { status, body })
61 }
62 Err(e) => {
63 cache.mark_stale();
64 let last_error = Some(cache.write_last_error(0, &e.to_string()));
65 fallback_with_error(cache, last_error, e)
66 }
67 }
68}
69
70fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
71 crate::outcome::fallback(cache, None, original, parse_cache)
72}
73
74fn fallback_with_error(
75 cache: &Cache,
76 last_error: Option<(u16, String)>,
77 original: AppError,
78) -> Result<FetchOutcome> {
79 crate::outcome::fallback(cache, last_error, original, parse_cache)
80}
81
82fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
83 let snap = parse_cache(&bytes)?;
84 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
85}
86
87fn parse_cache(bytes: &[u8]) -> Result<DeepseekSnapshot> {
90 let v: serde_json::Value = serde_json::from_slice(bytes)?;
91 let money = |name: &str| -> Result<f64> {
92 let n = v[name]
93 .as_f64()
94 .ok_or_else(|| AppError::Schema(format!("deepseek cache missing '{name}'")))?;
95 if n.is_finite() {
96 Ok(n)
97 } else {
98 Err(AppError::Schema(format!(
99 "deepseek cache '{name}' is not finite"
100 )))
101 }
102 };
103 let currency = v["currency"]
104 .as_str()
105 .ok_or_else(|| AppError::Schema("deepseek cache missing 'currency'".into()))?;
106 if !matches!(currency, "USD" | "CNY") {
107 return Err(AppError::Schema(format!(
108 "deepseek cache has unsupported currency {currency:?}"
109 )));
110 }
111 Ok(DeepseekSnapshot {
112 is_available: v["is_available"]
113 .as_bool()
114 .ok_or_else(|| AppError::Schema("deepseek cache missing 'is_available'".into()))?,
115 balance: money("balance")?,
116 granted: money("granted")?,
117 topped_up: money("topped_up")?,
118 currency: currency.to_string(),
119 })
120}
121
122fn snap_to_json(snap: &DeepseekSnapshot) -> serde_json::Value {
123 serde_json::json!({
124 "is_available": snap.is_available,
125 "balance": snap.balance,
126 "granted": snap.granted,
127 "topped_up": snap.topped_up,
128 "currency": snap.currency,
129 })
130}
131
132async fn fetch_live(
133 client: &reqwest::Client,
134 url: &str,
135 api_key: &str,
136) -> Result<DeepseekSnapshot> {
137 let resp = tokio::time::timeout(
138 HTTP_TIMEOUT,
139 client
140 .get(url)
141 .header("Authorization", format!("Bearer {api_key}"))
142 .header("Accept", "application/json")
143 .send(),
144 )
145 .await
146 .map_err(|_| AppError::Transport(format!("deepseek timeout: {url}")))??;
147
148 let status = resp.status();
149 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
150
151 if !status.is_success() {
152 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
153 return Err(AppError::Http {
154 status: status.as_u16(),
155 body,
156 });
157 }
158
159 let r: BalanceResponse = serde_json::from_slice(&bytes)
160 .map_err(|e| AppError::Schema(format!("deepseek balance response: {e}")))?;
161 r.into_snapshot()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use tempfile::TempDir;
168
169 fn cache_fixture() -> (TempDir, Cache) {
170 let td = TempDir::new().unwrap();
171 let cache = Cache::at(td.path().join("deepseek"));
172 cache.ensure_dir().unwrap();
173 (td, cache)
174 }
175
176 #[test]
177 fn cached_unknown_currency_is_rejected_like_a_live_response() {
178 let cache = serde_json::json!({
179 "is_available": true,
180 "balance": 10.0,
181 "granted": 10.0,
182 "topped_up": 0.0,
183 "currency": "EUR"
184 });
185 let error = parse_cache(cache.to_string().as_bytes()).unwrap_err();
186 assert!(
187 error.to_string().contains("unsupported currency"),
188 "{error}"
189 );
190 }
191
192 #[tokio::test]
193 async fn live_200_returns_snapshot() {
194 let mut server = mockito::Server::new_async().await;
195 server
196 .mock("GET", "/user/balance")
197 .with_status(200)
198 .with_body(r#"{
199 "is_available": true,
200 "balance_infos": [
201 {"currency": "USD", "total_balance": "5.00", "granted_balance": "5.00", "topped_up_balance": "0.00"}
202 ]
203 }"#)
204 .create_async()
205 .await;
206
207 let (_td, cache) = cache_fixture();
208 let client = reqwest::Client::new();
209 let endpoints = Endpoints {
210 balance: format!("{}/user/balance", server.url()),
211 };
212 let out = fetch_snapshot(
213 &client,
214 "sk-test",
215 &cache,
216 &endpoints,
217 Duration::from_secs(0),
218 )
219 .await
220 .unwrap();
221 assert!(out.snapshot.is_available);
222 assert!((out.snapshot.balance - 5.0).abs() < 1e-9);
223 assert_eq!(out.snapshot.currency, "USD");
224 assert!(!out.stale);
225 }
226
227 #[tokio::test]
236 async fn a_401_body_does_not_reach_the_outcome_when_a_cache_is_warm() {
237 let mut server = mockito::Server::new_async().await;
238 server
239 .mock("GET", "/user/balance")
240 .with_status(401)
241 .with_body("PANCEA user@example.test <credential>&token")
242 .create_async()
243 .await;
244
245 let (_td, cache) = cache_fixture();
246 let warm = serde_json::json!({
247 "is_available": true,
248 "balance": 5.0,
249 "granted": 5.0,
250 "topped_up": 0.0,
251 "currency": "USD"
252 });
253 cache.write_payload(warm.to_string().as_bytes()).unwrap();
254
255 let endpoints = Endpoints {
256 balance: format!("{}/user/balance", server.url()),
257 };
258 let out = fetch_snapshot(
259 &reqwest::Client::new(),
260 "sk-test",
261 &cache,
262 &endpoints,
263 Duration::from_secs(0),
264 )
265 .await
266 .unwrap();
267
268 let (code, msg) = out.last_error.expect("the 401 must still be reported");
269 assert_eq!(code, 401);
270 assert_eq!(msg, crate::error::AUTH_FAILURE_MESSAGE);
271 assert!(!msg.contains("PANCEA"), "{msg}");
272 assert!(!msg.contains("<credential>"), "{msg}");
273 assert!(out.stale);
274 }
275
276 #[tokio::test]
277 async fn http_401_falls_back_to_cache() {
278 let mut server = mockito::Server::new_async().await;
279 server
280 .mock("GET", "/user/balance")
281 .with_status(401)
282 .with_body(r#"{"error": "invalid api key"}"#)
283 .create_async()
284 .await;
285
286 let (_td, cache) = cache_fixture();
287 let seed = serde_json::json!({
288 "is_available": true,
289 "balance": 3.0,
290 "granted": 3.0,
291 "topped_up": 0.0,
292 "currency": "USD"
293 });
294 cache.write_payload(seed.to_string().as_bytes()).unwrap();
295
296 let client = reqwest::Client::new();
297 let endpoints = Endpoints {
298 balance: format!("{}/user/balance", server.url()),
299 };
300 let out = fetch_snapshot(
301 &client,
302 "bad-key",
303 &cache,
304 &endpoints,
305 Duration::from_secs(0),
306 )
307 .await
308 .unwrap();
309 assert!(out.stale);
310 assert!((out.snapshot.balance - 3.0).abs() < 1e-9);
311 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
312 }
313}