use std::sync::Arc;
use std::time::Duration;
use zero_engine_client::{
HttpClient, HttpError, ManualClock, RateBudget, RateLimitSource, SystemClock,
};
use zero_testkit::mock_engine::MockEngine;
fn client_with_full_budget(mock: &MockEngine) -> HttpClient {
let budget = RateBudget::with_clock(60, 1.0, Arc::new(SystemClock));
HttpClient::new(mock.base_url(), Some("tok".to_string()))
.expect("client")
.with_rate_budget(budget)
}
#[tokio::test]
async fn cli_budget_exhausted_refuses_without_touching_network() {
let mock = MockEngine::spawn().await.expect("mock");
let clock = ManualClock::new();
let budget = RateBudget::with_clock(1, 0.0, clock);
budget.try_consume(1).unwrap();
let client = HttpClient::new(mock.base_url(), Some("tok".to_string()))
.expect("client")
.with_rate_budget(budget);
match client.v2_status().await {
Err(HttpError::RateBudgetExhausted {
retry_after,
origin,
}) => {
assert_eq!(origin, RateLimitSource::CliBudget);
assert_eq!(retry_after, Duration::MAX);
}
other => panic!("expected CliBudget exhaustion, got {other:?}"),
}
mock.shutdown().await;
}
#[tokio::test]
async fn engine_429_refunds_local_bucket_and_surfaces_retry_after() {
let mock = MockEngine::spawn().await.expect("mock");
mock.with_overrides(|o| {
o.rate_limit_count = 1;
o.rate_limit_retry_after = Some("7".to_string());
});
let client = client_with_full_budget(&mock);
let before = client.rate_budget().unwrap().snapshot().tokens;
let err = client
.v2_status()
.await
.expect_err("engine 429 must surface as error");
match err {
HttpError::RateBudgetExhausted {
retry_after,
origin,
} => {
assert_eq!(origin, RateLimitSource::Engine429);
assert_eq!(retry_after, Duration::from_secs(7));
}
other => panic!("expected Engine429 exhaustion, got {other:?}"),
}
let after = client.rate_budget().unwrap().snapshot().tokens;
assert_eq!(
before, after,
"engine-429 must refund the local bucket so the operator is not double-charged",
);
mock.shutdown().await;
}
#[tokio::test]
async fn engine_429_is_never_auto_retried() {
let mock = MockEngine::spawn().await.expect("mock");
mock.with_overrides(|o| {
o.rate_limit_count = 1;
o.rate_limit_retry_after = Some("1".to_string());
});
let client = client_with_full_budget(&mock);
let first = client.v2_status().await;
assert!(
matches!(first, Err(HttpError::RateBudgetExhausted { .. })),
"expected 429-originated exhaustion, got {first:?}",
);
let second = client.v2_status().await;
assert!(
second.is_ok(),
"after the counter drains the engine returns 200; got {second:?}",
);
mock.shutdown().await;
}
#[tokio::test]
async fn retry_after_missing_header_defaults_to_one_second() {
let mock = MockEngine::spawn().await.expect("mock");
mock.with_overrides(|o| {
o.rate_limit_count = 1;
o.rate_limit_retry_after = None; });
let client = client_with_full_budget(&mock);
match client.v2_status().await {
Err(HttpError::RateBudgetExhausted { retry_after, .. }) => {
assert_eq!(retry_after, Duration::from_secs(1));
}
other => panic!("expected default-retry 429, got {other:?}"),
}
mock.shutdown().await;
}
#[tokio::test]
async fn budget_cost_is_applied_per_endpoint() {
let mock = MockEngine::spawn().await.expect("mock");
let budget = RateBudget::with_clock(4, 0.0, Arc::new(SystemClock));
let client = HttpClient::new(mock.base_url(), Some("tok".to_string()))
.expect("client")
.with_rate_budget(budget);
client.v2_status().await.expect("first v2 status");
client.positions().await.expect("positions");
match client.v2_status().await {
Err(HttpError::RateBudgetExhausted {
origin: RateLimitSource::CliBudget,
..
}) => {}
other => panic!("expected CLI-budget exhaustion on second v2_status, got {other:?}"),
}
mock.shutdown().await;
}