use crate::error::{Error, Result};
use crate::options::MarketType;
use crate::transport::HttpResponse;
pub(crate) const SPOT_AND_LINEAR: &[MarketType] = &[MarketType::Spot, MarketType::UsdMFutures];
pub(crate) const SPOT_ONLY: &[MarketType] = &[MarketType::Spot];
pub(crate) fn ensure_market_is_routed(
venue: &'static str,
asked: MarketType,
routed: &[MarketType],
) -> Result<()> {
if routed.contains(&asked) {
return Ok(());
}
Err(Error::unsupported_market(venue, asked))
}
const BODY_EXCERPT_CHARS: usize = 200;
pub(crate) fn attribute_to_http_status(response: &HttpResponse, error: Error) -> Error {
let Error::Deserialization(detail) = error else {
return error;
};
if response.is_success() {
return Error::Deserialization(format!(
"{detail} (body: {})",
body_excerpt(&response.body)
));
}
Error::Exchange {
code: response.status.to_string(),
message: response.body.clone(),
}
}
fn body_excerpt(body: &str) -> String {
match body.char_indices().nth(BODY_EXCERPT_CHARS) {
Some((cut, _)) => format!("{}...", &body[..cut]),
None => body.to_string(),
}
}
mod binance;
mod bitget;
mod bybit;
mod coinbase;
mod gate;
mod htx;
mod kraken;
mod kucoin;
mod okx;
mod paper;
mod replay;
mod upbit;
pub use binance::Binance;
pub use bitget::Bitget;
pub use bybit::Bybit;
pub use coinbase::Coinbase;
pub use gate::Gate;
pub use htx::Htx;
pub use kraken::Kraken;
pub use kucoin::KuCoin;
pub use okx::Okx;
pub use paper::PaperExchange;
pub use replay::ReplayExchange;
pub use upbit::Upbit;
#[cfg(test)]
mod tests {
use super::{attribute_to_http_status, body_excerpt, BODY_EXCERPT_CHARS};
use crate::error::Error;
use crate::transport::HttpResponse;
const BLOCKED: &str = "{\n error: 'unavailable in your region'\n}";
#[test]
fn a_failed_status_is_reported_as_the_venue_refusing_not_the_parser_failing() {
let response = HttpResponse::new(403, BLOCKED);
let error = Error::Deserialization("key must be a string at line 2 column 5".to_string());
let attributed = attribute_to_http_status(&response, error);
let named = matches!(&attributed, Error::Exchange { code, .. } if code == "403");
assert!(named, "a non-success status must not stay a parse failure");
let kept = matches!(&attributed, Error::Exchange { message, .. } if message == BLOCKED);
assert!(kept, "the body the venue sent is the evidence");
}
#[test]
fn a_successful_status_keeps_the_drift_error_and_gains_the_body() {
let response = HttpResponse::new(200, r#"{"retCode":0,"result":{"list":"moved"}}"#);
let error = Error::Deserialization("invalid type: string".to_string());
let attributed = attribute_to_http_status(&response, error);
let drift = matches!(&attributed, Error::Deserialization(detail)
if detail.starts_with("invalid type: string (body: "));
assert!(drift, "drift under a successful status stays drift");
let shown = matches!(&attributed, Error::Deserialization(detail)
if detail.contains(r#""list":"moved""#));
assert!(shown, "the body is what makes the drift diagnosable");
}
#[test]
fn an_error_that_is_not_a_parse_failure_passes_through_untouched() {
let response = HttpResponse::new(429, "");
let attributed =
attribute_to_http_status(&response, Error::RateLimited { retry_after: None });
assert!(matches!(
attributed,
Error::RateLimited { retry_after: None }
));
}
#[test]
fn a_short_body_is_carried_whole() {
assert_eq!(body_excerpt("not json"), "not json");
}
#[test]
fn a_long_body_is_cut_and_marked() {
let cut = body_excerpt(&"x".repeat(BODY_EXCERPT_CHARS + 50));
assert_eq!(cut.len(), BODY_EXCERPT_CHARS + 3);
assert!(cut.ends_with("..."));
}
#[test]
fn a_multibyte_body_is_cut_on_a_character_boundary() {
let cut = body_excerpt(&"ß".repeat(BODY_EXCERPT_CHARS + 10));
assert_eq!(cut.chars().count(), BODY_EXCERPT_CHARS + 3);
}
}