1use std::time::Duration;
2
3use serde::Deserialize;
4
5use crate::cache::{Cache, acquire_lock_async};
6use crate::error::{AppError, Result};
7use crate::usage::DeepRelaySnapshot;
8use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
9
10use super::types::{Balance, CachedPayload, Subscription};
11
12pub const BASE_URL: &str = "https://api.deeprelay.ai/v1";
13const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
14const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
15
16pub type FetchOutcome = crate::outcome::Outcome<DeepRelaySnapshot>;
17
18#[derive(Debug, Clone)]
19pub struct Endpoints {
20 pub subscription: String,
21 pub balance: String,
22}
23
24impl Default for Endpoints {
25 fn default() -> Self {
26 Self {
27 subscription: format!("{BASE_URL}/billing/subscription"),
28 balance: format!("{BASE_URL}/billing/balance"),
29 }
30 }
31}
32
33pub async fn fetch_snapshot(
34 client: &reqwest::Client,
35 api_key: &str,
36 cache: &Cache,
37 endpoints: &Endpoints,
38 cache_ttl: Duration,
39) -> Result<FetchOutcome> {
40 cache.ensure_dir()?;
41 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
42 let parse_cache = |bytes: &[u8]| -> Result<DeepRelaySnapshot> {
43 let payload: CachedPayload = serde_json::from_slice(bytes)
44 .map_err(|_| AppError::Schema("DeepRelay billing cache is invalid".into()))?;
45 payload.snapshot()
46 };
47
48 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
49 && let Ok(snapshot) = parse_cache(&bytes)
50 {
51 return Ok(crate::outcome::Outcome::cached(snapshot, cache, false));
52 }
53 if let Some(remaining) = cache.backoff_remaining() {
55 return Err(AppError::Http {
56 status: 429,
57 body: format!(
58 "rate limited; next attempt in {}",
59 crate::cache::human_backoff(remaining)
60 ),
61 });
62 }
63
64 match request(client, api_key, endpoints).await {
65 Ok((payload, snapshot)) => {
66 cache.write_payload(&serde_json::to_vec(&payload)?)?;
67 Ok(crate::outcome::Outcome::fresh(snapshot))
68 }
69 Err(error) if error.is_transient() => {
70 crate::outcome::fallback(cache, None, error, parse_cache)
71 }
72 Err(error) => {
73 let code = match &error {
74 AppError::Http { status, .. } => *status,
75 _ => 0,
76 };
77 cache.mark_stale();
78 let diagnostic = cache.write_last_error(code, &error.to_string());
79 crate::outcome::fallback(cache, Some(diagnostic), error, parse_cache)
80 }
81 }
82}
83
84fn wants_balance(subscription: &Subscription) -> bool {
88 if !subscription.subscribed {
89 return true;
90 }
91 subscription
92 .usage
93 .as_ref()
94 .and_then(|usage| usage.recent_payg_calls_24h)
95 .is_some_and(|calls| calls > 0)
96}
97
98async fn request(
99 client: &reqwest::Client,
100 api_key: &str,
101 endpoints: &Endpoints,
102) -> Result<(CachedPayload, DeepRelaySnapshot)> {
103 let subscription: Subscription =
104 get_json(client, api_key, &endpoints.subscription, "subscription").await?;
105 let balance = if wants_balance(&subscription) {
108 get_json::<Balance>(client, api_key, &endpoints.balance, "balance")
109 .await
110 .ok()
111 } else {
112 None
113 };
114 let payload = CachedPayload {
115 subscription,
116 balance,
117 };
118 let snapshot = payload.snapshot()?;
119 Ok((payload, snapshot))
120}
121
122async fn get_json<T: serde::de::DeserializeOwned>(
123 client: &reqwest::Client,
124 api_key: &str,
125 url: &str,
126 what: &str,
127) -> Result<T> {
128 let response = tokio::time::timeout(
129 HTTP_TIMEOUT,
130 client
131 .get(url)
132 .bearer_auth(api_key)
133 .header(reqwest::header::ACCEPT, "application/json")
134 .send(),
135 )
136 .await
137 .map_err(|_| AppError::Transport(format!("DeepRelay {what} request timed out")))??;
138 let status = response.status();
139 let bytes = read_body_capped(response, MAX_BODY_BYTES).await?;
140 if !status.is_success() {
141 return Err(http_error(status.as_u16(), &bytes));
142 }
143 serde_json::from_slice(&bytes)
144 .map_err(|_| AppError::Schema(format!("DeepRelay {what} response schema mismatch")))
145}
146
147#[derive(Deserialize)]
150struct Problem {
151 #[serde(default)]
152 detail: Option<String>,
153 #[serde(default)]
154 code: Option<String>,
155}
156
157fn http_error(status: u16, bytes: &[u8]) -> AppError {
158 let problem: Option<Problem> = serde_json::from_slice(bytes).ok();
159 let (code, detail) = match problem {
160 Some(Problem { code, detail }) => (code, detail),
161 None => (None, None),
162 };
163 let code = code.as_deref();
164 let detail =
165 detail.unwrap_or_else(|| String::from_utf8_lossy(bytes).chars().take(200).collect());
166 let detail = crate::display::sanitize_untrusted_line(&detail);
167 let body = match code {
168 Some("insufficient_scope") => format!(
171 "{detail} — this key needs the `billing:read` scope; create one with that scope at deeprelay.ai/cloud/api-keys"
172 ),
173 Some("unauthenticated" | "invalid_api_key") => {
174 format!("{detail} — check the key, or create a new one at deeprelay.ai/cloud/api-keys")
175 }
176 _ => detail,
177 };
178 AppError::Http { status, body }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use tempfile::TempDir;
185
186 fn cache() -> (TempDir, Cache) {
187 let dir = TempDir::new().unwrap();
188 let cache = Cache::at(dir.path().join("deeprelay"));
189 cache.ensure_dir().unwrap();
190 (dir, cache)
191 }
192
193 const SUBSCRIPTION: &str = include_str!("../../tests/fixtures/deeprelay/subscription.json");
194
195 fn cached_body() -> Vec<u8> {
198 format!(r#"{{"subscription":{SUBSCRIPTION}}}"#).into_bytes()
199 }
200 const BALANCE: &str = r#"{"balance_cents":1234,"auto_pay_enabled":false,"auto_pay_threshold_cents":0,"auto_pay_amount_cents":0,"burn_cents_per_hour":0}"#;
201
202 fn endpoints(server: &mockito::Server) -> Endpoints {
203 Endpoints {
204 subscription: format!("{}/billing/subscription", server.url()),
205 balance: format!("{}/billing/balance", server.url()),
206 }
207 }
208
209 #[tokio::test]
210 async fn sends_bearer_key_and_parses_the_plan() {
211 let mut server = mockito::Server::new_async().await;
212 let subscription = server
213 .mock("GET", "/billing/subscription")
214 .match_header("authorization", "Bearer drl-test")
215 .match_header("accept", "application/json")
216 .with_status(200)
217 .with_body(SUBSCRIPTION)
218 .create_async()
219 .await;
220 let balance = server
222 .mock("GET", "/billing/balance")
223 .expect(0)
224 .create_async()
225 .await;
226 let (_dir, cache) = cache();
227
228 let outcome = fetch_snapshot(
229 &reqwest::Client::new(),
230 "drl-test",
231 &cache,
232 &endpoints(&server),
233 Duration::ZERO,
234 )
235 .await
236 .unwrap();
237
238 assert_eq!(outcome.snapshot.plan, "Plus");
239 assert_eq!(
240 outcome.snapshot.monthly_input.and_then(|m| m.pct()),
241 Some(32)
242 );
243 assert_eq!(outcome.snapshot.weekly_window().unwrap().resets_at, None);
244 assert_eq!(outcome.snapshot.balance_usd, None);
245 assert!(outcome.off_the_wire());
246 subscription.assert_async().await;
247 balance.assert_async().await;
248 }
249
250 #[tokio::test]
251 async fn reads_the_wallet_for_pay_as_you_go_accounts() {
252 let mut server = mockito::Server::new_async().await;
253 server
254 .mock("GET", "/billing/subscription")
255 .with_status(200)
256 .with_body(
257 r#"{"subscribed":false,"plan":null,"quota":null,
258 "usage":null}"#,
259 )
260 .create_async()
261 .await;
262 server
263 .mock("GET", "/billing/balance")
264 .with_status(200)
265 .with_body(BALANCE)
266 .create_async()
267 .await;
268 let (_dir, cache) = cache();
269
270 let outcome = fetch_snapshot(
271 &reqwest::Client::new(),
272 "drl-test",
273 &cache,
274 &endpoints(&server),
275 Duration::ZERO,
276 )
277 .await
278 .unwrap();
279 assert_eq!(outcome.snapshot.plan, "Pay as you go");
280 assert_eq!(outcome.snapshot.balance_usd, Some(12.34));
281 }
282
283 #[tokio::test]
284 async fn a_failed_balance_call_keeps_the_plan_figures() {
285 let mut server = mockito::Server::new_async().await;
286 server
287 .mock("GET", "/billing/subscription")
288 .with_status(200)
289 .with_body(SUBSCRIPTION)
290 .create_async()
291 .await;
292 server
293 .mock("GET", "/billing/balance")
294 .with_status(500)
295 .with_body("{}")
296 .create_async()
297 .await;
298 let (_dir, cache) = cache();
299
300 let outcome = fetch_snapshot(
301 &reqwest::Client::new(),
302 "drl-test",
303 &cache,
304 &endpoints(&server),
305 Duration::ZERO,
306 )
307 .await
308 .unwrap();
309 assert_eq!(outcome.snapshot.plan, "Plus");
310 assert_eq!(outcome.snapshot.balance_usd, None);
311 assert!(!outcome.stale);
312 }
313
314 #[tokio::test]
315 async fn a_warm_cache_serves_the_previous_plan_without_a_request() {
316 let mut server = mockito::Server::new_async().await;
317 let request = server
318 .mock("GET", "/billing/subscription")
319 .expect(0)
320 .create_async()
321 .await;
322 let (_dir, cache) = cache();
323 cache.write_payload(&cached_body()).unwrap();
324
325 let outcome = fetch_snapshot(
326 &reqwest::Client::new(),
327 "drl-test",
328 &cache,
329 &endpoints(&server),
330 Duration::from_secs(60),
331 )
332 .await
333 .unwrap();
334 assert_eq!(outcome.snapshot.plan, "Plus");
335 assert!(!outcome.off_the_wire());
336 assert!(!outcome.stale);
337 request.assert_async().await;
338 }
339
340 #[tokio::test]
341 async fn an_insufficient_scope_key_says_which_scope_to_add() {
342 let mut server = mockito::Server::new_async().await;
343 server
344 .mock("GET", "/billing/subscription")
345 .with_status(403)
346 .with_body(
347 r#"{"type":"https://api.deeprelay.ai/errors/insufficient_scope",
348 "title":"Forbidden","status":403,
349 "detail":"This key is not allowed to read billing",
350 "code":"insufficient_scope","request_id":"abc"}"#,
351 )
352 .create_async()
353 .await;
354 let (_dir, cache) = cache();
355
356 let error = fetch_snapshot(
357 &reqwest::Client::new(),
358 "drl-test",
359 &cache,
360 &endpoints(&server),
361 Duration::ZERO,
362 )
363 .await
364 .expect_err("scope failures have no cache to fall back on");
365 assert!(matches!(
366 &error,
367 AppError::Http { status: 403, body } if body.contains("billing:read")
368 ));
369 }
370
371 #[tokio::test]
372 async fn malformed_wallet_bodies_are_a_schema_mismatch() {
373 let mut server = mockito::Server::new_async().await;
374 server
375 .mock("GET", "/billing/subscription")
376 .with_status(200)
377 .with_body(r#"{"error":"unauthorized"}"#)
378 .create_async()
379 .await;
380 let (_dir, cache) = cache();
381
382 let error = fetch_snapshot(
383 &reqwest::Client::new(),
384 "drl-test",
385 &cache,
386 &endpoints(&server),
387 Duration::ZERO,
388 )
389 .await
390 .expect_err("a body that is not a subscription must not project one");
391 assert!(matches!(error, AppError::Schema(_)));
392 }
393
394 #[tokio::test]
395 async fn auth_failure_keeps_the_previous_plan_stale_and_redacted() {
396 let mut server = mockito::Server::new_async().await;
397 server
398 .mock("GET", "/billing/subscription")
399 .with_status(401)
400 .with_body(r#"{"detail":"bad key canary","code":"unauthenticated"}"#)
401 .create_async()
402 .await;
403 let (_dir, cache) = cache();
404 cache.write_payload(&cached_body()).unwrap();
405
406 let outcome = fetch_snapshot(
407 &reqwest::Client::new(),
408 "drl-test",
409 &cache,
410 &endpoints(&server),
411 Duration::ZERO,
412 )
413 .await
414 .unwrap();
415 assert!(outcome.stale);
416 let (_, message) = outcome.last_error.expect("auth diagnostic");
417 assert_eq!(message, crate::error::AUTH_FAILURE_MESSAGE);
418 assert!(!message.contains("canary"));
419 }
420
421 #[tokio::test]
422 async fn an_unparseable_cache_does_not_bypass_the_429_backoff() {
423 let mut server = mockito::Server::new_async().await;
424 let request = server
425 .mock("GET", "/billing/subscription")
426 .expect(0)
427 .create_async()
428 .await;
429 let (_dir, cache) = cache();
430 cache.write_payload(br#"{"unexpected":true}"#).unwrap();
431 cache.write_last_error(429, "rate limited");
432
433 let error = fetch_snapshot(
434 &reqwest::Client::new(),
435 "drl-test",
436 &cache,
437 &endpoints(&server),
438 Duration::ZERO,
439 )
440 .await
441 .expect_err("an invalid cache must not skip the backoff");
442 assert!(matches!(
443 error,
444 AppError::Http { status: 429, body } if body.contains("next attempt in")
445 ));
446 request.assert_async().await;
447 }
448}