Current section
Files
Jump to
Current section
Files
lib/ccxt/http/rate_limit_headers.ex
defmodule CCXT.HTTP.RateLimitHeaders do
@moduledoc """
Parses rate limit status headers from exchange API responses.
Exchanges return rate limit information with every response, not just 429s.
This module detects the header pattern and extracts normalized rate limit data.
## Supported Patterns
Patterns are tried in order; the first match wins:
1. **Binance** — `x-mbx-used-weight-1m` or `x-sapi-used-ip-weight-1m`
2. **Bybit** — `x-bapi-limit`, `x-bapi-limit-status`, `x-bapi-limit-reset-timestamp`
3. **Standard** — `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset`
Exchanges without custom rate limit headers (OKX, Kraken) return `:none`.
"""
alias CCXT.HTTP.RateLimitInfo
@doc """
Parses rate limit headers from a response.
Headers are in Req format: `%{String.t() => [String.t()]}` with lowercase keys.
`spec_rate_limits` is the `rate_limits` map from the exchange spec, used to
derive `limit` when the exchange only reports `used` (e.g., Binance).
Returns `{:ok, %RateLimitInfo{}}` if rate limit headers are found, `:none` otherwise.
"""
@spec parse(atom(), %{String.t() => [String.t()]}, map() | nil) ::
{:ok, RateLimitInfo.t()} | :none
def parse(exchange, headers, spec_rate_limits \\ nil) when is_atom(exchange) and is_map(headers) do
with :none <- parse_binance(exchange, headers, spec_rate_limits),
:none <- parse_bybit(exchange, headers) do
parse_standard(exchange, headers)
end
end
# =============================================================================
# Binance Pattern
#
# Binance reports weight used in the current 1-minute window:
# - x-mbx-used-weight-1m: Main API (api.binance.com)
# - x-sapi-used-ip-weight-1m: SAPI endpoints (sapi.binance.com)
#
# Only `used` is reported; `limit` comes from spec.rate_limits.requests.
# =============================================================================
@binance_headers ["x-mbx-used-weight-1m", "x-sapi-used-ip-weight-1m"]
@doc false
defp parse_binance(exchange, headers, spec_rate_limits) do
case find_header(headers, @binance_headers) do
{header_name, used_str} ->
used = parse_int(used_str)
limit = get_spec_limit(spec_rate_limits)
remaining =
if is_integer(used) and is_integer(limit) do
max(limit - used, 0)
end
raw = collect_raw_headers(headers, @binance_headers)
{:ok,
%RateLimitInfo{
exchange: exchange,
limit: limit,
used: used,
remaining: remaining,
reset_at: nil,
source: :binance_weight,
raw_headers: Map.put(raw, "matched", header_name)
}}
nil ->
:none
end
end
# =============================================================================
# Bybit Pattern
#
# Bybit provides all three pieces:
# - x-bapi-limit: Maximum requests allowed
# - x-bapi-limit-status: Remaining requests
# - x-bapi-limit-reset-timestamp: Unix ms when window resets
# =============================================================================
@bybit_limit_header "x-bapi-limit"
@bybit_remaining_header "x-bapi-limit-status"
@bybit_reset_header "x-bapi-limit-reset-timestamp"
@bybit_headers [@bybit_limit_header, @bybit_remaining_header, @bybit_reset_header]
@doc false
defp parse_bybit(exchange, headers) do
case get_header(headers, @bybit_limit_header) do
nil ->
:none
limit_str ->
limit = parse_int(limit_str)
remaining = parse_int(get_header(headers, @bybit_remaining_header))
reset_at = parse_int(get_header(headers, @bybit_reset_header))
used =
if is_integer(limit) and is_integer(remaining) do
max(limit - remaining, 0)
end
{:ok,
%RateLimitInfo{
exchange: exchange,
limit: limit,
used: used,
remaining: remaining,
reset_at: reset_at,
source: :bybit_bapi,
raw_headers: collect_raw_headers(headers, @bybit_headers)
}}
end
end
# =============================================================================
# Standard Pattern (RFC-style)
#
# Common headers used by KuCoin and others:
# - x-ratelimit-limit: Maximum requests in window
# - x-ratelimit-remaining: Remaining requests
# - x-ratelimit-reset: Unix timestamp (seconds) when window resets
# =============================================================================
@standard_limit_header "x-ratelimit-limit"
@standard_remaining_header "x-ratelimit-remaining"
@standard_reset_header "x-ratelimit-reset"
@standard_headers [@standard_limit_header, @standard_remaining_header, @standard_reset_header]
@doc false
defp parse_standard(exchange, headers) do
case get_header(headers, @standard_limit_header) do
nil ->
:none
limit_str ->
limit = parse_int(limit_str)
remaining = parse_int(get_header(headers, @standard_remaining_header))
reset_seconds = parse_int(get_header(headers, @standard_reset_header))
# Convert seconds to ms for consistency
reset_at = if is_integer(reset_seconds), do: reset_seconds * 1000
used =
if is_integer(limit) and is_integer(remaining) do
max(limit - remaining, 0)
end
{:ok,
%RateLimitInfo{
exchange: exchange,
limit: limit,
used: used,
remaining: remaining,
reset_at: reset_at,
source: :standard,
raw_headers: collect_raw_headers(headers, @standard_headers)
}}
end
end
# =============================================================================
# Helpers
# =============================================================================
@doc false
# Gets first matching header value from a list of header names
defp find_header(headers, names) do
Enum.find_value(names, fn name ->
case get_header(headers, name) do
nil -> nil
value -> {name, value}
end
end)
end
@doc false
# Gets a single header value (Req headers are %{String.t() => [String.t()]})
defp get_header(headers, name) do
case Map.get(headers, name) do
[value | _] -> value
_ -> nil
end
end
@doc false
# Parses a string to integer, returns nil on failure
defp parse_int(nil), do: nil
defp parse_int(str) when is_binary(str) do
case Integer.parse(str) do
{n, _} -> n
:error -> nil
end
end
@doc false
# Extracts the requests-per-window limit from spec.rate_limits
defp get_spec_limit(nil), do: nil
defp get_spec_limit(%{requests: requests}) when is_integer(requests), do: requests
defp get_spec_limit(_), do: nil
@doc false
# Collects raw header values for the given header names
defp collect_raw_headers(headers, names) do
Enum.reduce(names, %{}, fn name, acc ->
case get_header(headers, name) do
nil -> acc
value -> Map.put(acc, name, value)
end
end)
end
end