1use std::io;
16use std::time::{Duration, SystemTime};
17
18use super::credentials::Credentials;
19use super::error::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(e) => {
84 crate::lsm_debug!("fetch_usage: transport error ({:?}): {e}", e.kind());
94 Err(UsageError::NetworkError)
95 }
96 }
97}
98
99fn build_url(base_url: &str) -> String {
103 let trimmed = base_url.trim_end_matches('/');
104 format!("{trimmed}{OAUTH_USAGE_PATH}")
105}
106
107fn interpret_status(resp: HttpResponse) -> Result<UsageApiResponse, UsageError> {
108 match resp.status {
109 200..=299 => serde_json::from_slice(&resp.body).map_err(|e| {
110 crate::lsm_debug!("fetch_usage: parse error: {e}");
114 UsageError::ParseError
115 }),
116 401 => Err(UsageError::Unauthorized),
117 429 => {
118 let retry_after = resp
119 .retry_after
120 .as_deref()
121 .and_then(parse_retry_after)
122 .or(Some(DEFAULT_RATE_LIMIT_BACKOFF));
123 Err(UsageError::RateLimited { retry_after })
124 }
125 _ => Err(UsageError::NetworkError),
131 }
132}
133
134fn parse_retry_after(raw: &str) -> Option<Duration> {
143 let raw = raw.trim();
144 let parsed = if let Ok(secs) = raw.parse::<u64>() {
145 Some(Duration::from_secs(secs))
146 } else {
147 let when = httpdate::parse_http_date(raw).ok()?;
148 when.duration_since(SystemTime::now()).ok()
149 };
150 parsed.map(|d| d.min(MAX_RETRY_AFTER))
151}
152
153pub struct UreqTransport {
158 agent: ureq::Agent,
159 user_agent: String,
160}
161
162impl UreqTransport {
163 #[must_use]
164 pub fn new() -> Self {
165 let mut builder = ureq::Agent::config_builder().http_status_as_error(false);
169 if let Some(proxy) = resolve_proxy_from_env() {
170 builder = builder.proxy(Some(proxy));
171 }
172 Self {
173 agent: ureq::Agent::new_with_config(builder.build()),
174 user_agent: default_user_agent(),
175 }
176 }
177}
178
179fn resolve_proxy_from_env() -> Option<ureq::Proxy> {
189 resolve_proxy(ureq::Proxy::try_from_env, |var| match std::env::var(var) {
190 Ok(v) => Some(v),
191 Err(std::env::VarError::NotPresent) => None,
192 Err(std::env::VarError::NotUnicode(_)) => {
193 crate::lsm_warn!("{var}: contains non-UTF-8 bytes; ignoring as a proxy source");
197 None
198 }
199 })
200}
201
202fn resolve_proxy<P, G>(probe: P, get_env: G) -> Option<ureq::Proxy>
212where
213 P: FnOnce() -> Option<ureq::Proxy>,
214 G: Fn(&str) -> Option<String>,
215{
216 if let Some(proxy) = probe() {
217 return Some(proxy);
218 }
219 for var in [
224 "ALL_PROXY",
225 "all_proxy",
226 "HTTPS_PROXY",
227 "https_proxy",
228 "HTTP_PROXY",
229 "http_proxy",
230 ] {
231 if get_env(var).is_some_and(|v| !v.is_empty()) {
234 crate::lsm_warn!(
235 "{var}: failed to parse as proxy URL; falling back to direct connection"
236 );
237 return None;
238 }
239 }
240 None
241}
242
243#[must_use]
246pub fn default_user_agent() -> String {
247 format!("linesmith/{}", env!("CARGO_PKG_VERSION"))
248}
249
250impl Default for UreqTransport {
251 fn default() -> Self {
252 Self::new()
253 }
254}
255
256impl UsageTransport for UreqTransport {
257 fn get(&self, url: &str, token: &str, timeout: Duration) -> io::Result<HttpResponse> {
258 let auth = format!("Bearer {token}");
259 let mut response = self
260 .agent
261 .get(url)
262 .config()
263 .timeout_global(Some(timeout))
264 .build()
265 .header("Authorization", &auth)
266 .header(ANTHROPIC_BETA_HEADER, ANTHROPIC_BETA_VALUE)
267 .header("User-Agent", &self.user_agent)
268 .call()
269 .map_err(ureq_err_to_io)?;
270
271 let status: u16 = response.status().as_u16();
272 let retry_after = response.headers().get("retry-after").and_then(|v| {
273 v.to_str().map(String::from).ok().or_else(|| {
274 if status == 429 {
279 crate::lsm_warn!(
280 "retry-after header contained non-ASCII bytes; falling back to default backoff"
281 );
282 }
283 None
284 })
285 });
286
287 let body = response
292 .body_mut()
293 .with_config()
294 .limit(MAX_RESPONSE_BYTES)
295 .read_to_vec()
296 .map_err(ureq_err_to_io)?;
297
298 Ok(HttpResponse {
299 status,
300 body,
301 retry_after,
302 })
303 }
304}
305
306fn ureq_err_to_io(e: ureq::Error) -> io::Error {
311 match e {
312 ureq::Error::Timeout(_) => io::Error::new(io::ErrorKind::TimedOut, "request timed out"),
313 ureq::Error::Io(inner) => inner,
314 other => io::Error::other(other.to_string()),
315 }
316}
317
318#[cfg(test)]
321mod tests {
322 use super::*;
323 use std::cell::RefCell;
324
325 fn creds() -> Credentials {
326 Credentials::for_testing("test-token-xyz")
327 }
328
329 struct FakeTransport {
333 response: io::Result<HttpResponse>,
334 captured: RefCell<Option<FakeCall>>,
335 }
336
337 #[derive(Debug, Clone)]
338 struct FakeCall {
339 url: String,
340 token: String,
341 timeout: Duration,
342 }
343
344 impl UsageTransport for FakeTransport {
345 fn get(&self, url: &str, token: &str, timeout: Duration) -> io::Result<HttpResponse> {
346 *self.captured.borrow_mut() = Some(FakeCall {
347 url: url.to_string(),
348 token: token.to_string(),
349 timeout,
350 });
351 match &self.response {
352 Ok(r) => Ok(HttpResponse {
353 status: r.status,
354 body: r.body.clone(),
355 retry_after: r.retry_after.clone(),
356 }),
357 Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
358 }
359 }
360 }
361
362 fn ok_transport(status: u16, body: &str, retry_after: Option<&str>) -> FakeTransport {
363 FakeTransport {
364 response: Ok(HttpResponse {
365 status,
366 body: body.as_bytes().to_vec(),
367 retry_after: retry_after.map(String::from),
368 }),
369 captured: RefCell::new(None),
370 }
371 }
372
373 fn err_transport(kind: io::ErrorKind) -> FakeTransport {
374 FakeTransport {
375 response: Err(io::Error::new(kind, "fake")),
376 captured: RefCell::new(None),
377 }
378 }
379
380 const SAMPLE_OK_BODY: &str = r#"{
381 "five_hour": { "utilization": 22.0, "resets_at": "2026-04-19T05:00:00Z" },
382 "seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
383 }"#;
384
385 #[test]
386 fn fetch_happy_path_parses_live_shape() {
387 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
388 let resp = fetch_usage(
389 &transport,
390 "https://api.anthropic.com",
391 &creds(),
392 DEFAULT_TIMEOUT,
393 )
394 .expect("ok");
395 assert_eq!(resp.five_hour.unwrap().utilization.value(), 22.0);
396 }
397
398 #[test]
399 fn fetch_builds_url_without_double_slash() {
400 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
401 let _ = fetch_usage(
402 &transport,
403 "https://api.anthropic.com/",
404 &creds(),
405 DEFAULT_TIMEOUT,
406 );
407 let captured = transport.captured.borrow().clone().unwrap();
408 assert_eq!(captured.url, "https://api.anthropic.com/api/oauth/usage");
409 }
410
411 #[test]
412 fn fetch_passes_token_through_to_transport() {
413 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
414 let _ = fetch_usage(
415 &transport,
416 "https://example.test",
417 &Credentials::for_testing("unique-token-42"),
418 DEFAULT_TIMEOUT,
419 );
420 let captured = transport.captured.borrow().clone().unwrap();
421 assert_eq!(captured.token, "unique-token-42");
422 }
423
424 #[test]
425 fn fetch_passes_timeout_through_to_transport() {
426 let transport = ok_transport(200, SAMPLE_OK_BODY, None);
427 let custom_timeout = Duration::from_millis(750);
428 let _ = fetch_usage(&transport, "https://x", &creds(), custom_timeout);
429 let captured = transport.captured.borrow().clone().unwrap();
430 assert_eq!(captured.timeout, custom_timeout);
431 }
432
433 #[test]
434 fn fetch_maps_401_to_unauthorized() {
435 let transport = ok_transport(401, "", None);
436 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
437 assert!(matches!(err, UsageError::Unauthorized));
438 }
439
440 #[test]
441 fn fetch_maps_429_with_integer_retry_after() {
442 let transport = ok_transport(429, "", Some("120"));
443 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
444 match err {
445 UsageError::RateLimited {
446 retry_after: Some(d),
447 } => assert_eq!(d.as_secs(), 120),
448 other => panic!("expected RateLimited(Some(120s)), got {other:?}"),
449 }
450 }
451
452 #[test]
453 fn fetch_maps_429_with_http_date_retry_after() {
454 let future = SystemTime::now() + Duration::from_secs(3600);
458 let header_value = httpdate::fmt_http_date(future);
459 let transport = ok_transport(429, "", Some(&header_value));
460 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
461 let UsageError::RateLimited {
462 retry_after: Some(d),
463 } = err
464 else {
465 panic!("expected RateLimited with Some duration, got {err:?}");
466 };
467 assert!(d.as_secs() > 0, "expected positive duration, got {d:?}");
468 }
469
470 #[test]
471 fn fetch_maps_429_without_retry_after_to_default_backoff() {
472 let transport = ok_transport(429, "", None);
473 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
474 match err {
475 UsageError::RateLimited {
476 retry_after: Some(d),
477 } => assert_eq!(d, DEFAULT_RATE_LIMIT_BACKOFF),
478 other => panic!("expected RateLimited with default backoff, got {other:?}"),
479 }
480 }
481
482 #[test]
483 fn fetch_maps_5xx_to_network_error() {
484 let transport = ok_transport(503, "", None);
485 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
486 assert!(matches!(err, UsageError::NetworkError));
487 }
488
489 #[test]
490 fn fetch_maps_malformed_json_to_parse_error() {
491 let transport = ok_transport(200, "{ not valid json ", None);
492 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
493 assert!(matches!(err, UsageError::ParseError));
494 }
495
496 #[test]
497 fn fetch_maps_timeout_to_usage_timeout() {
498 let transport = err_transport(io::ErrorKind::TimedOut);
499 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
500 assert!(matches!(err, UsageError::Timeout));
501 }
502
503 #[test]
504 fn fetch_maps_connection_refused_to_network_error() {
505 let transport = err_transport(io::ErrorKind::ConnectionRefused);
506 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
507 assert!(matches!(err, UsageError::NetworkError));
508 }
509
510 #[test]
511 fn fetch_401_display_does_not_leak_token() {
512 let transport = ok_transport(401, "", None);
516 let err = fetch_usage(
517 &transport,
518 "https://x",
519 &Credentials::for_testing("super-secret-token-abc123"),
520 DEFAULT_TIMEOUT,
521 )
522 .unwrap_err();
523 let display = format!("{err}");
524 let debug = format!("{err:?}");
525 assert!(
526 !display.contains("super-secret-token-abc123"),
527 "display leaked: {display}"
528 );
529 assert!(
530 !debug.contains("super-secret-token-abc123"),
531 "debug leaked: {debug}"
532 );
533 }
534
535 #[test]
536 fn parse_retry_after_integer_seconds() {
537 assert_eq!(parse_retry_after("60"), Some(Duration::from_secs(60)));
538 assert_eq!(parse_retry_after(" 60 "), Some(Duration::from_secs(60)));
539 }
540
541 #[test]
542 fn parse_retry_after_zero() {
543 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
544 }
545
546 #[test]
547 fn parse_retry_after_http_date_future() {
548 let future = SystemTime::now() + Duration::from_secs(3600);
551 let raw = httpdate::fmt_http_date(future);
552 let parsed = parse_retry_after(&raw);
553 assert!(parsed.is_some_and(|d| d.as_secs() > 0));
554 }
555
556 #[test]
557 fn parse_retry_after_http_date_past_returns_none() {
558 assert_eq!(parse_retry_after("Thu, 01 Jan 1970 00:00:00 GMT"), None);
560 }
561
562 #[test]
563 fn parse_retry_after_garbage_returns_none() {
564 assert_eq!(parse_retry_after("not a date"), None);
565 assert_eq!(parse_retry_after(""), None);
566 assert_eq!(parse_retry_after("-1"), None);
567 }
568
569 #[test]
570 fn parse_retry_after_caps_pathological_values() {
571 let parsed = parse_retry_after(&u64::MAX.to_string()).unwrap();
574 assert_eq!(parsed, MAX_RETRY_AFTER);
575 }
576
577 #[test]
578 fn ureq_transport_construction_pins_user_agent_and_proxy_path() {
579 let transport = UreqTransport::new();
588 assert_eq!(transport.user_agent, default_user_agent());
589 }
590
591 #[test]
592 fn default_user_agent_includes_version_and_crate_name() {
593 let ua = default_user_agent();
597 assert!(ua.starts_with("linesmith/"), "ua = {ua}");
598 assert!(
599 ua.ends_with(env!("CARGO_PKG_VERSION")),
600 "ua = {ua}; version = {}",
601 env!("CARGO_PKG_VERSION"),
602 );
603 }
604
605 #[test]
606 fn fetch_204_empty_body_surfaces_parse_error() {
607 let transport = ok_transport(204, "", None);
612 let err = fetch_usage(&transport, "https://x", &creds(), DEFAULT_TIMEOUT).unwrap_err();
613 assert!(matches!(err, UsageError::ParseError));
614 }
615
616 #[test]
617 fn resolve_proxy_returns_probe_value_and_skips_env_check() {
618 let env_called = std::cell::Cell::new(false);
619 let (proxy, warns) = crate::logging::_test_capture_warns(|| {
620 resolve_proxy(
621 || ureq::Proxy::new("http://probe.example:8080").ok(),
622 |_| {
623 env_called.set(true);
624 None
625 },
626 )
627 });
628 assert!(proxy.is_some(), "probe Some passes through");
629 assert!(
630 !env_called.get(),
631 "env getter must not run when probe returned Some"
632 );
633 assert!(warns.is_empty(), "happy path must not warn, got {warns:?}");
634 }
635
636 #[test]
637 fn resolve_proxy_returns_none_silently_when_no_env_vars_set() {
638 let (proxy, warns) =
639 crate::logging::_test_capture_warns(|| resolve_proxy(|| None, |_| None));
640 assert!(proxy.is_none());
641 assert!(warns.is_empty(), "no env set must not warn, got {warns:?}");
642 }
643
644 #[test]
645 fn resolve_proxy_warns_with_var_name_only_when_value_unparseable() {
646 let (proxy, warns) = crate::logging::_test_capture_warns(|| {
651 resolve_proxy(
652 || None,
653 |var| {
654 if var == "HTTPS_PROXY" {
655 Some("http://sneakyuser:sneakypass@badproxy.example:9090".to_string())
656 } else {
657 None
658 }
659 },
660 )
661 });
662 assert!(proxy.is_none());
663 assert_eq!(warns.len(), 1, "expected one warn, got {warns:?}");
664 assert!(
665 warns[0].contains("HTTPS_PROXY"),
666 "warn must name the variable, got {:?}",
667 warns[0]
668 );
669 assert!(
670 warns[0].contains("falling back to direct connection"),
671 "warn must surface the action, got {:?}",
672 warns[0]
673 );
674 assert!(
675 !warns[0].contains("sneakyuser"),
676 "username must NOT appear in warn, got {:?}",
677 warns[0]
678 );
679 assert!(
680 !warns[0].contains("sneakypass"),
681 "password must NOT appear in warn, got {:?}",
682 warns[0]
683 );
684 assert!(
685 !warns[0].contains("badproxy"),
686 "URL host must NOT appear in warn, got {:?}",
687 warns[0]
688 );
689 }
690
691 #[test]
692 fn resolve_proxy_warns_for_first_unparseable_var_in_ureq_precedence_order() {
693 let (proxy, warns) = crate::logging::_test_capture_warns(|| {
699 resolve_proxy(
700 || None,
701 |var| match var {
702 "ALL_PROXY" | "HTTPS_PROXY" | "HTTP_PROXY" => Some("garbage://".to_string()),
703 _ => None,
704 },
705 )
706 });
707 assert!(proxy.is_none());
708 assert_eq!(warns.len(), 1, "expected one warn, got {warns:?}");
709 assert!(
710 warns[0].contains("ALL_PROXY"),
711 "warn must name ALL_PROXY (first in precedence), got {:?}",
712 warns[0]
713 );
714 assert!(
715 !warns[0].contains("HTTPS_PROXY") && !warns[0].contains("HTTP_PROXY"),
716 "warn must not name later-precedence vars, got {:?}",
717 warns[0]
718 );
719 assert!(
720 !warns[0].contains("garbage"),
721 "warn must not echo the value (redaction guard), got {:?}",
722 warns[0]
723 );
724 }
725
726 #[test]
727 fn resolve_proxy_skips_empty_env_values_without_warning() {
728 let (proxy, warns) = crate::logging::_test_capture_warns(|| {
732 resolve_proxy(
733 || None,
734 |var| {
735 if var == "HTTPS_PROXY" {
736 Some(String::new())
737 } else {
738 None
739 }
740 },
741 )
742 });
743 assert!(proxy.is_none());
744 assert!(warns.is_empty(), "empty value must not warn, got {warns:?}");
745 }
746}