Skip to main content

linesmith_core/data_context/
fetcher.rs

1//! OAuth `/api/oauth/usage` HTTP client.
2//!
3//! Lowest layer of the rate-limit data pipeline. Talks HTTP to the
4//! Anthropic endpoint, classifies the response, and returns a parsed
5//! [`UsageApiResponse`] on success. Retry / stale-serve / JSONL
6//! fallback behavior lives in the orchestrator above this layer;
7//! this module has no knowledge of the usage-data cache stack.
8//! (The `ureq::Agent` held by [`UreqTransport`] maintains an internal
9//! connection pool — that's a transport-level concern, separate from
10//! the response cache.)
11//!
12//! Canonical spec: `docs/adrs/0011-rate-limit-data-source.md`
13//! §Endpoint contract and §Cache stack (Retry-After rules).
14
15use std::io;
16use std::time::{Duration, SystemTime};
17
18use super::credentials::Credentials;
19use super::error::UsageError;
20use super::usage::UsageApiResponse;
21
22/// Forward-compat header Anthropic currently requires for the OAuth
23/// usage endpoint. When this rotates we bump the value here.
24const ANTHROPIC_BETA_HEADER: &str = "anthropic-beta";
25const ANTHROPIC_BETA_VALUE: &str = "oauth-2025-04-20";
26
27/// Endpoint path appended to the configured `usage.api_base_url`.
28pub const OAUTH_USAGE_PATH: &str = "/api/oauth/usage";
29
30/// Default per-request timeout per ADR-0011 §Endpoint contract.
31pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
32
33/// Fallback backoff for 429 responses that omit the `Retry-After`
34/// header. ADR-0011 §Cache stack specifies 300s.
35const DEFAULT_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(300);
36
37/// Upper bound on parsed `Retry-After` values. A malformed or
38/// pathological header like `Retry-After: 18446744073709551615`
39/// would otherwise produce a Duration the orchestrator can't
40/// meaningfully compare with a cache TTL.
41const MAX_RETRY_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
42
43/// Cap on the response body we'll read. The live endpoint emits
44/// roughly 500 bytes; 64 KiB is two orders of magnitude headroom
45/// without letting a misbehaving (or MITM'd) server OOM us.
46const MAX_RESPONSE_BYTES: u64 = 64 * 1024;
47
48// --- Transport trait ----------------------------------------------------
49
50/// Raw HTTP response — enough for [`fetch_usage`] to classify the
51/// outcome without leaking the HTTP crate to callers.
52pub struct HttpResponse {
53    pub status: u16,
54    pub body: Vec<u8>,
55    /// Verbatim `Retry-After` header value if present. Parsing lives
56    /// in [`parse_retry_after`] so the transport stays dumb.
57    pub retry_after: Option<String>,
58}
59
60/// Injected HTTP surface. [`UreqTransport`] is the default; tests use
61/// a fake to exercise status-handling paths without real I/O. Errors
62/// carry [`io::ErrorKind::TimedOut`] for timeouts; anything else is
63/// treated as a generic network failure in [`fetch_usage`].
64pub trait UsageTransport {
65    fn get(&self, url: &str, token: &str, timeout: Duration) -> io::Result<HttpResponse>;
66}
67
68// --- Public entry point -------------------------------------------------
69
70/// Fetch usage data for the given credentials. Maps transport errors
71/// and HTTP status codes onto the [`UsageError`] taxonomy per
72/// `docs/specs/rate-limit-segments.md` §Error message table.
73pub 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            // `UsageError::NetworkError` coalesces ConnectionRefused /
85            // TLS / DNS / cert / read failures into one variant for the
86            // orchestrator's stale-cache fallback. `LINESMITH_LOG=debug`
87            // reveals the root cause without enlarging the public taxonomy.
88            //
89            // Redaction invariant: `io::Error` Display from `ureq_err_to_io`
90            // carries URL + OS-level kind only — ureq does not propagate
91            // request headers (Bearer token) into error Display, and the
92            // URL itself is user-supplied config without credentials.
93            crate::lsm_debug!("fetch_usage: transport error ({:?}): {e}", e.kind());
94            Err(UsageError::NetworkError)
95        }
96    }
97}
98
99/// Strip a trailing slash from `base_url` before appending the fixed
100/// path so both `"https://api.anthropic.com"` and
101/// `"https://api.anthropic.com/"` yield a single-slash URL.
102fn 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            // Diagnosing live-endpoint shape drift requires serde's
111            // line/column/message; the public variant can't carry that.
112            // Body bytes stay out of logs.
113            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        // 3xx (when redirects are disabled or exhausted) and 5xx fall
126        // here. ADR-0011 doesn't carve out a distinct variant for
127        // server errors; `NetworkError` triggers the stale-cache
128        // fallback path in the orchestrator, which is the intended
129        // behavior for transient server failures.
130        _ => Err(UsageError::NetworkError),
131    }
132}
133
134/// Parse a `Retry-After` header value. Accepts the two RFC 9110
135/// §10.2.3 forms (RFC 7231 §7.1.3 superseded): integer seconds
136/// (`"120"`) and HTTP-date (`"Fri, 31 Dec 2026 23:59:59 GMT"`).
137/// Past-dated values and values the `httpdate` crate rejects fall
138/// back to `None`; the caller then applies
139/// [`DEFAULT_RATE_LIMIT_BACKOFF`]. Values above [`MAX_RETRY_AFTER`]
140/// are capped so a malformed header can't produce a Duration the
141/// orchestrator treats as "never retry."
142fn 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
153// --- ureq transport -----------------------------------------------------
154
155/// `ureq::Agent`-backed [`UsageTransport`]. One agent is shared across
156/// all `fetch_usage` calls so connection pooling / keepalive applies.
157pub struct UreqTransport {
158    agent: ureq::Agent,
159    user_agent: String,
160}
161
162impl UreqTransport {
163    #[must_use]
164    pub fn new() -> Self {
165        // `http_status_as_error(false)` so 4xx/5xx surface as
166        // `Ok(Response)` with a status code rather than `Err` — we
167        // need to inspect 401 and 429 specifically.
168        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
179/// Resolve a proxy from `ALL_PROXY` / `HTTPS_PROXY` / `HTTP_PROXY`
180/// / `NO_PROXY` (and lowercase variants), matching ureq's documented
181/// env-var order. ureq's `Proxy::try_from_env` does the parsing,
182/// including the `NO_PROXY` exclusion list. A malformed proxy URL
183/// warns to stderr (variable name only — the value can carry
184/// credentials like `http://user:pass@host`) and falls through to a
185/// direct connection. Without the warn the proxy silently drops and
186/// the user sees `[Network error]` with no clue the env var was the
187/// cause.
188fn 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            // Naming the variable (not the bytes) preserves the
194            // credential-leak guarantee. Without this arm, a non-UTF-8
195            // proxy env would silently route as no-proxy.
196            crate::lsm_warn!("{var}: contains non-UTF-8 bytes; ignoring as a proxy source");
197            None
198        }
199    })
200}
201
202/// Pure form of [`resolve_proxy_from_env`] with the `ureq` probe and
203/// the env reader as parameters, so tests drive all three branches
204/// (probe returns Some, env unset, env set-but-unparseable) without
205/// touching process env — racy under parallel `cargo test`.
206///
207/// **Invariant:** callers of `get_env` must not emit, log, or echo
208/// the returned value. The credential-leak guarantee in the warn
209/// path (variable NAME only) is a `resolve_proxy` invariant, not a
210/// per-call discipline — proxy URLs routinely embed `user:pass@`.
211fn 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    // Probe returned None both when no env var is set AND when a set
220    // var fails to parse. Distinguish by re-reading in ureq's own
221    // iteration order; a set-but-unparsed value is the case worth
222    // warning about.
223    for var in [
224        "ALL_PROXY",
225        "all_proxy",
226        "HTTPS_PROXY",
227        "https_proxy",
228        "HTTP_PROXY",
229        "http_proxy",
230    ] {
231        // No `let val` binding: prevents a future refactor from
232        // accidentally printing the proxy URL (which embeds credentials).
233        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/// Build the `User-Agent` header value from the compile-time package
244/// version. Exposed for regression tests that pin the format contract.
245#[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                // RFC 9110 §10.2.3 specifies ASCII for `Retry-After`;
275                // non-ASCII bytes are server malice or misconfig. Warn
276                // only on 429 — that's where a silent drop misleads the
277                // caller into thinking rate-limiting isn't in effect.
278                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        // Cap the body read so a misbehaving / MITM'd server can't
288        // OOM us. Oversized responses surface as ParseError in
289        // `interpret_status` (the serde parse will fail on the
290        // truncated prefix).
291        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
306/// Collapse `ureq::Error` into `io::Error` for the [`UsageTransport`]
307/// boundary. Timeouts keep [`io::ErrorKind::TimedOut`] so
308/// [`fetch_usage`] can differentiate; everything else is treated as
309/// a generic network failure.
310fn 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// --- Tests --------------------------------------------------------------
319
320#[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    /// Single pre-programmed response returned on every `get()`. Tests
330    /// verify the call arguments (URL, token, timeout) separately via
331    /// `captured`.
332    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        // `httpdate` validates the day-name-vs-date consistency, so
455        // build the header value via the crate's own formatter rather
456        // than hand-rolling a hopefully-correct fixed date.
457        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        // Regression guard for credentials.md §Non-functional:
513        // token bytes must not appear in error output for a request
514        // that carried them, even when the server rejects auth.
515        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        // Roundtrip via `httpdate::fmt_http_date` so we feed parse the
549        // same format the library emits — eliminates day-name drift.
550        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        // A past date can't yield a positive duration-since-now.
559        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        // u64::MAX seconds would otherwise produce a Duration the
572        // orchestrator can't meaningfully compare to a cache TTL.
573        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        // `UreqTransport::new` is infallible by contract: a future
580        // refactor that wires the proxy via a fallible API without
581        // handling the error would regress here. The user_agent
582        // assertion doubles as a constructor smoke for the
583        // unrelated field. ureq's `Proxy::try_from_env` is the
584        // canonical RFC implementation; the warn-on-unparseable-
585        // env-var case our wrapper adds needs a warn-sink hook to
586        // assert directly.
587        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        // Regression guard for the ADR-0011 §Endpoint contract
594        // User-Agent requirement. Pins the format so a future
595        // refactor can't silently drop the version suffix.
596        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        // 2xx non-200 responses with empty bodies collapse to
608        // ParseError via `serde_json::from_slice(b"")`. Lock the
609        // behavior down so a future change to 204 handling is
610        // intentional.
611        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        // A set-but-unparseable proxy env var must name the VARIABLE
647        // and the action in the warn, but never echo the value. Proxy
648        // URLs routinely embed `user:pass@`; leaking those into CI logs
649        // is the failure mode this redaction prevents.
650        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        // ALL_PROXY, HTTPS_PROXY, and HTTP_PROXY are all set to
694        // unparseable garbage. The loop stops on the first match, so
695        // the warn must name ALL_PROXY (first in ureq's precedence) and
696        // not the later vars. The value is also banned from the warn
697        // (redaction guard for the precedence path).
698        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        // `export HTTPS_PROXY=""` is the same as unset for routing
729        // purposes — surfacing a warn would flood logs for users who
730        // null an inherited proxy via empty assignment.
731        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}