test(usage): cover the provider HTTP paths against a loopback server

The Claude and Codex fetches now take their endpoint URLs as arguments,
with poll supplying the real ones, so tests can point them at the local
one-shot server. The tests pin the current behaviour: usage auth
failures stop before the Messages fallback, server errors and unparsable
bodies fall back to Messages, partial bodies get their reset times
merged, Messages auth failures and missing rate-limit headers are
reported, Codex 401 and 500 map to auth-required and bad-response, and
the account header is sent only when an account id is set.
This commit is contained in:
tiennm99 committed 2026-10-23 03:09:40 +07:00
1 parent 100cd59194
commit 13810a6004
2 files changed
+268 -35

No files matched your search

+151 -9
View File
@@ -44,29 +44,38 @@ impl UsageProvider for ClaudeProvider {
if token_is_expired(token.expires_at_unix_ms) {
return Err(Error::AuthRequired);
}
fetch_with_fallback(http, &token.access_token)
fetch_with_fallback(http, &token.access_token, USAGE_URL, MESSAGES_URL)
}
}
fn fetch_with_fallback(http: &Client, token: &str) -> Result<UsageWindows, Error> {
fn fetch_with_fallback(
http: &Client,
token: &str,
usage_url: &str,
messages_url: &str,
) -> Result<UsageWindows, Error> {
// First try the dedicated endpoint.
match try_usage_endpoint(http, token)? {
match try_usage_endpoint(http, token, usage_url)? {
Some(windows) if has_reset_times(&windows) => return Ok(windows),
Some(partial) => {
// Got percentages but no reset times — fill them in from messages.
if let Ok(fallback) = try_messages_endpoint(http, token) {
if let Ok(fallback) = try_messages_endpoint(http, token, messages_url) {
return Ok(merge_resets(partial, fallback));
}
return Ok(partial);
}
None => {}
}
try_messages_endpoint(http, token)
try_messages_endpoint(http, token, messages_url)
}
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> {
fn try_usage_endpoint(
http: &Client,
token: &str,
url: &str,
) -> Result<Option<UsageWindows>, Error> {
let resp = match http
.get(USAGE_URL)
.get(url)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", BETA_HEADER)
.send()
@@ -95,7 +104,7 @@ fn parse_usage_body(body: &[u8]) -> Option<UsageWindows> {
Some(UsageWindows { primary, secondary })
}
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
fn try_messages_endpoint(http: &Client, token: &str, url: &str) -> Result<UsageWindows, Error> {
for model in PROBE_MODELS {
let body = serde_json::json!({
"model": model,
@@ -103,7 +112,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
"messages": [{"role": "user", "content": "."}],
});
let resp = match http
.post(MESSAGES_URL)
.post(url)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-version", API_VERSION)
.header("anthropic-beta", BETA_HEADER)
@@ -264,6 +273,8 @@ struct Bucket {
#[cfg(test)]
mod tests {
use super::*;
use crate::net::test_server::{serve_once, Served};
use crate::net::USER_AGENT;
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
@@ -381,4 +392,135 @@ mod tests {
assert!(token_is_expired(Some(0)));
assert!(!token_is_expired(Some(i64::MAX)));
}
// --- HTTP paths against a loopback server ---
const RATE_LIMIT_HEADERS: &[(&str, &str)] = &[
("anthropic-ratelimit-unified-5h-utilization", "0.25"),
("anthropic-ratelimit-unified-5h-reset", "1000"),
("anthropic-ratelimit-unified-7d-utilization", "0.5"),
("anthropic-ratelimit-unified-7d-reset", "2000"),
];
fn client() -> Client {
Client::without_proxy(USER_AGENT)
}
fn fetch(usage: &Served, messages: &Served) -> Result<UsageWindows, Error> {
fetch_with_fallback(&client(), "tok", &usage.base, &messages.base)
}
fn received(served: &Served) -> String {
served
.request
.recv_timeout(Duration::from_secs(10))
.expect("server saw the request")
}
fn assert_not_called(served: &Served) {
assert!(
served
.request
.recv_timeout(Duration::from_millis(300))
.is_err(),
"endpoint must not be called"
);
}
fn messages_with_headers() -> Served {
serve_once("200 OK", RATE_LIMIT_HEADERS, b"{}".to_vec())
}
#[test]
fn usage_auth_failures_stop_before_the_messages_fallback() {
for status in ["401 Unauthorized", "403 Forbidden"] {
let usage = serve_once(status, &[], b"{}".to_vec());
let messages = messages_with_headers();
let result = fetch(&usage, &messages);
assert!(
matches!(result, Err(Error::AuthRequired)),
"{status}: {result:?}"
);
let request = received(&usage);
assert!(request.starts_with("GET / HTTP/1.1\r\n"), "{request}");
let lower = request.to_ascii_lowercase();
assert!(lower.contains("authorization: bearer tok\r\n"), "{request}");
assert!(
lower.contains("anthropic-beta: oauth-2025-04-20\r\n"),
"{request}"
);
assert_not_called(&messages);
}
}
#[test]
fn usage_server_error_falls_back_to_messages_headers() {
let usage = serve_once("500 Internal Server Error", &[], b"oops".to_vec());
let messages = messages_with_headers();
let w = fetch(&usage, &messages).expect("fallback windows");
assert_eq!(w.primary.utilization, 25.0);
assert_eq!(w.primary.resets_at, at(1000));
assert_eq!(w.secondary.utilization, 50.0);
assert_eq!(w.secondary.resets_at, at(2000));
let request = received(&messages);
assert!(request.starts_with("POST / HTTP/1.1\r\n"), "{request}");
assert!(request
.to_ascii_lowercase()
.contains("anthropic-version: 2023-06-01\r\n"));
assert!(request.contains("\"max_tokens\":1"), "{request}");
}
#[test]
fn unparsable_usage_body_falls_back_to_messages_headers() {
let usage = serve_once("200 OK", &[], b"not json".to_vec());
let messages = messages_with_headers();
let w = fetch(&usage, &messages).expect("fallback windows");
assert_eq!(w.primary.utilization, 25.0);
assert_eq!(w.secondary.resets_at, at(2000));
received(&messages);
}
#[test]
fn full_usage_body_skips_the_messages_call() {
let usage = serve_once("200 OK", &[], fixture("anthropic-usage-full.json"));
let messages = messages_with_headers();
let w = fetch(&usage, &messages).expect("usage windows");
assert_eq!(w.primary.utilization, 42.5);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_not_called(&messages);
}
#[test]
fn partial_usage_body_merges_reset_times_from_messages() {
let usage = serve_once("200 OK", &[], fixture("anthropic-usage-partial.json"));
let messages = messages_with_headers();
let w = fetch(&usage, &messages).expect("merged windows");
assert_eq!(w.primary.utilization, 7.0, "usage percent is kept");
assert_eq!(w.primary.resets_at, at(1000), "missing reset is filled in");
assert_eq!(w.secondary.utilization, 61.0);
assert_eq!(
w.secondary.resets_at,
at(1_709_251_199),
"known reset is kept"
);
received(&messages);
}
#[test]
fn messages_auth_failure_is_auth_required() {
let usage = serve_once("503 Service Unavailable", &[], Vec::new());
let messages = serve_once("401 Unauthorized", RATE_LIMIT_HEADERS, b"{}".to_vec());
let result = fetch(&usage, &messages);
assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}");
}
#[test]
fn messages_without_rate_limit_headers_is_a_bad_response() {
// The second probe model finds the one-shot server gone, which
// counts as a failed probe too.
let usage = serve_once("500 Internal Server Error", &[], Vec::new());
let messages = serve_once("200 OK", &[], b"{}".to_vec());
let result = fetch(&usage, &messages);
assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}");
}
}
+117 -26
View File
@@ -35,35 +35,50 @@ impl UsageProvider for ChatGptProvider {
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
let token = source.read()?;
let mut req = http
.get(USAGE_URL)
.header("Authorization", &format!("Bearer {}", token.access_token))
.header("User-Agent", "codex-cli");
if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) {
req = req.header("ChatGPT-Account-Id", account_id);
}
let resp = match req.send() {
Ok(r) => r,
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
return Err(Error::AuthRequired);
}
Err(e) => return Err(Error::Network(e)),
};
if resp.status() == 401 || resp.status() == 403 {
fetch(
http,
USAGE_URL,
&token.access_token,
token.account_id.as_deref(),
)
}
}
/// Usage windows from the Codex usage endpoint at `url`. The account header
/// is sent only when `account_id` is set and not empty.
fn fetch(
http: &Client,
url: &str,
token: &str,
account_id: Option<&str>,
) -> Result<UsageWindows, Error> {
let mut req = http
.get(url)
.header("Authorization", &format!("Bearer {token}"))
.header("User-Agent", "codex-cli");
if let Some(account_id) = account_id.filter(|s| !s.is_empty()) {
req = req.header("ChatGPT-Account-Id", account_id);
}
let resp = match req.send() {
Ok(r) => r,
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
return Err(Error::AuthRequired);
}
if !(200..300).contains(&resp.status()) {
return Err(Error::BadResponse(format!(
"Codex usage endpoint returned {}",
resp.status()
)));
}
let body: Envelope = resp
.json()
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
envelope_to_windows(body)
.ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
Err(e) => return Err(Error::Network(e)),
};
if resp.status() == 401 || resp.status() == 403 {
return Err(Error::AuthRequired);
}
if !(200..300).contains(&resp.status()) {
return Err(Error::BadResponse(format!(
"Codex usage endpoint returned {}",
resp.status()
)));
}
let body: Envelope = resp
.json()
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
envelope_to_windows(body).ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
}
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
@@ -129,6 +144,8 @@ impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::net::test_server::{serve_once, Served};
use crate::net::USER_AGENT;
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
@@ -173,4 +190,78 @@ mod tests {
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.resets_at, at(0));
}
// --- HTTP path against a loopback server ---
fn fixture(name: &str) -> Vec<u8> {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
fn fetch_from(served: &Served, account_id: Option<&str>) -> Result<UsageWindows, Error> {
fetch(
&Client::without_proxy(USER_AGENT),
&served.base,
"tok",
account_id,
)
}
fn received(served: &Served) -> String {
served
.request
.recv_timeout(Duration::from_secs(10))
.expect("server saw the request")
.to_ascii_lowercase()
}
#[test]
fn success_maps_windows_and_sends_codex_headers() {
let served = serve_once("200 OK", &[], fixture("chatgpt-usage-full.json"));
let w = fetch_from(&served, None).expect("windows");
assert_eq!(w.primary.utilization, 12.0);
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
let request = received(&served);
assert!(
request.contains("authorization: bearer tok\r\n"),
"{request}"
);
assert!(request.contains("user-agent: codex-cli\r\n"), "{request}");
}
#[test]
fn unauthorized_is_auth_required() {
let served = serve_once("401 Unauthorized", &[], b"{}".to_vec());
let result = fetch_from(&served, None);
assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}");
}
#[test]
fn server_error_is_a_bad_response() {
let served = serve_once("500 Internal Server Error", &[], b"{}".to_vec());
let result = fetch_from(&served, None);
assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}");
}
#[test]
fn account_header_is_sent_only_with_an_account_id() {
let body = || fixture("chatgpt-usage-full.json");
let with_id = serve_once("200 OK", &[], body());
fetch_from(&with_id, Some("acct-123")).expect("windows");
let request = received(&with_id);
assert!(
request.contains("chatgpt-account-id: acct-123\r\n"),
"{request}"
);
for account_id in [None, Some("")] {
let served = serve_once("200 OK", &[], body());
fetch_from(&served, account_id).expect("windows");
let request = received(&served);
assert!(
!request.contains("chatgpt-account-id"),
"{account_id:?}: {request}"
);
}
}
}