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