1use std::time::Duration;
9
10use crate::cache::{Cache, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::OrcaRouterSnapshot;
13use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
14
15use super::types::{SubscriptionResponse, UsageResponse, combine};
16
17pub const BASE_URL: &str = "https://api.orcarouter.ai/v1";
18const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
19const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
20
21#[derive(Debug, Clone)]
22pub struct Endpoints {
23 pub usage: String,
24 pub subscription: String,
25}
26
27impl Default for Endpoints {
28 fn default() -> Self {
29 Self {
30 usage: format!("{BASE_URL}/dashboard/billing/usage"),
31 subscription: format!("{BASE_URL}/dashboard/billing/subscription"),
32 }
33 }
34}
35
36pub type FetchOutcome = crate::outcome::Outcome<OrcaRouterSnapshot>;
39
40pub async fn fetch_snapshot(
42 client: &reqwest::Client,
43 api_key: &str,
44 cache: &Cache,
45 endpoints: &Endpoints,
46 cache_ttl: Duration,
47) -> Result<FetchOutcome> {
48 cache.ensure_dir()?;
49 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
50
51 let target = target_key(endpoints, api_key);
52
53 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
54 && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
55 {
56 return Ok(outcome);
57 }
58
59 match fetch_live(client, endpoints, api_key).await {
60 Ok((usage, subscription)) => {
61 let snap = combine(&usage, &subscription);
62 let cache_repr = serde_json::json!({
63 "target": target,
64 "snapshot": serde_repr(&snap),
65 });
66 let bytes = serde_json::to_vec(&cache_repr)?;
67 cache.write_payload(&bytes)?;
68 Ok(crate::outcome::Outcome::fresh(snap))
69 }
70 Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
71 Err(AppError::Http { status, body }) => {
72 cache.mark_stale();
73 let diag = cache.write_last_error(status, &body);
74 fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
75 }
76 Err(e) => {
77 cache.mark_stale();
78 let diag = cache.write_last_error(0, &e.to_string());
79 fallback_with_error(cache, Some(diag), &target, e)
80 }
81 }
82}
83
84fn target_key(endpoints: &Endpoints, api_key: &str) -> String {
89 use std::hash::{Hash, Hasher};
90 let mut hasher = std::collections::hash_map::DefaultHasher::new();
91 api_key.hash(&mut hasher);
92 format!("{}|key:{:016x}", endpoints.usage, hasher.finish())
93}
94
95fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
96 crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, target))
97}
98
99fn fallback_with_error(
100 cache: &Cache,
101 last_error: Option<(u16, String)>,
102 target: &str,
103 original: AppError,
104) -> Result<FetchOutcome> {
105 crate::outcome::fallback(cache, last_error, original, |bytes| {
106 parse_cache(bytes, target)
107 })
108}
109
110fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
111 let snap = parse_cache(bytes, target)?;
112 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
113}
114
115fn serde_repr(snap: &OrcaRouterSnapshot) -> serde_json::Value {
116 serde_json::json!({
117 "spent_cents": snap.spent_cents,
118 "limit_cents": snap.limit_cents,
119 "access_until": snap.access_until.map(|t| t.timestamp()),
120 })
121}
122
123fn parse_cache(bytes: &[u8], target: &str) -> Result<OrcaRouterSnapshot> {
127 let v: serde_json::Value = serde_json::from_slice(bytes)?;
128 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
129 if cached_target != Some(target) {
130 return Err(AppError::Schema(format!(
131 "orcarouter cache belongs to a different key ({}); refetching",
132 cached_target.unwrap_or("unknown")
133 )));
134 }
135 let s = v
136 .get("snapshot")
137 .ok_or_else(|| AppError::Schema("orcarouter cache missing 'snapshot' field".into()))?;
138 let spent_cents = s["spent_cents"]
139 .as_i64()
140 .ok_or_else(|| AppError::Schema("orcarouter cache missing 'spent_cents'".into()))?;
141 if spent_cents < 0 {
142 return Err(AppError::Schema(
143 "orcarouter cache 'spent_cents' cannot be negative".into(),
144 ));
145 }
146 let limit_cents = match s.get("limit_cents") {
147 None | Some(serde_json::Value::Null) => None,
148 Some(value) => {
149 let cents = value.as_i64().ok_or_else(|| {
150 AppError::Schema("orcarouter cache 'limit_cents' is not an integer".into())
151 })?;
152 if cents <= 0 {
153 return Err(AppError::Schema(
154 "orcarouter cache 'limit_cents' must be positive".into(),
155 ));
156 }
157 Some(cents)
158 }
159 };
160 let access_until = match s.get("access_until") {
161 None | Some(serde_json::Value::Null) => None,
162 Some(value) => {
163 let secs = value.as_i64().ok_or_else(|| {
164 AppError::Schema("orcarouter cache 'access_until' is not an integer".into())
165 })?;
166 if secs <= 0 {
167 return Err(AppError::Schema(
168 "orcarouter cache 'access_until' must be a positive Unix timestamp".into(),
169 ));
170 }
171 Some(chrono::DateTime::from_timestamp(secs, 0).ok_or_else(|| {
172 AppError::Schema("orcarouter cache 'access_until' is out of range".into())
173 })?)
174 }
175 };
176 Ok(OrcaRouterSnapshot {
177 spent_cents,
178 limit_cents,
179 access_until,
180 })
181}
182
183async fn fetch_live(
184 client: &reqwest::Client,
185 endpoints: &Endpoints,
186 api_key: &str,
187) -> Result<(UsageResponse, SubscriptionResponse)> {
188 let usage_fut = fetch_one::<UsageResponse>(client, &endpoints.usage, api_key);
189 let subscription_fut =
190 fetch_one::<SubscriptionResponse>(client, &endpoints.subscription, api_key);
191 let (usage, subscription) = tokio::join!(usage_fut, subscription_fut);
192 Ok((usage?, subscription?))
193}
194
195async fn fetch_one<T: for<'de> serde::Deserialize<'de>>(
196 client: &reqwest::Client,
197 url: &str,
198 api_key: &str,
199) -> Result<T> {
200 let resp = tokio::time::timeout(
201 HTTP_TIMEOUT,
202 client
203 .get(url)
204 .header("Authorization", format!("Bearer {api_key}"))
205 .send(),
206 )
207 .await
208 .map_err(|_| AppError::Transport(format!("orcarouter timeout: {url}")))??;
209
210 let status = resp.status();
211 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
212
213 if !status.is_success() {
214 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
215 return Err(AppError::Http {
216 status: status.as_u16(),
217 body,
218 });
219 }
220 if let Some(message) = error_envelope(&bytes) {
224 return Err(AppError::Schema(format!(
225 "orcarouter {url}: error envelope: {message}"
226 )));
227 }
228 serde_json::from_slice(&bytes).map_err(|e| AppError::Schema(format!("orcarouter {url}: {e}")))
229}
230
231fn error_envelope(bytes: &[u8]) -> Option<String> {
234 let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
235 let message = v.get("error")?.get("message")?.as_str()?;
236 Some(message.chars().take(200).collect())
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use tempfile::TempDir;
243
244 const USAGE_BODY: &str = r#"{"object":"list","total_usage":275}"#;
245 const SUBSCRIPTION_BODY: &str = r#"{
246 "object":"billing_subscription",
247 "has_payment_method":true,
248 "soft_limit_usd":12.5,
249 "hard_limit_usd":12.5,
250 "system_hard_limit_usd":12.5,
251 "access_until":1790000000
252 }"#;
253
254 fn cache_fixture() -> (TempDir, Cache) {
255 let td = TempDir::new().unwrap();
256 let cache = Cache::at(td.path().join("orcarouter"));
257 cache.ensure_dir().unwrap();
258 (td, cache)
259 }
260
261 fn endpoints_for(server: &mockito::Server) -> Endpoints {
262 Endpoints {
263 usage: format!("{}/v1/dashboard/billing/usage", server.url()),
264 subscription: format!("{}/v1/dashboard/billing/subscription", server.url()),
265 }
266 }
267
268 async fn mock_both(server: &mut mockito::Server, usage: &str, subscription: &str) {
269 server
270 .mock("GET", "/v1/dashboard/billing/usage")
271 .match_header("authorization", "Bearer sk-orca-test")
272 .with_status(200)
273 .with_body(usage)
274 .create_async()
275 .await;
276 server
277 .mock("GET", "/v1/dashboard/billing/subscription")
278 .match_header("authorization", "Bearer sk-orca-test")
279 .with_status(200)
280 .with_body(subscription)
281 .create_async()
282 .await;
283 }
284
285 #[tokio::test]
286 async fn live_fetch_combines_both_endpoints() {
287 let mut server = mockito::Server::new_async().await;
288 mock_both(&mut server, USAGE_BODY, SUBSCRIPTION_BODY).await;
289
290 let (_td, cache) = cache_fixture();
291 let out = fetch_snapshot(
292 &reqwest::Client::new(),
293 "sk-orca-test",
294 &cache,
295 &endpoints_for(&server),
296 Duration::from_secs(0),
297 )
298 .await
299 .unwrap();
300 assert_eq!(out.snapshot.spent_cents, 275);
302 assert_eq!(out.snapshot.limit_cents, Some(1250));
303 assert_eq!(out.snapshot.remaining_cents(), Some(975));
304 assert!(out.snapshot.access_until.is_some());
305 assert!(!out.stale);
306 }
307
308 #[tokio::test]
311 async fn unlimited_sentinel_yields_a_spend_only_snapshot() {
312 let mut server = mockito::Server::new_async().await;
313 mock_both(
314 &mut server,
315 USAGE_BODY,
316 r#"{"soft_limit_usd":100000000,"hard_limit_usd":100000000,
317 "system_hard_limit_usd":100000000,"access_until":0}"#,
318 )
319 .await;
320
321 let (_td, cache) = cache_fixture();
322 let out = fetch_snapshot(
323 &reqwest::Client::new(),
324 "sk-orca-test",
325 &cache,
326 &endpoints_for(&server),
327 Duration::from_secs(0),
328 )
329 .await
330 .unwrap();
331 assert_eq!(out.snapshot.limit_cents, None);
332 assert_eq!(out.snapshot.remaining_cents(), None);
333 assert_eq!(out.snapshot.consumed_pct(), None);
334 assert_eq!(out.snapshot.spent_cents, 275);
335 assert!(out.snapshot.access_until.is_none());
336 }
337
338 #[tokio::test]
342 async fn http_200_error_envelope_is_a_failure_not_a_zero() {
343 let mut server = mockito::Server::new_async().await;
344 mock_both(
345 &mut server,
346 r#"{"error":{"message":"Invalid API key provided","type":"invalid_request_error"}}"#,
347 SUBSCRIPTION_BODY,
348 )
349 .await;
350
351 let (_td, cache) = cache_fixture();
352 let err = fetch_snapshot(
353 &reqwest::Client::new(),
354 "sk-orca-test",
355 &cache,
356 &endpoints_for(&server),
357 Duration::from_secs(0),
358 )
359 .await
360 .unwrap_err();
361 let message = err.to_string();
362 assert!(
363 matches!(err, AppError::Schema(_)),
364 "expected schema failure, got {err:?}"
365 );
366 assert!(message.contains("error envelope"), "{message}");
367 assert!(message.contains("Invalid API key"), "{message}");
368 }
369
370 #[tokio::test]
373 async fn an_http_error_with_no_cache_surfaces_the_status() {
374 let mut server = mockito::Server::new_async().await;
375 server
376 .mock("GET", "/v1/dashboard/billing/usage")
377 .with_status(401)
378 .with_body(r#"{"error":"unauthorized"}"#)
379 .create_async()
380 .await;
381 server
382 .mock("GET", "/v1/dashboard/billing/subscription")
383 .with_status(401)
384 .with_body(r#"{"error":"unauthorized"}"#)
385 .create_async()
386 .await;
387
388 let (_td, cache) = cache_fixture();
389 let err = fetch_snapshot(
390 &reqwest::Client::new(),
391 "sk-orca-test",
392 &cache,
393 &endpoints_for(&server),
394 Duration::from_secs(0),
395 )
396 .await
397 .unwrap_err();
398 assert!(
399 matches!(err, AppError::Http { status: 401, .. }),
400 "expected the 401 to survive, got {err:?}"
401 );
402 }
403
404 #[tokio::test]
408 async fn http_401_falls_back_to_cache_with_a_redacted_body() {
409 let mut server = mockito::Server::new_async().await;
410 server
411 .mock("GET", "/v1/dashboard/billing/usage")
412 .with_status(401)
413 .with_body(r#"{"error":{"message":"sk-orca-test leaked"}}"#)
414 .create_async()
415 .await;
416 server
417 .mock("GET", "/v1/dashboard/billing/subscription")
418 .with_status(401)
419 .with_body(r#"{"error":"unauthorized"}}"#)
420 .create_async()
421 .await;
422
423 let (_td, cache) = cache_fixture();
424 let endpoints = endpoints_for(&server);
425 let seed = serde_json::json!({
426 "target": target_key(&endpoints, "sk-orca-test"),
427 "snapshot": { "spent_cents": 900, "limit_cents": 1250, "access_until": null },
428 });
429 cache
430 .write_payload(&serde_json::to_vec(&seed).unwrap())
431 .unwrap();
432
433 let out = fetch_snapshot(
434 &reqwest::Client::new(),
435 "sk-orca-test",
436 &cache,
437 &endpoints,
438 Duration::from_secs(0),
439 )
440 .await
441 .unwrap();
442 assert!(out.stale);
443 assert_eq!(out.snapshot.spent_cents, 900);
444 assert_eq!(out.snapshot.remaining_cents(), Some(350));
445 let (code, body) = out.last_error.expect("error recorded alongside the figure");
446 assert_eq!(code, 401);
447 assert!(
448 !body.contains("sk-orca-test") && !body.contains("leaked"),
449 "401 body must be redacted, got {body:?}"
450 );
451 }
452
453 #[tokio::test]
456 async fn cache_from_another_key_is_rejected() {
457 let mut server = mockito::Server::new_async().await;
458 server
459 .mock("GET", "/v1/dashboard/billing/usage")
460 .match_header("authorization", "Bearer sk-orca-new")
461 .with_status(200)
462 .with_body(USAGE_BODY)
463 .expect(1)
464 .create_async()
465 .await;
466 server
467 .mock("GET", "/v1/dashboard/billing/subscription")
468 .match_header("authorization", "Bearer sk-orca-new")
469 .with_status(200)
470 .with_body(SUBSCRIPTION_BODY)
471 .expect(1)
472 .create_async()
473 .await;
474
475 let (_td, cache) = cache_fixture();
476 let endpoints = endpoints_for(&server);
477 let seed = serde_json::json!({
478 "target": target_key(&endpoints, "sk-orca-old"),
479 "snapshot": { "spent_cents": 99999, "limit_cents": 100000, "access_until": null },
480 });
481 cache
482 .write_payload(&serde_json::to_vec(&seed).unwrap())
483 .unwrap();
484
485 let out = fetch_snapshot(
487 &reqwest::Client::new(),
488 "sk-orca-new",
489 &cache,
490 &endpoints,
491 Duration::from_secs(3600),
492 )
493 .await
494 .unwrap();
495 assert_eq!(out.snapshot.spent_cents, 275, "refetched, not 99999");
496
497 let stored = std::fs::read_to_string(cache.payload_path()).unwrap();
498 assert!(!stored.contains("sk-orca-new"), "cache leaked the API key");
499 }
500
501 #[test]
502 fn cache_round_trips_and_validates() {
503 let endpoints = Endpoints::default();
504 let target = target_key(&endpoints, "k");
505 let snap = OrcaRouterSnapshot {
506 spent_cents: 275,
507 limit_cents: Some(1250),
508 access_until: chrono::DateTime::from_timestamp(1_790_000_000, 0),
509 };
510 let bytes = serde_json::to_vec(&serde_json::json!({
511 "target": target,
512 "snapshot": serde_repr(&snap),
513 }))
514 .unwrap();
515 assert_eq!(parse_cache(&bytes, &target).unwrap(), snap);
516
517 let spend_only = OrcaRouterSnapshot {
519 spent_cents: 275,
520 limit_cents: None,
521 access_until: None,
522 };
523 let bytes = serde_json::to_vec(&serde_json::json!({
524 "target": target,
525 "snapshot": serde_repr(&spend_only),
526 }))
527 .unwrap();
528 assert_eq!(parse_cache(&bytes, &target).unwrap(), spend_only);
529
530 for bad in [
532 serde_json::json!({"target": target, "snapshot": {}}),
533 serde_json::json!({"target": target, "snapshot": {"spent_cents": -1}}),
534 serde_json::json!({"target": target, "snapshot": {"spent_cents": "275"}}),
535 ] {
536 let err = parse_cache(&serde_json::to_vec(&bad).unwrap(), &target);
537 assert!(err.is_err(), "{bad} must not parse");
538 }
539 let foreign = serde_json::to_vec(&serde_json::json!({
541 "target": "somewhere-else",
542 "snapshot": {"spent_cents": 1, "limit_cents": null, "access_until": null},
543 }))
544 .unwrap();
545 assert!(parse_cache(&foreign, &target).is_err());
546 }
547}