1use std::time::Duration;
4
5use crate::cache::{Cache, MAX_STALE, 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
28#[derive(Debug, Clone)]
29pub struct FetchOutcome {
30 pub snapshot: DeepseekSnapshot,
31 pub stale: bool,
32 pub last_error: Option<(u16, String)>,
33 pub cache_age: Option<Duration>,
34}
35
36pub async fn fetch_snapshot(
37 client: &reqwest::Client,
38 api_key: &str,
39 cache: &Cache,
40 endpoints: &Endpoints,
41 cache_ttl: Duration,
42) -> Result<FetchOutcome> {
43 cache.ensure_dir()?;
44 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
45
46 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
47 && let Ok(outcome) = reuse_cache(bytes, cache, false)
48 {
49 return Ok(outcome);
50 }
51 match fetch_live(client, &endpoints.balance, api_key).await {
55 Ok(snap) => {
56 let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
57 cache.write_payload(&bytes)?;
58 Ok(FetchOutcome {
59 snapshot: snap,
60 stale: false,
61 last_error: None,
62 cache_age: Some(Duration::ZERO),
63 })
64 }
65 Err(e) if e.is_transient() => fallback_silent(cache),
66 Err(AppError::Http { status, body }) => {
67 cache.mark_stale();
68 cache.write_last_error(status, &body);
69 fallback_with_error(cache, Some((status, body)))
70 }
71 Err(e) => {
72 cache.mark_stale();
73 cache.write_last_error(0, &e.to_string());
74 fallback_with_error(cache, Some((0, e.to_string())))
75 }
76 }
77}
78
79fn fallback_silent(cache: &Cache) -> Result<FetchOutcome> {
80 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
81 return Err(AppError::Transport(
82 "deepseek: no cache and network unreachable".into(),
83 ));
84 };
85 reuse_cache(bytes, cache, true)
86}
87
88fn fallback_with_error(cache: &Cache, last_error: Option<(u16, String)>) -> Result<FetchOutcome> {
89 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
90 return Err(AppError::Other("deepseek: no usable cache".into()));
91 };
92 let mut outcome = reuse_cache(bytes, cache, true)?;
93 outcome.last_error = last_error;
94 Ok(outcome)
95}
96
97fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
98 let snap = parse_cache(&bytes)?;
99 Ok(FetchOutcome {
100 snapshot: snap,
101 stale,
102 last_error: cache.read_last_error(),
103 cache_age: cache.payload_age(),
104 })
105}
106
107fn parse_cache(bytes: &[u8]) -> Result<DeepseekSnapshot> {
110 let v: serde_json::Value = serde_json::from_slice(bytes)?;
111 let money = |name: &str| -> Result<f64> {
112 let n = v[name]
113 .as_f64()
114 .ok_or_else(|| AppError::Schema(format!("deepseek cache missing '{name}'")))?;
115 if n.is_finite() {
116 Ok(n)
117 } else {
118 Err(AppError::Schema(format!(
119 "deepseek cache '{name}' is not finite"
120 )))
121 }
122 };
123 let currency = v["currency"]
124 .as_str()
125 .ok_or_else(|| AppError::Schema("deepseek cache missing 'currency'".into()))?;
126 if !matches!(currency, "USD" | "CNY") {
127 return Err(AppError::Schema(format!(
128 "deepseek cache has unsupported currency {currency:?}"
129 )));
130 }
131 Ok(DeepseekSnapshot {
132 is_available: v["is_available"]
133 .as_bool()
134 .ok_or_else(|| AppError::Schema("deepseek cache missing 'is_available'".into()))?,
135 balance: money("balance")?,
136 granted: money("granted")?,
137 topped_up: money("topped_up")?,
138 currency: currency.to_string(),
139 })
140}
141
142fn snap_to_json(snap: &DeepseekSnapshot) -> serde_json::Value {
143 serde_json::json!({
144 "is_available": snap.is_available,
145 "balance": snap.balance,
146 "granted": snap.granted,
147 "topped_up": snap.topped_up,
148 "currency": snap.currency,
149 })
150}
151
152async fn fetch_live(
153 client: &reqwest::Client,
154 url: &str,
155 api_key: &str,
156) -> Result<DeepseekSnapshot> {
157 let resp = tokio::time::timeout(
158 HTTP_TIMEOUT,
159 client
160 .get(url)
161 .header("Authorization", format!("Bearer {api_key}"))
162 .header("Accept", "application/json")
163 .send(),
164 )
165 .await
166 .map_err(|_| AppError::Transport(format!("deepseek timeout: {url}")))??;
167
168 let status = resp.status();
169 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
170
171 if !status.is_success() {
172 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
173 return Err(AppError::Http {
174 status: status.as_u16(),
175 body,
176 });
177 }
178
179 let r: BalanceResponse = serde_json::from_slice(&bytes)
180 .map_err(|e| AppError::Schema(format!("deepseek balance response: {e}")))?;
181 r.into_snapshot()
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use tempfile::TempDir;
188
189 fn cache_fixture() -> (TempDir, Cache) {
190 let td = TempDir::new().unwrap();
191 let cache = Cache::at(td.path().join("deepseek"));
192 cache.ensure_dir().unwrap();
193 (td, cache)
194 }
195
196 #[test]
197 fn cached_unknown_currency_is_rejected_like_a_live_response() {
198 let cache = serde_json::json!({
199 "is_available": true,
200 "balance": 10.0,
201 "granted": 10.0,
202 "topped_up": 0.0,
203 "currency": "EUR"
204 });
205 let error = parse_cache(cache.to_string().as_bytes()).unwrap_err();
206 assert!(
207 error.to_string().contains("unsupported currency"),
208 "{error}"
209 );
210 }
211
212 #[tokio::test]
213 async fn live_200_returns_snapshot() {
214 let mut server = mockito::Server::new_async().await;
215 server
216 .mock("GET", "/user/balance")
217 .with_status(200)
218 .with_body(r#"{
219 "is_available": true,
220 "balance_infos": [
221 {"currency": "USD", "total_balance": "5.00", "granted_balance": "5.00", "topped_up_balance": "0.00"}
222 ]
223 }"#)
224 .create_async()
225 .await;
226
227 let (_td, cache) = cache_fixture();
228 let client = reqwest::Client::new();
229 let endpoints = Endpoints {
230 balance: format!("{}/user/balance", server.url()),
231 };
232 let out = fetch_snapshot(
233 &client,
234 "sk-test",
235 &cache,
236 &endpoints,
237 Duration::from_secs(0),
238 )
239 .await
240 .unwrap();
241 assert!(out.snapshot.is_available);
242 assert!((out.snapshot.balance - 5.0).abs() < 1e-9);
243 assert_eq!(out.snapshot.currency, "USD");
244 assert!(!out.stale);
245 }
246
247 #[tokio::test]
248 async fn http_401_falls_back_to_cache() {
249 let mut server = mockito::Server::new_async().await;
250 server
251 .mock("GET", "/user/balance")
252 .with_status(401)
253 .with_body(r#"{"error": "invalid api key"}"#)
254 .create_async()
255 .await;
256
257 let (_td, cache) = cache_fixture();
258 let seed = serde_json::json!({
259 "is_available": true,
260 "balance": 3.0,
261 "granted": 3.0,
262 "topped_up": 0.0,
263 "currency": "USD"
264 });
265 cache.write_payload(seed.to_string().as_bytes()).unwrap();
266
267 let client = reqwest::Client::new();
268 let endpoints = Endpoints {
269 balance: format!("{}/user/balance", server.url()),
270 };
271 let out = fetch_snapshot(
272 &client,
273 "bad-key",
274 &cache,
275 &endpoints,
276 Duration::from_secs(0),
277 )
278 .await
279 .unwrap();
280 assert!(out.stale);
281 assert!((out.snapshot.balance - 3.0).abs() < 1e-9);
282 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
283 }
284}