use std::time::Duration;
use httpmock::prelude::*;
use projectx_client::{
AccountId, AmbiguityOrigin, BarUnit, CancelOrder, Client, CloseContract, ContractId,
Credentials, Endpoints, Error, HistoryRequest, ModifyOrder, OrderId, OrderQuery, OrderStatus,
OrderType, PartialCloseContract, PlaceOrder, RateLimit, RateLimitConfig, RateLimitKind, Side,
Timestamp, TradeQuery,
};
use serde_json::json;
fn limits(max_requests: usize, window: Duration) -> RateLimitConfig {
let limit = RateLimit::new(max_requests, window)
.unwrap_or_else(|error| panic!("fixture rate limit must be valid: {error}"));
RateLimitConfig::new(limit, limit)
}
fn fixture_client(server: &MockServer, rate_limits: RateLimitConfig, max_retries: u32) -> Client {
let credentials = Credentials::new("synthetic-user", "synthetic-key")
.unwrap_or_else(|error| panic!("fixture credentials must be valid: {error}"));
let endpoints = Endpoints::custom(&server.base_url(), &server.base_url())
.unwrap_or_else(|error| panic!("fixture endpoints must be valid: {error}"));
Client::builder(credentials)
.endpoints(endpoints)
.rate_limits(rate_limits)
.max_retries(max_retries)
.retry_delays(Duration::from_millis(1), Duration::from_millis(2))
.build()
.unwrap_or_else(|error| panic!("fixture client must build: {error}"))
}
async fn authenticate(client: &Client, server: &MockServer) {
let login = server
.mock_async(|when, then| {
when.method(POST).path("/api/Auth/loginKey");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0, "token": "synthetic-token"}));
})
.await;
client
.authenticate()
.await
.unwrap_or_else(|error| panic!("fixture authentication must succeed: {error}"));
login.assert_calls_async(1).await;
}
fn place_order() -> PlaceOrder {
let account_id =
AccountId::new(42).unwrap_or_else(|error| panic!("fixture account must be valid: {error}"));
let contract_id = ContractId::new("CON.F.US.MNQ.M26")
.unwrap_or_else(|error| panic!("fixture contract must be valid: {error}"));
PlaceOrder::builder(account_id, contract_id, OrderType::Market, Side::Bid, 1)
.custom_tag("rate-limit-fixture")
.build()
.unwrap_or_else(|error| panic!("fixture order must be valid: {error}"))
}
fn history_request() -> HistoryRequest {
let contract_id = ContractId::new("CON.F.US.MNQ.M26")
.unwrap_or_else(|error| panic!("fixture contract must be valid: {error}"));
HistoryRequest::builder(
contract_id,
false,
Timestamp::new("2026-01-01T00:00:00Z")
.unwrap_or_else(|error| panic!("fixture timestamp must be valid: {error}")),
Timestamp::new("2026-01-01T01:00:00Z")
.unwrap_or_else(|error| panic!("fixture timestamp must be valid: {error}")),
BarUnit::Minute,
)
.limit(60)
.build()
.unwrap_or_else(|error| panic!("fixture history request must be valid: {error}"))
}
#[tokio::test]
async fn cloned_clients_share_capacity_and_local_mutation_rejection_sends_nothing() {
let server = MockServer::start_async().await;
let accounts = server
.mock_async(|when, then| {
when.method(POST).path("/api/Account/search");
then.status(200)
.json_body(json!({"accounts": [], "success": true, "errorCode": 0}));
})
.await;
let placement = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/place");
then.status(200)
.json_body(json!({"orderId": 84, "success": true, "errorCode": 0}));
})
.await;
let window = Duration::from_mins(1);
let client = fixture_client(&server, limits(1, window), 0);
authenticate(&client, &server).await;
client
.search_active_accounts()
.await
.unwrap_or_else(|error| panic!("first general request must succeed: {error}"));
let Err(error) = client.clone().place_order(&place_order()).await else {
panic!("the shared general budget must reject the mutation locally");
};
assert!(matches!(
error,
Error::LocallyRateLimited {
kind: RateLimitKind::General,
retry_after
} if retry_after <= window && !retry_after.is_zero()
));
accounts.assert_calls_async(1).await;
placement.assert_calls_async(0).await;
}
#[tokio::test]
async fn cloned_clients_reach_nontrivial_general_and_history_limits() {
let server = MockServer::start_async().await;
let accounts = server
.mock_async(|when, then| {
when.method(POST).path("/api/Account/search");
then.status(200)
.json_body(json!({"accounts": [], "success": true, "errorCode": 0}));
})
.await;
let history = server
.mock_async(|when, then| {
when.method(POST).path("/api/History/retrieveBars");
then.status(200)
.json_body(json!({"bars": [], "success": true, "errorCode": 0}));
})
.await;
let placement = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/place");
then.status(200)
.json_body(json!({"orderId": 84, "success": true, "errorCode": 0}));
})
.await;
let window = Duration::from_mins(1);
let client = fixture_client(&server, limits(3, window), 0);
authenticate(&client, &server).await;
let clients = [client.clone(), client.clone(), client.clone()];
let request = history_request();
for client_clone in &clients {
client_clone
.search_active_accounts()
.await
.unwrap_or_else(|error| panic!("every allowed general request must succeed: {error}"));
}
for client_clone in &clients {
client_clone
.retrieve_bars(&request)
.await
.unwrap_or_else(|error| panic!("every allowed history request must succeed: {error}"));
}
let Err(error) = client.place_order(&place_order()).await else {
panic!("the fourth general request must be rejected locally");
};
assert!(matches!(
error,
Error::LocallyRateLimited {
kind: RateLimitKind::General,
retry_after
} if retry_after <= window && !retry_after.is_zero()
));
accounts.assert_calls_async(3).await;
history.assert_calls_async(3).await;
placement.assert_calls_async(0).await;
}
#[tokio::test]
async fn retrieve_bars_uses_a_budget_independent_from_general_queries() {
let server = MockServer::start_async().await;
let accounts = server
.mock_async(|when, then| {
when.method(POST).path("/api/Account/search");
then.status(200)
.json_body(json!({"accounts": [], "success": true, "errorCode": 0}));
})
.await;
let history = server
.mock_async(|when, then| {
when.method(POST).path("/api/History/retrieveBars");
then.status(200)
.json_body(json!({"bars": [], "success": true, "errorCode": 0}));
})
.await;
let client = fixture_client(&server, limits(1, Duration::from_mins(1)), 0);
authenticate(&client, &server).await;
client
.search_active_accounts()
.await
.unwrap_or_else(|error| panic!("general query must succeed: {error}"));
let request = history_request();
client
.retrieve_bars(&request)
.await
.unwrap_or_else(|error| panic!("history query must use its own budget: {error}"));
accounts.assert_calls_async(1).await;
history.assert_calls_async(1).await;
}
#[tokio::test]
async fn provider_retry_after_cools_the_shared_budget() {
let server = MockServer::start_async().await;
let accounts = server
.mock_async(|when, then| {
when.method(POST).path("/api/Account/search");
then.status(429).header("Retry-After", "60");
})
.await;
let placement = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/place");
then.status(200)
.json_body(json!({"orderId": 84, "success": true, "errorCode": 0}));
})
.await;
let client = fixture_client(&server, limits(10, Duration::from_mins(1)), 0);
authenticate(&client, &server).await;
let Err(query_error) = client.search_active_accounts().await else {
panic!("provider HTTP 429 must remain observable after retries are exhausted");
};
assert!(matches!(
query_error,
Error::ProviderRateLimited {
kind: RateLimitKind::General,
retry_after
} if retry_after == Duration::from_mins(1)
));
let Err(mutation_error) = client.place_order(&place_order()).await else {
panic!("the shared provider cooldown must reject a mutation before sending");
};
assert!(matches!(
mutation_error,
Error::LocallyRateLimited {
kind: RateLimitKind::General,
retry_after
} if !retry_after.is_zero()
));
accounts.assert_calls_async(1).await;
placement.assert_calls_async(0).await;
}
#[tokio::test]
async fn provider_rate_limit_after_mutation_send_is_ambiguous_and_never_retried() {
let server = MockServer::start_async().await;
let placement = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/place");
then.status(429).header("Retry-After", "1");
})
.await;
let client = fixture_client(&server, limits(10, Duration::from_mins(1)), 3);
authenticate(&client, &server).await;
let Err(error) = client.place_order(&place_order()).await else {
panic!("a provider response after mutation submission must be reconciled");
};
assert!(matches!(
error,
Error::AmbiguousMutation {
operation: "order placement",
code: None,
origin: Some(AmbiguityOrigin::RateLimited),
..
}
));
placement.assert_calls_async(1).await;
}
#[tokio::test]
async fn session_validator_shutdown_cancels_a_rate_limit_wait() {
let server = MockServer::start_async().await;
let login = server
.mock_async(|when, then| {
when.method(POST).path("/api/Auth/loginKey");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0, "token": "synthetic-token"}));
})
.await;
let accounts = server
.mock_async(|when, then| {
when.method(POST).path("/api/Account/search");
then.status(200)
.json_body(json!({"accounts": [], "success": true, "errorCode": 0}));
})
.await;
let validation = server
.mock_async(|when, then| {
when.method(POST).path("/api/Auth/validate").body("");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
let client = fixture_client(&server, limits(1, Duration::from_mins(1)), 0);
let validator = client
.authenticate_with_validation(Duration::from_secs(1))
.await
.unwrap_or_else(|error| panic!("fixture validation must start: {error}"));
client
.search_active_accounts()
.await
.unwrap_or_else(|error| panic!("query must consume the general budget: {error}"));
tokio::time::sleep(Duration::from_millis(1_050)).await;
tokio::time::timeout(Duration::from_millis(100), validator.shutdown())
.await
.unwrap_or_else(|_| panic!("validator shutdown must cancel the limiter wait"))
.unwrap_or_else(|error| panic!("validator task must shut down cleanly: {error}"));
login.assert_calls_async(1).await;
accounts.assert_calls_async(1).await;
validation.assert_calls_async(0).await;
}
fn reserve_config() -> RateLimitConfig {
limits(5, Duration::from_mins(1))
.with_exit_reserve(2)
.unwrap_or_else(|error| panic!("fixture reserve must be valid: {error}"))
}
fn cancel_request() -> CancelOrder {
CancelOrder {
account_id: AccountId::new(42).unwrap_or_else(|error| panic!("{error}")),
order_id: OrderId::new(84).unwrap_or_else(|error| panic!("{error}")),
}
}
fn close_request() -> CloseContract {
CloseContract {
account_id: AccountId::new(42).unwrap_or_else(|error| panic!("{error}")),
contract_id: ContractId::new("CON.F.US.MNQ.M26").unwrap_or_else(|error| panic!("{error}")),
}
}
#[tokio::test]
async fn queued_trade_queries_cannot_consume_cancel_and_close_reserve() {
let server = MockServer::start_async().await;
let trades = server
.mock_async(|when, then| {
when.method(POST).path("/api/Trade/search");
then.status(200)
.json_body(json!({"trades": [], "success": true, "errorCode": 0}));
})
.await;
let cancel = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/cancel");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
let close = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/closeContract");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
let place = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/place");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0, "orderId": 84}));
})
.await;
let modify = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/modify");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
let validation = server
.mock_async(|when, then| {
when.method(POST).path("/api/Auth/validate");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
let query = TradeQuery::builder(close_request().account_id)
.build()
.unwrap_or_else(|error| panic!("{error}"));
for _ in 0..3 {
assert!(client.query_trades(&query).await.is_ok());
}
let shared_client = client.clone();
let mut waiting = Box::pin(shared_client.query_trades(&query));
assert!(futures_util::poll!(&mut waiting).is_pending());
let modification = ModifyOrder::builder(cancel_request().account_id, cancel_request().order_id)
.size(1)
.build()
.unwrap_or_else(|error| panic!("{error}"));
assert!(matches!(
client.place_order(&place_order()).await,
Err(Error::LocallyRateLimited {
kind: RateLimitKind::General,
..
})
));
assert!(matches!(
client.modify_order(&modification).await,
Err(Error::LocallyRateLimited {
kind: RateLimitKind::General,
..
})
));
let mut validating = Box::pin(shared_client.validate_session());
assert!(futures_util::poll!(&mut validating).is_pending());
assert!(matches!(
client.logout().await,
Err(Error::LocallyRateLimited {
kind: RateLimitKind::General,
..
})
));
assert!(client.cancel_order(&cancel_request()).await.is_ok());
assert!(futures_util::poll!(&mut waiting).is_pending());
assert!(client.close_contract(&close_request()).await.is_ok());
assert!(futures_util::poll!(&mut waiting).is_pending());
assert!(
matches!(shared_client.cancel_order(&cancel_request()).await, Err(Error::LocallyRateLimited { kind: RateLimitKind::General, retry_after }) if !retry_after.is_zero() && retry_after <= Duration::from_mins(1))
);
trades.assert_calls_async(3).await;
cancel.assert_calls_async(1).await;
close.assert_calls_async(1).await;
place.assert_calls_async(0).await;
modify.assert_calls_async(0).await;
validation.assert_calls_async(0).await;
}
#[tokio::test]
async fn concurrent_clones_share_two_reserved_partial_close_attempts() {
let server = MockServer::start_async().await;
let accounts = server
.mock_async(|when, then| {
when.method(POST).path("/api/Account/search");
then.status(200)
.json_body(json!({"accounts": [], "success": true, "errorCode": 0}));
})
.await;
let partial = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/partialCloseContract");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
for _ in 0..3 {
assert!(client.search_active_accounts().await.is_ok());
}
let close = close_request();
let request = PartialCloseContract::new(close.account_id, close.contract_id, 1)
.unwrap_or_else(|error| panic!("{error}"));
let clones: Vec<_> = (0..20).map(|_| client.clone()).collect();
let results = futures_util::future::join_all(
clones
.iter()
.map(|client| client.partial_close_contract(&request)),
)
.await;
let mut accepted = 0;
let mut refused = 0;
for result in results {
match result {
Ok(_) => accepted += 1,
Err(Error::LocallyRateLimited {
kind: RateLimitKind::General,
retry_after,
}) => {
assert!(!retry_after.is_zero());
refused += 1;
}
Err(error) => panic!("unexpected exit outcome: {error}"),
}
}
assert_eq!(accepted, 2);
assert_eq!(refused, 18);
accounts.assert_calls_async(3).await;
partial.assert_calls_async(2).await;
}
#[tokio::test]
async fn provider_cooldown_refuses_reserved_exits_without_sending() {
let server = MockServer::start_async().await;
let query = server
.mock_async(|when, then| {
when.method(POST).path("/api/Account/search");
then.status(429).header("Retry-After", "60");
})
.await;
let close = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/closeContract");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
let client = fixture_client(&server, reserve_config(), 0);
authenticate(&client, &server).await;
assert!(
matches!(client.search_active_accounts().await, Err(Error::ProviderRateLimited { kind: RateLimitKind::General, retry_after }) if retry_after == Duration::from_mins(1))
);
assert!(
matches!(client.clone().close_contract(&close_request()).await, Err(Error::LocallyRateLimited { kind: RateLimitKind::General, retry_after }) if !retry_after.is_zero())
);
query.assert_calls_async(1).await;
close.assert_calls_async(0).await;
}
#[tokio::test]
async fn reserved_exit_server_errors_are_ambiguous_single_attempts() {
let server = MockServer::start_async().await;
let cancel = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/cancel");
then.status(500);
})
.await;
let close = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/closeContract");
then.status(500);
})
.await;
let partial = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/partialCloseContract");
then.status(500);
})
.await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
let closing = close_request();
let partial_request =
PartialCloseContract::new(closing.account_id, closing.contract_id.clone(), 1)
.unwrap_or_else(|error| panic!("{error}"));
for (result, expected_operation) in [
(
client.cancel_order(&cancel_request()).await,
"order cancellation",
),
(client.close_contract(&closing).await, "position close"),
(
client.partial_close_contract(&partial_request).await,
"partial position close",
),
] {
assert!(
matches!(result, Err(Error::AmbiguousMutation { operation, origin: Some(AmbiguityOrigin::HttpStatus(500)), .. }) if operation == expected_operation)
);
}
cancel.assert_calls_async(1).await;
close.assert_calls_async(1).await;
partial.assert_calls_async(1).await;
}
fn nonterminal_query() -> OrderQuery {
OrderQuery::builder(close_request().account_id)
.statuses([
OrderStatus::Open,
OrderStatus::Pending,
OrderStatus::PendingCancellation,
OrderStatus::Suspended,
])
.page_size(100)
.include_total_count(true)
.build()
.unwrap_or_else(|error| panic!("fixture order query must be valid: {error}"))
}
struct ExitReads<'a> {
trades: httpmock::Mock<'a>,
positions: httpmock::Mock<'a>,
orders: httpmock::Mock<'a>,
close: httpmock::Mock<'a>,
}
async fn exit_reads(server: &MockServer) -> ExitReads<'_> {
let trades = server
.mock_async(|when, then| {
when.method(POST).path("/api/Trade/search");
then.status(200)
.json_body(json!({"trades": [], "success": true, "errorCode": 0}));
})
.await;
let positions = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/searchOpen");
then.status(200).json_body(json!({
"positions": [{"id": 7, "accountId": 42, "contractId": "CON.F.US.MNQ.M26",
"creationTimestamp": "2026-01-01T00:00:00Z", "type": 1, "size": 1,
"averagePrice": 21000.25}],
"success": true, "errorCode": 0,
}));
})
.await;
let orders = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/v2/query");
then.status(200).json_body(json!({
"orders": [{"id": 84, "accountId": 42, "contractId": "CON.F.US.MNQ.M26",
"creationTimestamp": "2026-01-01T00:00:00Z",
"updateTimestamp": "2026-01-01T00:00:00Z", "status": 8, "type": 4,
"side": 1, "size": 1, "fillVolume": 0, "parentOrderId": 80}],
"totalCount": 1, "success": true, "errorCode": 0,
}));
})
.await;
let close = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/closeContract");
then.status(200)
.json_body(json!({"success": true, "errorCode": 0}));
})
.await;
ExitReads {
trades,
positions,
orders,
close,
}
}
async fn fill_ordinary(client: &Client) {
let query = TradeQuery::builder(close_request().account_id)
.build()
.unwrap_or_else(|error| panic!("{error}"));
for _ in 0..3 {
assert!(client.query_trades(&query).await.is_ok());
}
}
const PROMPT: Duration = Duration::from_secs(5);
#[tokio::test]
async fn exit_reads_go_out_at_once_under_full_ordinary_pressure() {
let server = MockServer::start_async().await;
let gateway = exit_reads(&server).await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
fill_ordinary(&client).await;
let query = TradeQuery::builder(close_request().account_id)
.build()
.unwrap_or_else(|error| panic!("{error}"));
let shared = client.clone();
let mut waiting = Box::pin(shared.query_trades(&query));
assert!(futures_util::poll!(&mut waiting).is_pending());
let positions = tokio::time::timeout(
PROMPT,
client.search_open_positions_for_exit(close_request().account_id),
)
.await
.unwrap_or_else(|_| panic!("the exit position search must not wait"))
.unwrap_or_else(|error| panic!("the exit position search must be admitted: {error}"));
let orders = tokio::time::timeout(PROMPT, client.query_orders_for_exit(&nonterminal_query()))
.await
.unwrap_or_else(|_| panic!("the exit order query must not wait"))
.unwrap_or_else(|error| panic!("the exit order query must be admitted: {error}"));
assert_eq!(positions.into_listed().map(|rows| rows.len()), Some(1));
assert_eq!(orders.total_count, Some(1));
assert_eq!(orders.orders.into_listed().map(|rows| rows.len()), Some(1));
assert!(futures_util::poll!(&mut waiting).is_pending());
gateway.trades.assert_calls_async(3).await;
gateway.positions.assert_calls_async(1).await;
gateway.orders.assert_calls_async(1).await;
}
#[tokio::test]
async fn exit_reads_can_spend_the_reserve_a_later_close_needs() {
let server = MockServer::start_async().await;
let gateway = exit_reads(&server).await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
fill_ordinary(&client).await;
for _ in 0..2 {
let read = tokio::time::timeout(
PROMPT,
client.search_open_positions_for_exit(close_request().account_id),
)
.await
.unwrap_or_else(|_| panic!("the exit position search must not wait"));
assert!(read.is_ok());
}
assert!(matches!(
client.close_contract(&close_request()).await,
Err(Error::LocallyRateLimited {
kind: RateLimitKind::General,
..
})
));
gateway.positions.assert_calls_async(2).await;
gateway.close.assert_calls_async(0).await;
}
#[tokio::test]
async fn an_exhausted_exit_reserve_refuses_exit_reads_without_sending() {
let server = MockServer::start_async().await;
let gateway = exit_reads(&server).await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
fill_ordinary(&client).await;
for _ in 0..2 {
assert!(client.close_contract(&close_request()).await.is_ok());
}
let positions = tokio::time::timeout(
PROMPT,
client.search_open_positions_for_exit(close_request().account_id),
)
.await
.unwrap_or_else(|_| panic!("an exhausted exit position search must not wait"));
let orders = tokio::time::timeout(PROMPT, client.query_orders_for_exit(&nonterminal_query()))
.await
.unwrap_or_else(|_| panic!("an exhausted exit order query must not wait"));
for refused in [positions.map(|_| ()), orders.map(|_| ())] {
assert!(
matches!(refused, Err(Error::LocallyRateLimited { kind: RateLimitKind::General, retry_after }) if !retry_after.is_zero() && retry_after <= Duration::from_mins(1))
);
}
gateway.close.assert_calls_async(2).await;
gateway.positions.assert_calls_async(0).await;
gateway.orders.assert_calls_async(0).await;
}
#[tokio::test]
async fn a_provider_429_on_an_exit_read_is_a_plain_refusal_sent_once() {
let server = MockServer::start_async().await;
let positions = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/searchOpen");
then.status(429).header("Retry-After", "60");
})
.await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
let refused = tokio::time::timeout(
PROMPT,
client.search_open_positions_for_exit(close_request().account_id),
)
.await
.unwrap_or_else(|_| panic!("a refused exit position search must not wait to retry"));
assert!(
matches!(refused, Err(Error::ProviderRateLimited { kind: RateLimitKind::General, retry_after }) if retry_after == Duration::from_mins(1))
);
positions.assert_calls_async(1).await;
}
#[tokio::test]
async fn a_provider_429_on_the_exit_order_query_is_a_plain_refusal_sent_once() {
let server = MockServer::start_async().await;
let orders = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/v2/query");
then.status(429).header("Retry-After", "60");
})
.await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
let refused = tokio::time::timeout(PROMPT, client.query_orders_for_exit(&nonterminal_query()))
.await
.unwrap_or_else(|_| panic!("a refused exit order query must not wait to retry"));
assert!(
matches!(refused, Err(Error::ProviderRateLimited { kind: RateLimitKind::General, retry_after }) if retry_after == Duration::from_mins(1))
);
orders.assert_calls_async(1).await;
}
#[tokio::test]
async fn a_provider_cooldown_refuses_both_exit_reads_without_sending() {
let server = MockServer::start_async().await;
let positions = server
.mock_async(|when, then| {
when.method(POST).path("/api/Position/searchOpen");
then.status(429).header("Retry-After", "60");
})
.await;
let orders = server
.mock_async(|when, then| {
when.method(POST).path("/api/Order/v2/query");
then.status(200).json_body(json!({
"orders": [], "totalCount": 0, "success": true, "errorCode": 0,
}));
})
.await;
let client = fixture_client(&server, reserve_config(), 3);
authenticate(&client, &server).await;
let cooled = tokio::time::timeout(
PROMPT,
client.search_open_positions_for_exit(close_request().account_id),
)
.await
.unwrap_or_else(|_| panic!("a refused exit position search must not wait to retry"));
assert!(matches!(cooled, Err(Error::ProviderRateLimited { .. })));
let again = tokio::time::timeout(
PROMPT,
client.search_open_positions_for_exit(close_request().account_id),
)
.await
.unwrap_or_else(|_| panic!("an exit position search in a cooldown must not wait"));
let query = tokio::time::timeout(PROMPT, client.query_orders_for_exit(&nonterminal_query()))
.await
.unwrap_or_else(|_| panic!("an exit order query in a cooldown must not wait"));
for refused in [again.map(|_| ()), query.map(|_| ())] {
assert!(
matches!(refused, Err(Error::LocallyRateLimited { kind: RateLimitKind::General, retry_after }) if !retry_after.is_zero() && retry_after <= Duration::from_mins(1))
);
}
positions.assert_calls_async(1).await;
orders.assert_calls_async(0).await;
}
#[test]
fn runtime_reserve_is_shared_in_debug_and_disabled_clients_refuse_changes() {
let credentials =
Credentials::new("fixture-user", "fixture-key").unwrap_or_else(|error| panic!("{error}"));
let client = Client::builder(credentials)
.build()
.unwrap_or_else(|error| panic!("{error}"));
let clone = client.clone();
client
.set_exit_reserve(20)
.unwrap_or_else(|error| panic!("{error}"));
assert!(format!("{clone:?}").contains("exit_reserve: 20"));
clone
.set_exit_reserve(30)
.unwrap_or_else(|error| panic!("{error}"));
assert!(format!("{client:?}").contains("exit_reserve: 30"));
assert!(matches!(
clone.set_exit_reserve(200),
Err(Error::Configuration(_))
));
assert!(format!("{client:?}").contains("exit_reserve: 30"));
client
.set_exit_reserve(0)
.unwrap_or_else(|error| panic!("{error}"));
assert!(format!("{clone:?}").contains("exit_reserve: 0"));
let credentials =
Credentials::new("fixture-user", "fixture-key").unwrap_or_else(|error| panic!("{error}"));
let independent = Client::builder(credentials)
.build()
.unwrap_or_else(|error| panic!("{error}"));
independent
.set_exit_reserve(10)
.unwrap_or_else(|error| panic!("{error}"));
assert!(format!("{client:?}").contains("exit_reserve: 0"));
let credentials =
Credentials::new("fixture-user", "fixture-key").unwrap_or_else(|error| panic!("{error}"));
let disabled = Client::builder(credentials)
.disable_rate_limits()
.build()
.unwrap_or_else(|error| panic!("{error}"));
for reserve in [0, 20, usize::MAX] {
assert!(matches!(
disabled.set_exit_reserve(reserve),
Err(Error::Configuration(_))
));
assert!(format!("{disabled:?}").contains("rate_limits: None"));
}
}