1use std::time::Duration;
7
8use crate::cache::{Cache, MAX_STALE, acquire_lock_async};
9use crate::error::{AppError, Result};
10use crate::usage::{MoonshotSnapshot, finite_amount};
11use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
12
13use super::types::{BalanceEnvelope, to_snapshot};
14
15pub const BASE_GLOBAL: &str = "https://api.moonshot.ai";
16pub const BASE_CN: &str = "https://api.moonshot.cn";
17const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
18const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
19
20#[derive(Debug, Clone)]
21pub struct Endpoints {
22 pub balance: String,
23}
24
25impl Endpoints {
26 pub fn for_region(region: &str) -> (Self, &'static str) {
29 if region.eq_ignore_ascii_case("cn") {
30 (
31 Self {
32 balance: format!("{BASE_CN}/v1/users/me/balance"),
33 },
34 "CNY",
35 )
36 } else {
37 (
38 Self {
39 balance: format!("{BASE_GLOBAL}/v1/users/me/balance"),
40 },
41 "USD",
42 )
43 }
44 }
45}
46
47impl Default for Endpoints {
48 fn default() -> Self {
49 Self::for_region("global").0
50 }
51}
52
53#[derive(Debug, Clone)]
54pub struct FetchOutcome {
55 pub snapshot: MoonshotSnapshot,
56 pub stale: bool,
57 pub last_error: Option<(u16, String)>,
58 pub cache_age: Option<Duration>,
59}
60
61pub async fn fetch_snapshot(
62 client: &reqwest::Client,
63 api_key: &str,
64 cache: &Cache,
65 endpoints: &Endpoints,
66 cache_ttl: Duration,
67 currency: &str,
68) -> Result<FetchOutcome> {
69 cache.ensure_dir()?;
70 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
71
72 let target = target_key(endpoints, currency);
73
74 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
75 && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
76 {
77 return Ok(outcome);
78 }
79
80 match fetch_live(client, endpoints, api_key, currency).await {
81 Ok(snap) => {
82 let bytes = serde_json::to_vec(
83 &serde_json::json!({ "target": target, "snapshot": serde_repr(&snap) }),
84 )?;
85 cache.write_payload(&bytes)?;
86 Ok(FetchOutcome {
87 snapshot: snap,
88 stale: false,
89 last_error: None,
90 cache_age: Some(Duration::ZERO),
91 })
92 }
93 Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
94 Err(AppError::Http { status, body }) => {
95 cache.mark_stale();
96 cache.write_last_error(status, &body);
97 let diag = (status, body.clone());
98 fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
99 }
100 Err(e) => {
101 cache.mark_stale();
102 cache.write_last_error(0, &e.to_string());
103 let diag = (0, e.to_string());
104 fallback_with_error(cache, Some(diag), &target, e)
105 }
106 }
107}
108
109fn target_key(endpoints: &Endpoints, currency: &str) -> String {
113 format!("{}|{}", endpoints.balance, currency)
114}
115
116fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
117 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
118 return Err(original);
119 };
120 reuse_cache(&bytes, cache, true, target)
121}
122
123fn fallback_with_error(
127 cache: &Cache,
128 last_error: Option<(u16, String)>,
129 target: &str,
130 original: AppError,
131) -> Result<FetchOutcome> {
132 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
133 return Err(original);
134 };
135 let Ok(mut outcome) = reuse_cache(&bytes, cache, true, target) else {
137 return Err(original);
138 };
139 outcome.last_error = last_error;
140 Ok(outcome)
141}
142
143fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
144 let snap = parse_cache(bytes, target)?;
145 Ok(FetchOutcome {
146 snapshot: snap,
147 stale,
148 last_error: cache.read_last_error(),
149 cache_age: cache.payload_age(),
150 })
151}
152
153fn serde_repr(snap: &MoonshotSnapshot) -> serde_json::Value {
154 serde_json::json!({
155 "available": snap.available,
156 "voucher": snap.voucher,
157 "cash": snap.cash,
158 "currency": snap.currency,
159 })
160}
161
162fn parse_cache(bytes: &[u8], target: &str) -> Result<MoonshotSnapshot> {
163 let v: serde_json::Value = serde_json::from_slice(bytes)?;
164 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
167 if cached_target != Some(target) {
168 return Err(AppError::Schema(format!(
169 "moonshot cache belongs to a different endpoint/currency ({}); refetching",
170 cached_target.unwrap_or("unknown")
171 )));
172 }
173 let s = v
174 .get("snapshot")
175 .ok_or_else(|| AppError::Schema("moonshot cache missing 'snapshot' field".into()))?;
176 let field = |name: &str| -> Result<f64> {
177 let v = s[name]
178 .as_f64()
179 .ok_or_else(|| AppError::Schema(format!("moonshot cache missing '{name}'")))?;
180 finite_amount("moonshot cache", name, v)
181 };
182 Ok(MoonshotSnapshot {
183 available: field("available")?,
184 voucher: field("voucher")?,
185 cash: field("cash")?,
186 currency: s["currency"]
187 .as_str()
188 .ok_or_else(|| AppError::Schema("moonshot cache missing 'currency'".into()))?
189 .to_string(),
190 })
191}
192
193async fn fetch_live(
194 client: &reqwest::Client,
195 endpoints: &Endpoints,
196 api_key: &str,
197 currency: &str,
198) -> Result<MoonshotSnapshot> {
199 let resp = tokio::time::timeout(
200 HTTP_TIMEOUT,
201 client
202 .get(&endpoints.balance)
203 .header("Authorization", format!("Bearer {api_key}"))
204 .send(),
205 )
206 .await
207 .map_err(|_| AppError::Transport(format!("moonshot timeout: {}", endpoints.balance)))??;
208
209 let status = resp.status();
210 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
211
212 if !status.is_success() {
213 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
214 return Err(AppError::Http {
215 status: status.as_u16(),
216 body,
217 });
218 }
219 let env: BalanceEnvelope = serde_json::from_slice(&bytes)
220 .map_err(|e| AppError::Schema(format!("moonshot {}: {e}", endpoints.balance)))?;
221 env.check_ok()?;
223 to_snapshot(env.data, currency)
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use tempfile::TempDir;
230
231 fn cache_fixture() -> (TempDir, Cache) {
232 let td = TempDir::new().unwrap();
233 let cache = Cache::at(td.path().join("moonshot"));
234 cache.ensure_dir().unwrap();
235 (td, cache)
236 }
237
238 #[test]
239 fn region_picks_host_and_currency() {
240 let (global, cur) = Endpoints::for_region("global");
241 assert!(global.balance.starts_with("https://api.moonshot.ai"));
242 assert_eq!(cur, "USD");
243 let (cn, cur_cn) = Endpoints::for_region("cn");
244 assert!(cn.balance.starts_with("https://api.moonshot.cn"));
245 assert_eq!(cur_cn, "CNY");
246 }
247
248 #[tokio::test]
249 async fn live_fetch_reads_available_balance() {
250 let mut server = mockito::Server::new_async().await;
251 server
252 .mock("GET", "/v1/users/me/balance")
253 .match_header("authorization", "Bearer ms-test")
254 .with_status(200)
255 .with_body(
256 r#"{"code":0,"data":{"available_balance":49.58894,
257 "voucher_balance":46.58893,"cash_balance":3.00001},
258 "scode":"0x0","status":true}"#,
259 )
260 .create_async()
261 .await;
262
263 let (_td, cache) = cache_fixture();
264 let client = reqwest::Client::new();
265 let endpoints = Endpoints {
266 balance: format!("{}/v1/users/me/balance", server.url()),
267 };
268 let out = fetch_snapshot(
269 &client,
270 "ms-test",
271 &cache,
272 &endpoints,
273 Duration::from_secs(0),
274 "USD",
275 )
276 .await
277 .unwrap();
278 assert!((out.snapshot.available - 49.58894).abs() < 1e-6);
279 assert_eq!(out.snapshot.currency, "USD");
280 assert!(!out.stale);
281 }
282
283 #[tokio::test]
284 async fn http_error_falls_back_to_cache_when_present() {
285 let mut server = mockito::Server::new_async().await;
286 server
287 .mock("GET", "/v1/users/me/balance")
288 .with_status(401)
289 .with_body(r#"{"error":"auth"}"#)
290 .create_async()
291 .await;
292
293 let (_td, cache) = cache_fixture();
294 let endpoints = Endpoints {
295 balance: format!("{}/v1/users/me/balance", server.url()),
296 };
297 let seed = serde_json::json!({
298 "target": target_key(&endpoints, "USD"),
299 "snapshot": {
300 "available": 49.0, "voucher": 46.0, "cash": 3.0, "currency": "USD"
301 },
302 });
303 cache.write_payload(seed.to_string().as_bytes()).unwrap();
304
305 let client = reqwest::Client::new();
306 let out = fetch_snapshot(
307 &client,
308 "k",
309 &cache,
310 &endpoints,
311 Duration::from_secs(0),
312 "USD",
313 )
314 .await
315 .unwrap();
316 assert!(out.stale);
317 assert_eq!(out.snapshot.available, 49.0);
318 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
319 }
320
321 #[tokio::test]
322 async fn in_band_failure_on_200_is_not_a_zero_balance() {
323 let mut server = mockito::Server::new_async().await;
325 server
326 .mock("GET", "/v1/users/me/balance")
327 .with_status(200)
328 .with_body(r#"{"code":40100,"data":{"available_balance":0.0,"voucher_balance":0.0,"cash_balance":0.0},"status":false,"scode":"0x1"}"#)
329 .create_async()
330 .await;
331
332 let (_td, cache) = cache_fixture();
333 let client = reqwest::Client::new();
334 let endpoints = Endpoints {
335 balance: format!("{}/v1/users/me/balance", server.url()),
336 };
337 let out = fetch_snapshot(
338 &client,
339 "k",
340 &cache,
341 &endpoints,
342 Duration::from_secs(0),
343 "USD",
344 )
345 .await;
346 assert!(out.is_err(), "expected a schema error, got {out:?}");
347 }
348
349 #[tokio::test]
350 async fn switching_region_refetches_instead_of_reusing_the_cache() {
351 let mut server = mockito::Server::new_async().await;
353 server
354 .mock("GET", "/v1/users/me/balance")
355 .with_status(200)
356 .with_body(
357 r#"{"code":0,"data":{"available_balance":12.0,"voucher_balance":0.0,
358 "cash_balance":12.0},"status":true}"#,
359 )
360 .create_async()
361 .await;
362
363 let (_td, cache) = cache_fixture();
364 let endpoints = Endpoints {
365 balance: format!("{}/v1/users/me/balance", server.url()),
366 };
367 let seed = serde_json::json!({
369 "target": target_key(&endpoints, "CNY"),
370 "snapshot": {
371 "available": 999.0, "voucher": 0.0, "cash": 999.0, "currency": "CNY"
372 },
373 });
374 cache.write_payload(seed.to_string().as_bytes()).unwrap();
375
376 let client = reqwest::Client::new();
377 let out = fetch_snapshot(
378 &client,
379 "k",
380 &cache,
381 &endpoints,
382 Duration::from_secs(3600),
383 "USD",
384 )
385 .await
386 .unwrap();
387 assert_eq!(out.snapshot.available, 12.0);
388 assert_eq!(out.snapshot.currency, "USD");
389 assert!(!out.stale);
390 }
391}