Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions ccxt-core/src/http_client/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,37 @@ impl HttpClient {
const BODY_PREVIEW_SIZE: usize = 200;
let body_preview: String = body.chars().take(BODY_PREVIEW_SIZE).collect();

// Try to extract exchange-specific error code and message from JSON body
let exchange_code = result.get("code").and_then(serde_json::Value::as_i64);
let exchange_msg = result
.get("msg")
.and_then(serde_json::Value::as_str)
.map(std::string::ToString::to_string);

match status {
StatusCode::BAD_REQUEST => {
info!(body_preview = %body_preview, "Bad request error");
Error::invalid_request(body.to_string())
if let (Some(code), Some(msg)) = (exchange_code, &exchange_msg) {
Error::exchange(code.to_string(), msg)
} else {
Error::invalid_request(body.to_string())
}
}
StatusCode::UNAUTHORIZED => {
warn!("Authentication error: Unauthorized");
Error::authentication("Unauthorized")
if let Some(ref msg) = exchange_msg {
Error::authentication(msg.clone())
} else {
Error::authentication("Unauthorized")
}
}
StatusCode::FORBIDDEN => {
warn!("Authentication error: Forbidden");
Error::authentication("Forbidden")
if let Some(ref msg) = exchange_msg {
Error::authentication(msg.clone())
} else {
Error::authentication("Forbidden")
}
}
StatusCode::NOT_FOUND => {
info!("Resource not found");
Expand Down Expand Up @@ -189,6 +208,15 @@ impl HttpClient {
Error::rate_limit("Rate limit exceeded, please retry later", None)
}
}
// HTTP 418 - Binance uses this for IP bans
status if status.as_u16() == 418 => {
error!(body_preview = %body_preview, "IP banned (HTTP 418)");
if let (Some(code), Some(msg)) = (exchange_code, &exchange_msg) {
Error::exchange(code.to_string(), format!("IP banned: {msg}"))
} else {
Error::exchange("418", format!("IP banned: {body}"))
}
}
StatusCode::INTERNAL_SERVER_ERROR => {
error!(body_preview = %body_preview, "Internal server error");
Error::exchange("500", "Internal server error")
Expand All @@ -207,7 +235,11 @@ impl HttpClient {
body_preview = %body_preview,
"Unhandled HTTP error"
);
Error::network(format!("HTTP {status} error: {body}"))
if let (Some(code), Some(msg)) = (exchange_code, &exchange_msg) {
Error::exchange(code.to_string(), msg)
} else {
Error::network(format!("HTTP {status} error: {body}"))
}
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions ccxt-exchanges/src/binance/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,17 @@ impl Binance {
SignedRequestBuilder::new(self, endpoint)
}

/// Creates an API-key-only request builder (no signature).
///
/// Used for endpoints that require only the `X-MBX-APIKEY` header,
/// such as listen key operations.
pub(crate) fn api_key_request(
&self,
endpoint: impl Into<String>,
) -> signed_request::ApiKeyRequestBuilder<'_> {
signed_request::ApiKeyRequestBuilder::new(self, endpoint)
}

/// Returns the exchange ID.
pub fn id(&self) -> &'static str {
"binance"
Expand Down
43 changes: 39 additions & 4 deletions ccxt-exchanges/src/binance/rate_limiter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ use std::time::{Duration, Instant};
/// Default weight limit per minute for spot API.
pub const DEFAULT_WEIGHT_LIMIT_1M: u64 = 1200;

/// Default weight limit per second.
pub const DEFAULT_WEIGHT_LIMIT_1S: u64 = 6000;

/// Default order count limit per 10 seconds.
pub const DEFAULT_ORDER_LIMIT_10S: u64 = 100;

Expand All @@ -60,6 +63,8 @@ pub const THROTTLE_THRESHOLD: f64 = 0.80;
pub struct RateLimitInfo {
/// Used weight in the current 1-minute window.
pub used_weight_1m: Option<u64>,
/// Used weight in the current 1-second window.
pub used_weight_1s: Option<u64>,
/// Order count in the current 10-second window.
pub order_count_10s: Option<u64>,
/// Order count in the current day.
Expand All @@ -82,13 +87,10 @@ impl RateLimitInfo {
let mut info = Self::default();

if let Some(obj) = headers.as_object() {
// Extract X-MBX-USED-WEIGHT-1M (also fallback to x-mbx-used-weight without -1m suffix,
// and x-sapi-used-uid-weight-1m for SAPI endpoints)
// Extract X-MBX-USED-WEIGHT-1M (also fallback to x-sapi-used-uid-weight-1m for SAPI endpoints)
if let Some(weight) = obj
.get("x-mbx-used-weight-1m")
.or_else(|| obj.get("X-MBX-USED-WEIGHT-1M"))
.or_else(|| obj.get("x-mbx-used-weight"))
.or_else(|| obj.get("X-MBX-USED-WEIGHT"))
.or_else(|| obj.get("x-sapi-used-uid-weight-1m"))
.or_else(|| obj.get("X-SAPI-USED-UID-WEIGHT-1M"))
.and_then(|v| v.as_str())
Expand All @@ -97,6 +99,18 @@ impl RateLimitInfo {
info.used_weight_1m = Some(weight);
}

// Extract X-MBX-USED-WEIGHT-1S (1-second weight window)
if let Some(weight) = obj
.get("x-mbx-used-weight-1s")
.or_else(|| obj.get("X-MBX-USED-WEIGHT-1S"))
.or_else(|| obj.get("x-mbx-used-weight"))
.or_else(|| obj.get("X-MBX-USED-WEIGHT"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok())
{
info.used_weight_1s = Some(weight);
}

// Extract X-MBX-ORDER-COUNT-10S
if let Some(count) = obj
.get("x-mbx-order-count-10s")
Expand Down Expand Up @@ -148,12 +162,16 @@ impl RateLimitInfo {
pub struct WeightRateLimiter {
/// Current used weight (1-minute window).
used_weight_1m: AtomicU64,
/// Current used weight (1-second window).
used_weight_1s: AtomicU64,
/// Current order count (10-second window).
order_count_10s: AtomicU64,
/// Current order count (daily).
order_count_1d: AtomicU64,
/// Weight limit per minute.
weight_limit_1m: AtomicU64,
/// Weight limit per second.
weight_limit_1s: AtomicU64,
/// Order limit per 10 seconds.
order_limit_10s: AtomicU64,
/// Order limit per day.
Expand All @@ -177,9 +195,11 @@ impl WeightRateLimiter {
pub fn new() -> Self {
Self {
used_weight_1m: AtomicU64::new(0),
used_weight_1s: AtomicU64::new(0),
order_count_10s: AtomicU64::new(0),
order_count_1d: AtomicU64::new(0),
weight_limit_1m: AtomicU64::new(DEFAULT_WEIGHT_LIMIT_1M),
weight_limit_1s: AtomicU64::new(DEFAULT_WEIGHT_LIMIT_1S),
order_limit_10s: AtomicU64::new(DEFAULT_ORDER_LIMIT_10S),
order_limit_1d: AtomicU64::new(DEFAULT_ORDER_LIMIT_1D),
retry_after_until: RwLock::new(None),
Expand All @@ -198,9 +218,11 @@ impl WeightRateLimiter {
pub fn with_limits(weight_limit_1m: u64, order_limit_10s: u64, order_limit_1d: u64) -> Self {
Self {
used_weight_1m: AtomicU64::new(0),
used_weight_1s: AtomicU64::new(0),
order_count_10s: AtomicU64::new(0),
order_count_1d: AtomicU64::new(0),
weight_limit_1m: AtomicU64::new(weight_limit_1m),
weight_limit_1s: AtomicU64::new(DEFAULT_WEIGHT_LIMIT_1S),
order_limit_10s: AtomicU64::new(order_limit_10s),
order_limit_1d: AtomicU64::new(order_limit_1d),
retry_after_until: RwLock::new(None),
Expand All @@ -219,6 +241,10 @@ impl WeightRateLimiter {
self.used_weight_1m.store(weight, Ordering::SeqCst);
}

if let Some(weight) = info.used_weight_1s {
self.used_weight_1s.store(weight, Ordering::SeqCst);
}

if let Some(count) = info.order_count_10s {
self.order_count_10s.store(count, Ordering::SeqCst);
}
Expand Down Expand Up @@ -298,6 +324,14 @@ impl WeightRateLimiter {
return true;
}

// Check 1s weight threshold (80%)
let weight_1s = self.used_weight_1s.load(Ordering::SeqCst);
let weight_limit_1s = self.weight_limit_1s.load(Ordering::SeqCst);
#[allow(clippy::cast_precision_loss)]
if (weight_1s as f64) >= (weight_limit_1s as f64) * THROTTLE_THRESHOLD {
return true;
}

// Check order count threshold (80%)
let order_count = self.order_count_10s.load(Ordering::SeqCst);
let order_limit = self.order_limit_10s.load(Ordering::SeqCst);
Expand Down Expand Up @@ -380,6 +414,7 @@ impl WeightRateLimiter {
/// This should be called when the rate limit window resets.
pub fn reset(&self) {
self.used_weight_1m.store(0, Ordering::SeqCst);
self.used_weight_1s.store(0, Ordering::SeqCst);
self.order_count_10s.store(0, Ordering::SeqCst);
self.order_count_1d.store(0, Ordering::SeqCst);
if let Ok(mut guard) = self.retry_after_until.write() {
Expand Down
Loading