1use std::io;
16use std::time::{Duration, SystemTime};
17
18use super::credentials::Credentials;
19use super::errors::UsageError;
20use super::usage::UsageApiResponse;
21
22const ANTHROPIC_BETA_HEADER: &str = "anthropic-beta";
25const ANTHROPIC_BETA_VALUE: &str = "oauth-2025-04-20";
26
27pub const OAUTH_USAGE_PATH: &str = "/api/oauth/usage";
29
30pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
32
33const DEFAULT_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(300);
36
37const MAX_RETRY_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
42
43const MAX_RESPONSE_BYTES: u64 = 64 * 1024;
47
48pub struct HttpResponse {
53 pub status: u16,
54 pub body: Vec<u8>,
55 pub retry_after: Option<String>,
58}
59
60pub trait UsageTransport {
65 fn get(&self, url: &str, token: &str, timeout: Duration) -> io::Result<HttpResponse>;
66}
67
68pub fn fetch_usage(
74 transport: &dyn UsageTransport,
75 base_url: &str,
76 creds: &Credentials,
77 timeout: Duration,
78) -> Result<UsageApiResponse, UsageError> {
79 let url = build_url(base_url);
80 match transport.get(&url, creds.token(), timeout) {
81 Ok(resp) => interpret_status(resp),
82 Err(e) if e.kind() == io::ErrorKind::TimedOut => Err(UsageError::Timeout),
83 Err(_) => Err(UsageError::NetworkError),
84 }
85}
86
87fn build_url(base_url: &str) -> String {
91 let trimmed = base_url.trim_end_matches('/');
92 format!("{trimmed}{OAUTH_USAGE_PATH}")
93}
94
95fn interpret_status(resp: HttpResponse) -> Result<UsageApiResponse, UsageError> {
96 match resp.status {
97 200..=299 => serde_json::from_slice(&resp.body).map_err(|_| UsageError::ParseError),
98 401 => Err(UsageError::Unauthorized),
99 429 => {
100 let retry_after = resp
101 .retry_after
102 .as_deref()
103 .and_then(parse_retry_after)
104 .or(Some(DEFAULT_RATE_LIMIT_BACKOFF));
105 Err(UsageError::RateLimited { retry_after })
106 }
107 _ => Err(UsageError::NetworkError),
113 }
114}
115
116fn parse_retry_after(raw: &str) -> Option<Duration> {
125 let raw = raw.trim();
126 let parsed = if let Ok(secs) = raw.parse::<u64>() {
127 Some(Duration::from_secs(secs))
128 } else {
129 let when = httpdate::parse_http_date(raw).ok()?;
130 when.duration_since(SystemTime::now()).ok()
131 };
132 parsed.map(|d| d.min(MAX_RETRY_AFTER))
133}
134
135pub struct UreqTransport {
140 agent: ureq::Agent,
141 user_agent: String,
142}
143
144impl UreqTransport {
145 #[must_use]
146 pub fn new() -> Self {
147 let mut builder = ureq::Agent::config_builder().http_status_as_error(false);
151 if let Some(proxy) = resolve_proxy_from_env() {
152 builder = builder.proxy(Some(proxy));
153 }
154 Self {
155 agent: ureq::Agent::new_with_config(builder.build()),
156 user_agent: default_user_agent(),
157 }
158 }
159}
160
161fn resolve_proxy_from_env() -> Option<ureq::Proxy> {
171 match ureq::Proxy::try_from_env() {
172 Some(proxy) => Some(proxy),
173 None => {
174 for var in [
180 "ALL_PROXY",
181 "all_proxy",
182 "HTTPS_PROXY",
183 "https_proxy",
184 "HTTP_PROXY",
185 "http_proxy",
186 ] {
187 if let Ok(val) = std::env::var(var) {
188 if !val.is_empty() {
189 crate::lsm_warn!(
196 "{var}: failed to parse as proxy URL; falling back to direct connection"
197 );
198 return None;
199 }
200 }
201 }
202 None
203 }
204 }
205}
206
207#[must_use]
210pub fn default_user_agent() -> String {
211 format!("linesmith/{}", env!("CARGO_PKG_VERSION"))
212}
213
214impl Default for UreqTransport {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220impl UsageTransport for UreqTransport {
221 fn get(&self, url: &str, token: &str, timeout: Duration) -> io::Result<HttpResponse> {
222 let auth = format!("Bearer {token}");
223 let mut response = self
224 .agent
225 .get(url)
226 .config()
227 .timeout_global(Some(timeout))
228 .build()
229 .header("Authorization", &auth)
230 .header(ANTHROPIC_BETA_HEADER, ANTHROPIC_BETA_VALUE)
231 .header("User-Agent", &self.user_agent)
232 .call()
233 .map_err(ureq_err_to_io)?;
234
235 let status: u16 = response.status().as_u16();
236 let retry_after = response
237 .headers()
238 .get("retry-after")
239 .and_then(|v| v.to_str().ok())
240 .map(String::from);
241
242 let body = response
247 .body_mut()
248 .with_config()
249 .limit(MAX_RESPONSE_BYTES)
250 .read_to_vec()
251 .map_err(ureq_err_to_io)?;
252
253 Ok(HttpResponse {
254 status,
255 body,
256 retry_after,
257 })
258 }
259}
260
261fn ureq_err_to_io(e: ureq::Error) -> io::Error {
266 match e {
267 ureq::Error::Timeout(_) => io::Error::new(io::ErrorKind::TimedOut, "request timed out"),
268 ureq::Error::Io(inner) => inner,
269 other => io::Error::other(other.to_string()),
270 }
271}
272
273#[cfg(test)]
276mod tests {
277 use super::*;
278 use std::cell::RefCell;
279
280 fn creds() -> Credentials {
281 Credentials::for_testing("test-token-xyz")
282 }
283
284 struct FakeTransport {
288 response: io::Result<HttpResponse>,
289 captured: RefCell<Option<FakeCall>>,
290 }
291
292 #[derive(Debug, Clone)]
293 struct FakeCall {
294 url: String,
295 token: String,
296 timeout: Duration,
297 }
298
299 impl UsageTransport for FakeTransport {
300 fn get(&self, url: &str, token: &str, timeout: Duration) -> io::Result<HttpResponse> {
301 *self.captured.borrow_mut() = Some(FakeCall {
302 url: url.to_string(),
303 token: token.to_string(),
304 timeout,
305 });
306 match &self.response {
307 Ok(r) => Ok(HttpResponse {
308 status: r.status,
309 body: r.body.clone(),
310 retry_after: r.retry_after.clone(),
311 }),
312 Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
313 }
314 }
315 }
316
317 fn ok_transport(status: u16, body: &str, retry_after: Option<&str>) -> FakeTransport {
318 FakeTransport {
319 response: Ok(HttpResponse {
320 status,
321 body: body.as_bytes().to_vec(),
322 retry_after: retry_after.map(String::from),
323 }),
324 captured: RefCell::new(None),
325 }
326 }
327
328 fn err_transport(kind: io::ErrorKind) -> FakeTransport {
329 FakeTransport {
330 response: Err(io::Error::new(kind, "fake")),
331 captured: RefCell::new(None),
332 }
333 }
334
335 const SAMPLE_OK_BODY: &str = r#"{
336 "five_hour": { "utilization": 22.0, "resets_at": "2026-04-19T05:00:00Z" },
337 "seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
338 }"#;
339
340 #[test]
341 fn fetch_happy_path_parses_live_shape() {
342 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
343 let resp = fetch_usage(
344 &transport,
345 "https://api.anthropic.com",
346 &creds(),
347 DEFAULT_TIMEOUT,
348 )
349 .expect("ok");
350 assert_eq!(resp.five_hour.unwrap().utilization.value(), 22.0);
351 }
352
353 #[test]
354 fn fetch_builds_url_without_double_slash() {
355 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
356 let _ = fetch_usage(
357 &transport,
358 "https://api.anthropic.com/",
359 &creds(),
360 DEFAULT_TIMEOUT,
361 );
362 let captured = transport.captured.borrow().clone().unwrap();
363 assert_eq!(captured.url, "https://api.anthropic.com/api/oauth/usage");
364 }
365
366 #[test]
367 fn fetch_passes_token_through_to_transport() {
368 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
369 let _ = fetch_usage(
370 &transport,
371 "https://example.test",
372 &Credentials::for_testing("unique-token-42"),
373 DEFAULT_TIMEOUT,
374 );
375 let captured = transport.captured.borrow().clone().unwrap();
376 assert_eq!(captured.token, "unique-token-42");
377 }
378
379 #[test]
380 fn fetch_passes_timeout_through_to_transport() {
381 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
382 let custom_timeout = Duration::from_millis(750);
383 let _ = fetch_usage(&transport, "https://x", &creds(), custom_timeout);
384 let captured = transport.captured.borrow().clone().unwrap();
385 assert_eq!(captured.timeout, custom_timeout);
386 }
387
388 #[test]
389 fn fetch_maps_401_to_unauthorized() {
390 let transport = ok_transport(401, "", None);
391 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
392 assert!(matches!(err, UsageError::Unauthorized));
393 }
394
395 #[test]
396 fn fetch_maps_429_with_integer_retry_after() {
397 let transport = ok_transport(429, "", Some("120"));
398 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
399 match err {
400 UsageError::RateLimited {
401 retry_after: Some(d),
402 } => assert_eq!(d.as_secs(), 120),
403 other => panic!("expected RateLimited(Some(120s)), got {other:?}"),
404 }
405 }
406
407 #[test]
408 fn fetch_maps_429_with_http_date_retry_after() {
409 let future = SystemTime::now() + Duration::from_secs(3600);
413 let header_value = httpdate::fmt_http_date(future);
414 let transport = ok_transport(429, "", Some(&header_value));
415 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
416 let UsageError::RateLimited {
417 retry_after: Some(d),
418 } = err
419 else {
420 panic!("expected RateLimited with Some duration, got {err:?}");
421 };
422 assert!(d.as_secs() > 0, "expected positive duration, got {d:?}");
423 }
424
425 #[test]
426 fn fetch_maps_429_without_retry_after_to_default_backoff() {
427 let transport = ok_transport(429, "", None);
428 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
429 match err {
430 UsageError::RateLimited {
431 retry_after: Some(d),
432 } => assert_eq!(d, DEFAULT_RATE_LIMIT_BACKOFF),
433 other => panic!("expected RateLimited with default backoff, got {other:?}"),
434 }
435 }
436
437 #[test]
438 fn fetch_maps_5xx_to_network_error() {
439 let transport = ok_transport(503, "", None);
440 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
441 assert!(matches!(err, UsageError::NetworkError));
442 }
443
444 #[test]
445 fn fetch_maps_malformed_json_to_parse_error() {
446 let transport = ok_transport(200, "{ not valid json ", None);
447 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
448 assert!(matches!(err, UsageError::ParseError));
449 }
450
451 #[test]
452 fn fetch_maps_timeout_to_usage_timeout() {
453 let transport = err_transport(io::ErrorKind::TimedOut);
454 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
455 assert!(matches!(err, UsageError::Timeout));
456 }
457
458 #[test]
459 fn fetch_maps_connection_refused_to_network_error() {
460 let transport = err_transport(io::ErrorKind::ConnectionRefused);
461 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
462 assert!(matches!(err, UsageError::NetworkError));
463 }
464
465 #[test]
466 fn fetch_401_display_does_not_leak_token() {
467 let transport = ok_transport(401, "", None);
471 let err = fetch_usage(
472 &transport,
473 "https://x",
474 &Credentials::for_testing("super-secret-token-abc123"),
475 DEFAULT_TIMEOUT,
476 )
477 .unwrap_err();
478 let display = format!("{err}");
479 let debug = format!("{err:?}");
480 assert!(
481 !display.contains("super-secret-token-abc123"),
482 "display leaked: {display}"
483 );
484 assert!(
485 !debug.contains("super-secret-token-abc123"),
486 "debug leaked: {debug}"
487 );
488 }
489
490 #[test]
491 fn parse_retry_after_integer_seconds() {
492 assert_eq!(parse_retry_after("60"), Some(Duration::from_secs(60)));
493 assert_eq!(parse_retry_after(" 60 "), Some(Duration::from_secs(60)));
494 }
495
496 #[test]
497 fn parse_retry_after_zero() {
498 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
499 }
500
501 #[test]
502 fn parse_retry_after_http_date_future() {
503 let future = SystemTime::now() + Duration::from_secs(3600);
506 let raw = httpdate::fmt_http_date(future);
507 let parsed = parse_retry_after(&raw);
508 assert!(parsed.is_some_and(|d| d.as_secs() > 0));
509 }
510
511 #[test]
512 fn parse_retry_after_http_date_past_returns_none() {
513 assert_eq!(parse_retry_after("Thu, 01 Jan 1970 00:00:00 GMT"), None);
515 }
516
517 #[test]
518 fn parse_retry_after_garbage_returns_none() {
519 assert_eq!(parse_retry_after("not a date"), None);
520 assert_eq!(parse_retry_after(""), None);
521 assert_eq!(parse_retry_after("-1"), None);
522 }
523
524 #[test]
525 fn parse_retry_after_caps_pathological_values() {
526 let parsed = parse_retry_after(&u64::MAX.to_string()).unwrap();
529 assert_eq!(parsed, MAX_RETRY_AFTER);
530 }
531
532 #[test]
533 fn ureq_transport_construction_pins_user_agent_and_proxy_path() {
534 let transport = UreqTransport::new();
543 assert_eq!(transport.user_agent, default_user_agent());
544 }
545
546 #[test]
547 fn default_user_agent_includes_version_and_crate_name() {
548 let ua = default_user_agent();
552 assert!(ua.starts_with("linesmith/"), "ua = {ua}");
553 assert!(
554 ua.ends_with(env!("CARGO_PKG_VERSION")),
555 "ua = {ua}; version = {}",
556 env!("CARGO_PKG_VERSION"),
557 );
558 }
559
560 #[test]
561 fn fetch_204_empty_body_surfaces_parse_error() {
562 let transport = ok_transport(204, "", None);
567 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
568 assert!(matches!(err, UsageError::ParseError));
569 }
570}