Infrastructure·General Utilities·Beginner

Rate Limit Handling

Handle exchange API rate limits robustly using token bucket and sliding window algorithms, priority-based request queuing, exponential backoff with jitter on HTTP 429 responses, and intelligent request batching to maximize API throughput while avoiding temporary or permanent bans.

general-utilitiesinfrastructure

Rate Limit Handling


Introduction

Exchange APIs implement rate limits to safeguard infrastructure resources and ensure equitable service distribution among connected clients. Exceeding these limits typically leads to HTTP 429 Too Many Requests responses, temporary API restrictions, or IP-level bans.

Inadequate rate limit handling can disrupt automated trading systems, impede order execution, and activate exchange security protocols. Consequently, robust API clients require the implementation of request throttling, retry mechanisms, backoff strategies, and comprehensive monitoring systems.

Exchange Rate Limit Models

ModelDescriptionExample
Requests Per Second (RPS)Limits the number of requests permitted within a one-second interval.Bybit: 10 req/s
Requests Per Minute (RPM)Limits the total number of requests over a rolling 60-second window.Binance: 1200 req/min
Weight-Based LimitsAssigns varying costs to different API endpoints based on resource consumption.Binance weighted endpoints
IP-Based LimitsApplies rate limits uniformly across all API keys originating from the same IP address.Common exchange policy
API-Key LimitsEnforces independent rate limits per individual API key, often tiered by access level.VIP or institutional tiers

Consequences of Rate Limit Violation

SeverityTypical Exchange Response
MinorHTTP 429 response
ModerateTemporary API key suspension
SevereTemporary IP ban
Repeated ViolationsPermanent account restrictions

Recommended Rate Limit Management Strategy

  1. Continuously track request timestamps.
  2. Implement pre-emptive request prevention to avoid limit violations.
  3. Introduce request delays upon reaching threshold limits.
  4. Apply exponential backoff mechanisms following HTTP 429 responses.
  5. Adhere to the Retry-After response header when provided by the API.
  6. Maintain monitoring metrics for wait times and observed limit violations.

1. Dependency Installation

Objective

Installation of essential packages required for notebook execution.

[24]
# requests: HTTP client library for making web requests.
# pandas: Data manipulation and analysis library, used for tabular reporting.
!pip install requests pandas
Requirement already satisfied: requests in /usr/local/lib/python3.12/dist-packages (2.32.4)
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests) (3.4.7)
Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests) (3.18)
Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests) (2.5.0)
Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests) (2026.5.20)
Requirement already satisfied: numpy>=1.26.0 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.0.2)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.2)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)

2. Library Imports

Objective

Importation of standard and third-party libraries necessary for:

  • Timing and delay management
  • Sliding window tracking mechanisms
  • Structured logging
  • Retry logic implementation
  • Statistical reporting and data presentation
[25]
import warnings
warnings.filterwarnings("ignore")

# Standard library imports for core functionalities
import time
import random
import logging
import sys
import json
from collections import deque
from functools import wraps
from datetime import datetime, timezone

# Third-party library for data manipulation
import pandas as pd

# Configure application logger for consistent output
logging.basicConfig(
    level=logging.INFO, # Keep basicConfig for root logger, but set specific logger level explicitly
    format="%(asctime)s | %(levelname)-8s | %(message)s",
    datefmt="%H:%M:%S",
    stream=sys.stdout,
)

# Initialize a logger specific to rate limiting operations
logger = logging.getLogger("rate_limiter")
logger.setLevel(logging.DEBUG) # Explicitly set the level for the 'rate_limiter' logger

3. Token Bucket Rate Limiter

Strategy Overview

The token bucket algorithm regulates request throughput while accommodating short bursts of traffic.

Core Mechanism

  • A bucket maintains a supply of tokens.
  • Tokens are continuously replenished at a predefined rate.
  • Each API request consumes one or more tokens.
  • Requests are delayed if the available token count is insufficient.

Advantages

  • Facilitates controlled burst request handling.
  • Prevents sustained system overload.
  • Normalizes request distribution over time.
  • Well-suited for exchanges employing request-per-second limiting models.

Token Bucket State Initialization

[26]
def create_token_bucket(
    max_tokens: float,
    refill_rate: float,
) -> dict:
    """
    Initializes a new token bucket state.

    Parameters
    ----------
    max_tokens : float
        Maximum token capacity of the bucket.
    refill_rate : float
        Rate at which tokens are added to the bucket per second.

    Returns
    -------
    dict
        A dictionary representing the token bucket's current state, including:
        - 'max_tokens': The maximum capacity of the bucket.
        - 'refill_rate': The token replenishment rate.
        - 'tokens': The current number of tokens in the bucket (initially max_tokens).
        - 'last_refill': The monotonic timestamp of the last refill operation.
    """
    return {
        "max_tokens": max_tokens,
        "refill_rate": refill_rate,
        "tokens": max_tokens,  # Bucket starts full.
        "last_refill": time.monotonic(), # Record initial time.
    }

Token Acquisition Logic

[27]
def token_bucket_acquire(
    bucket: dict,
    tokens_needed: float = 1.0,
) -> float:
    """
    Acquires the specified number of tokens from the bucket. If insufficient tokens are available,
    execution blocks until enough tokens have accumulated.

    Parameters
    ----------
    bucket : dict
        The mutable state of the token bucket.
    tokens_needed : float, optional
        The number of tokens required for the current operation (default is 1.0).

    Returns
    -------
    float
        The duration (in seconds) the process waited to acquire tokens.
    """

    now = time.monotonic() # Current monotonic timestamp.

    # Calculate elapsed time since the last token refill.
    elapsed = now - bucket["last_refill"]

    # Determine the number of new tokens generated during the elapsed time.
    new_tokens = elapsed * bucket["refill_rate"]

    # Replenish the bucket, ensuring not to exceed its maximum capacity.
    bucket["tokens"] = min(
        bucket["max_tokens"],
        bucket["tokens"] + new_tokens,
    )

    # Update the timestamp of the last refill operation.
    bucket["last_refill"] = now

    # If sufficient tokens are available, consume them immediately and return no wait time.
    if bucket["tokens"] >= tokens_needed:
        bucket["tokens"] -= tokens_needed
        return 0.0

    # Calculate the token deficit and the required wait time to generate sufficient tokens.
    deficit = tokens_needed - bucket["tokens"]
    wait_time = deficit / bucket["refill_rate"]

    logger.debug(
        "Token bucket wait: %.3fs for %.2f tokens",
        wait_time,
        tokens_needed,
    )

    # Pause execution for the calculated wait time.
    time.sleep(wait_time)

    # After waiting, the bucket is effectively emptied for the requested tokens, as they are now available.
    bucket["tokens"] = 0.0
    # Update the refill timestamp to reflect the end of the wait period.
    bucket["last_refill"] = time.monotonic()

    return wait_time

Demonstration

[28]
logger.info("--- Token Bucket Demonstration ---")

# Initialize a token bucket with a capacity of 5 tokens and a refill rate of 5 tokens/second.
bucket = create_token_bucket(
    max_tokens=5,
    refill_rate=5,
)

# Simulate 8 requests to demonstrate token acquisition and waiting.
for i in range(8):
    # Attempt to acquire a token; execution will pause if insufficient tokens are available.
    wait = token_bucket_acquire(bucket)

    # Log the status of each request, including wait time and remaining tokens.
    logger.info(
        "Request %d | waited=%.3fs | remaining_tokens=%.2f",
        i + 1,
        wait,
        bucket["tokens"],
    )
INFO:rate_limiter:--- Token Bucket Demonstration ---
INFO:rate_limiter:Request 1 | waited=0.000s | remaining_tokens=4.00
INFO:rate_limiter:Request 2 | waited=0.000s | remaining_tokens=3.00
INFO:rate_limiter:Request 3 | waited=0.000s | remaining_tokens=2.01
INFO:rate_limiter:Request 4 | waited=0.000s | remaining_tokens=1.01
INFO:rate_limiter:Request 5 | waited=0.000s | remaining_tokens=0.01
DEBUG:rate_limiter:Token bucket wait: 0.196s for 1.00 tokens
INFO:rate_limiter:Request 6 | waited=0.196s | remaining_tokens=0.00
DEBUG:rate_limiter:Token bucket wait: 0.199s for 1.00 tokens
INFO:rate_limiter:Request 7 | waited=0.199s | remaining_tokens=0.00
DEBUG:rate_limiter:Token bucket wait: 0.199s for 1.00 tokens
INFO:rate_limiter:Request 8 | waited=0.199s | remaining_tokens=0.00

4. Sliding Window Rate Limiter

Strategy Overview

The sliding window algorithm tracks individual request timestamps to enforce rate limits.

Core Mechanism

  • Each request's timestamp is recorded.
  • Expired timestamps are continuously purged from the record.
  • Requests are deferred when the maximum allowed request count within the defined window is reached.

Advantages

  • Ensures strict enforcement of rate limits.
  • Mitigates burst exploitation near window boundaries.
  • Precisely aligns with rolling-window exchange limits.

Sliding Window State Initialization

[29]
def create_sliding_window(
    max_calls: int,
    window_secs: float,
) -> dict:
    """
    Initializes a new sliding window rate limiter state.

    Parameters
    ----------
    max_calls : int
        The maximum number of calls permitted within the sliding window.
    window_secs : float
        The duration of the sliding window in seconds.

    Returns
    -------
    dict
        A dictionary representing the sliding window's state, including:
        - 'max_calls': The maximum call count.
        - 'window_secs': The window duration.
        - 'call_times': A deque storing the monotonic timestamps of recent calls.
    """
    return {
        "max_calls": max_calls,
        "window_secs": window_secs,
        "call_times": deque(), # Stores timestamps of calls within the window.
    }

Sliding Window Enforcement

[30]
def sliding_window_acquire(window: dict) -> float:
    """
    Enforces sliding window rate limiting. If the call limit within the window is reached,
    execution is paused until a slot becomes available.

    Parameters
    ----------
    window : dict
        The mutable state of the sliding window rate limiter.

    Returns
    -------
    float
        The duration (in seconds) the process waited to make the call.
    """
    now = time.monotonic() # Current monotonic timestamp.

    # Remove timestamps of calls that have expired from the window.
    while (
        window["call_times"]
        and (now - window["call_times"][0]) >= window["window_secs"]
    ):
        window["call_times"].popleft() # Remove the oldest call.

    # If the number of calls in the window is below the maximum, record the current call and return no wait time.
    if len(window["call_times"]) < window["max_calls"]:
        window["call_times"].append(now) # Add current call timestamp.
        return 0.0

    # Calculate the wait time required until the oldest call in the window expires.
    oldest_call = window["call_times"][0]
    wait_time = window["window_secs"] - (now - oldest_call)

    logger.debug(
        "Sliding window wait: %.3fs",
        wait_time,
    )

    # Pause execution for the calculated wait time, ensuring it's not negative.
    time.sleep(max(0, wait_time))

    # Record the current call's timestamp after the wait period.
    window["call_times"].append(time.monotonic())

    return wait_time

Demonstration

[31]
logger.info("--- Sliding Window Demonstration ---")

# Initialize a sliding window with a maximum of 3 calls within a 2-second window.
sw = create_sliding_window(
    max_calls=3,
    window_secs=2.0,
)

# Simulate 7 requests to demonstrate sliding window enforcement.
for i in range(7):
    # Attempt to acquire a slot in the sliding window; execution will pause if the limit is exceeded.
    wait = sliding_window_acquire(sw)

    # Log the status of each request, including wait time and the number of calls currently in the window.
    logger.info(
        "Request %d | waited=%.3fs | calls_in_window=%d",
        i + 1,
        wait,
        len(sw["call_times"]),
    )
INFO:rate_limiter:--- Sliding Window Demonstration ---
INFO:rate_limiter:Request 1 | waited=0.000s | calls_in_window=1
INFO:rate_limiter:Request 2 | waited=0.000s | calls_in_window=2
INFO:rate_limiter:Request 3 | waited=0.000s | calls_in_window=3
DEBUG:rate_limiter:Sliding window wait: 1.995s
INFO:rate_limiter:Request 4 | waited=1.995s | calls_in_window=4
INFO:rate_limiter:Request 5 | waited=0.000s | calls_in_window=3
DEBUG:rate_limiter:Sliding window wait: 0.000s
INFO:rate_limiter:Request 6 | waited=0.000s | calls_in_window=4
DEBUG:rate_limiter:Sliding window wait: 1.993s
INFO:rate_limiter:Request 7 | waited=1.993s | calls_in_window=4

5. Exponential Backoff with Jitter

Strategy Overview

Exponential backoff mitigates repeated request collisions following server-side throttling events.

Retry Delay Calculation

Without jitter, the delay is deterministic:

Delay = base_delay × backoff_factor^attempt

With jitter, the delay introduces randomness to prevent synchronized retries:

Delay = random(0, calculated_delay)

Advantages

  • Prevents synchronized retry attempts from multiple clients.
  • Reduces spikes in infrastructure load.
  • Enhances system recovery stability.
  • Recommended for all HTTP 429 response handling.

Backoff Implementation

[32]
def exponential_backoff_sleep(
    attempt: int,
    base_delay: float = 1.0,
    max_delay: float = 60.0,
    backoff_factor: float = 2.0,
    jitter: bool = True,
) -> float:
    """
    Applies an exponential backoff delay, optionally with jitter, before a retry attempt.

    Parameters
    ----------
    attempt : int
        The current retry attempt number (0-indexed).
    base_delay : float, optional
        The initial delay in seconds for the first retry (default is 1.0).
    max_delay : float, optional
        The maximum allowed delay in seconds (default is 60.0).
    backoff_factor : float, optional
        The multiplier for exponential growth of the delay (default is 2.0).
    jitter : bool, optional
        If True, a random delay between 0 and the calculated delay is used
        to prevent synchronized retries (default is True).

    Returns
    -------
    float
        The actual duration (in seconds) the process slept.
    """
    # Calculate the exponential delay, capping it at max_delay.
    delay = min(
        base_delay * (backoff_factor ** attempt),
        max_delay,
    )

    # Apply jitter if enabled, randomizing the delay within the calculated range.
    if jitter:
        delay = random.uniform(0, delay)

    logger.warning(
        "Backoff delay: %.2fs",
        delay,
    )

    time.sleep(delay) # Pause execution for the calculated delay.

    return delay

Delay Schedule Demonstration

[33]
logger.info("--- Exponential Backoff Schedule ---")

schedule = []

# Generate a schedule of exponential backoff delays for 8 retry attempts.
for attempt in range(8):
    # Calculate delay with a base of 1.0s and a factor of 2.0, capped at 60.0s.
    delay = min(1.0 * (2.0 ** attempt), 60.0)

    schedule.append({
        "attempt": attempt + 1,
        "delay_seconds": delay,
    })

# Display the generated backoff schedule.
pd.DataFrame(schedule)
INFO:rate_limiter:--- Exponential Backoff Schedule ---
attempt delay_seconds
0 1 1.0
1 2 2.0
2 3 4.0
3 4 8.0
4 5 16.0
5 6 32.0
6 7 60.0
7 8 60.0

6. Retry Wrapper

Strategy Overview

The retry wrapper centralizes and manages retry behavior for API requests.

Features

  • Automatic execution of retry attempts.
  • Specific handling for HTTP 429 Too Many Requests responses.
  • Integration of exponential backoff.
  • Adherence to Retry-After response headers.
  • Retry logic for transient server errors.
  • Robust exception recovery.

Retry Wrapper Implementation

[34]
def call_with_retry(
    func,
    *args,
    max_retries: int = 5,
    base_delay: float = 1.0,
    max_delay: float = 60.0,
    backoff_factor: float = 2.0,
    retry_on: tuple = (429, 500, 502, 503, 504), # HTTP status codes to trigger a retry.
    **kwargs,
):
    """
    Executes a given function with built-in retry handling, supporting exponential backoff
    and specific handling for HTTP 429 responses.

    Parameters
    ----------
    func : callable
        The function to be executed and retried.
    *args
        Positional arguments passed to `func`.
    max_retries : int, optional
        The maximum number of retry attempts (default is 5).
    base_delay : float, optional
        The base delay in seconds for exponential backoff (default is 1.0).
    max_delay : float, optional
        The maximum delay in seconds for exponential backoff (default is 60.0).
    backoff_factor : float, optional
        The multiplier for exponential backoff (default is 2.0).
    retry_on : tuple, optional
        A tuple of HTTP status codes that should trigger a retry (default is (429, 500, 502, 503, 504)).
    **kwargs
        Keyword arguments passed to `func`.

    Returns
    -------
    Any or None
        The result of the `func` call if successful, or None if all retries fail
        and no exception is raised.

    Raises
    ------
    Exception
        Re-raises the last encountered exception if all retries are exhausted and
        an exception was caught.
    """
    last_exception = None # Stores the last exception encountered.

    for attempt in range(max_retries + 1): # Iterate for initial call + max_retries.
        try:
            result = func(*args, **kwargs)

            # Check if the result object has an HTTP status code attribute.
            if hasattr(result, "status_code"):
                if result.status_code == 200:
                    return result # Success: return result immediately.

                elif result.status_code == 429: # Handle Too Many Requests specifically.
                    retry_after = result.headers.get("Retry-After") # Check for Retry-After header.

                    if retry_after: # If header is present, use its value for delay.
                        wait = float(retry_after)
                        time.sleep(wait)
                    else: # Otherwise, use exponential backoff.
                        exponential_backoff_sleep(
                            attempt,
                            base_delay,
                            max_delay,
                            backoff_factor,
                            jitter=True,
                        )

                elif result.status_code in retry_on: # Handle other retryable errors.
                    if attempt < max_retries: # If retries remaining, apply backoff.
                        exponential_backoff_sleep(
                            attempt,
                            base_delay,
                            max_delay,
                            backoff_factor,
                            jitter=True,
                        )
                    else:
                        return result # No retries left, return the failed result.

                else:
                    return result # Non-retryable status code, return result.

            else:
                return result # Result without status_code, assume success or non-HTTP context.

        except Exception as exc:
            last_exception = exc # Capture the exception.

            logger.error(
                "Retry exception: %s",
                exc,
            )

            if attempt < max_retries: # If retries remaining, apply backoff.
                exponential_backoff_sleep(
                    attempt,
                    base_delay,
                    max_delay,
                    backoff_factor,
                    jitter=True,
                )

    if last_exception: # If an exception occurred and all retries exhausted, re-raise.
        raise last_exception

    return None # All retries exhausted without a successful result or re-raised exception.

Simulated API Failure Example

[35]
_fail_count = [0]

def simulated_api_call(fail_times: int = 3):
    """
    Simulate an API endpoint that returns HTTP 429 Too Many Requests for a specified number of initial calls,
    then returns HTTP 200 OK.
    """
    class FakeResponse:
        """A mock HTTP response object."""
        def __init__(self, status_code):
            self.status_code = status_code
            self.headers = {}

    _fail_count[0] += 1

    # Return 429 if the simulated failure count has not been reached.
    if _fail_count[0] <= fail_times:
        logger.info(f"Simulated API call returning 429 (attempt {_fail_count[0]})")
        return FakeResponse(429)

    # Return 200 after the failure count is exceeded.
    logger.info("Simulated API call returning 200 OK.")
    return FakeResponse(200)

# Execute the simulated API call with retry logic.
# The system will retry up to 5 times, with exponential backoff.
result = call_with_retry(
    simulated_api_call,
    fail_times=3,  # API will fail 3 times before succeeding.
    max_retries=5, # Allow up to 5 retries.
    base_delay=0.1,
    max_delay=2.0,
)

# Print the final status code of the API call after retries.
if result:
    logger.info(f"Final simulated API call status code: {result.status_code}")
else:
    logger.info("Simulated API call failed after all retries.")
INFO:rate_limiter:Simulated API call returning 429 (attempt 1)
WARNING:rate_limiter:Backoff delay: 0.09s
INFO:rate_limiter:Simulated API call returning 429 (attempt 2)
WARNING:rate_limiter:Backoff delay: 0.18s
INFO:rate_limiter:Simulated API call returning 429 (attempt 3)
WARNING:rate_limiter:Backoff delay: 0.35s
INFO:rate_limiter:Simulated API call returning 200 OK.
INFO:rate_limiter:Final simulated API call status code: 200

7. Rate Limit Decorator

Strategy Overview

The decorator pattern enables the transparent application of rate limiting to any function.

Benefits

  • Centralizes rate limit enforcement logic.
  • Promotes reusability across diverse API endpoints.
  • Contributes to cleaner and more modular application code.
  • Provides automatic request throttling capabilities.

Decorator Implementation

[36]
def rate_limited(
    max_calls: int,
    window_secs: float,
):
    """
    A decorator that applies sliding window rate limiting to a function.

    Parameters
    ----------
    max_calls : int
        The maximum number of calls allowed within the specified window.
    window_secs : float
        The duration of the sliding window in seconds.

    Returns
    -------
    callable
        A decorator function that can be applied to other functions.
    """

    def decorator(func): # The actual decorator that takes the function to be wrapped.
        # Initialize a sliding window state for this specific rate-limited function.
        window = create_sliding_window(
            max_calls,
            window_secs,
        )

        @wraps(func) # Preserves metadata of the original function (`func`).
        def wrapper(*args, **kwargs):
            # Acquire a slot in the sliding window before executing the original function.
            # This call will block if the rate limit is exceeded.
            sliding_window_acquire(window)
            return func(*args, **kwargs) # Execute the original function.

        # Attach the window state to the wrapper for potential introspection or resetting.
        wrapper._rate_limit_window = window

        return wrapper # Return the wrapped function.

    return decorator # Return the decorator itself.

Decorator Demonstration

[37]
@rate_limited(max_calls=3, window_secs=1.0)
def fetch_ticker(symbol: str) -> dict:
    """
    Simulates an API endpoint call to fetch ticker information.
    This function is rate-limited by the `@rate_limited` decorator.
    """
    logger.info(f"Fetching ticker for {symbol}")
    return {
        "symbol": symbol,
        "price": 42000 + random.randint(-100, 100),
    }

# List of cryptocurrency symbols to fetch.
symbols = [
    "BTCUSDT",
    "ETHUSDT",
    "SOLUSDT",
    "BNBUSDT",
    "XRPUSDT",
]

results = []

# Iterate through symbols and call the rate-limited function.
# The decorator will automatically manage delays to adhere to the rate limit.
for symbol in symbols:
    result = fetch_ticker(symbol)
    results.append(result)
    # A short sleep to allow the rate limiter's window to slide, demonstrating its effect.
    time.sleep(0.1)

# Display the results in a DataFrame.
pd.DataFrame(results)
INFO:rate_limiter:Fetching ticker for BTCUSDT
INFO:rate_limiter:Fetching ticker for ETHUSDT
INFO:rate_limiter:Fetching ticker for SOLUSDT
DEBUG:rate_limiter:Sliding window wait: 0.692s
INFO:rate_limiter:Fetching ticker for BNBUSDT
INFO:rate_limiter:Fetching ticker for XRPUSDT
symbol price
0 BTCUSDT 42090
1 ETHUSDT 41969
2 SOLUSDT 41944
3 BNBUSDT 41981
4 XRPUSDT 41918

8. Rate Limit Statistics Tracking

Strategy Overview

Monitoring systems provide crucial visibility into throttling behavior and retry efficiency.

Tracked Metrics

  • Total number of requests initiated.
  • Frequency of request waits due to rate limits.
  • Cumulative duration of all wait times.
  • Count of HTTP 429 (Too Many Requests) occurrences.
  • Historical record of request timings.

Tracker Initialization

[38]
def create_rate_limit_tracker() -> dict:
    """
    Initializes a new monitoring statistics tracker for rate limit performance.

    Returns
    -------
    dict
        A dictionary representing the tracker's state, including:
        - 'total_requests': Cumulative count of all requests.
        - 'total_waits': Count of requests that experienced a delay.
        - 'total_wait_time': Sum of all wait durations.
        - 'rate_limit_hits': Count of times a 429 status code was encountered.
        - 'request_times': A list of tuples containing (timestamp, wait_time) for each request.
    """
    return {
        "total_requests": 0,
        "total_waits": 0,
        "total_wait_time": 0.0,
        "rate_limit_hits": 0,
        "request_times": [],
    }

Request Tracking

[39]
def track_request(
    tracker: dict,
    wait_time: float,
    rate_limited: bool = False,
) -> None:
    """
    Records statistics for a single API request.

    Parameters
    ----------
    tracker : dict
        The mutable state of the rate limit tracker.
    wait_time : float
        The duration (in seconds) the request was delayed due to rate limiting.
    rate_limited : bool, optional
        True if the request explicitly encountered a rate limit (e.g., HTTP 429),
        False otherwise (default is False).
    """
    tracker["total_requests"] += 1 # Increment total request count.

    # Record the current monotonic timestamp and the associated wait time.
    tracker["request_times"].append(
        (time.monotonic(), wait_time)
    )

    if wait_time > 0: # If a wait occurred, update wait-related metrics.
        tracker["total_waits"] += 1
        tracker["total_wait_time"] += wait_time

    if rate_limited: # If the request was explicitly rate-limited (e.g., by 429).
        tracker["rate_limit_hits"] += 1

Summary Reporting

[40]
def summarize_tracker(tracker: dict) -> pd.DataFrame:
    """
    Generates a summary DataFrame of the collected rate limit tracking statistics.

    Parameters
    ----------
    tracker : dict
        The state of the rate limit tracker.

    Returns
    -------
    pd.DataFrame
        A DataFrame presenting key summary statistics, including total requests,
        requests with waits, percentage of waited requests, total wait time,
        and explicit rate limit hits.
    """
    n = tracker["total_requests"]
    waits = tracker["total_waits"]

    summary = {
        "total_requests": n,
        "requests_with_wait": waits,
        "pct_requests_waited": round((waits / n) * 100, 1) if n else 0, # Percentage of requests that experienced a wait.
        "total_wait_time_s": round(tracker["total_wait_time"], 4),
        "rate_limit_hits_429": tracker["rate_limit_hits"],
    }

    return pd.DataFrame([summary]).T.rename(columns={0: "value"})

9. Exchange-Specific Rate Limit Profiles

Strategy Overview

Different exchanges implement varied throttling models. Centralized profile definitions serve to:

  • Enable dynamic creation of rate limiters.
  • Facilitate exchange abstraction.
  • Simplify production configuration management.
  • Support endpoint-specific enforcement rules.

Exchange Profile Configuration

[41]
# Defines a dictionary containing rate limit configurations for various cryptocurrency exchanges.
EXCHANGE_RATE_LIMITS = {
    "bybit": {
        "description": "Bybit Unified Trading API",
        "public_endpoints": {
            "get_kline": {"max_calls": 10, "window_secs": 1},   # 10 calls per second
            "get_orderbook": {"max_calls": 10, "window_secs": 1}, # 10 calls per second
        },
    },

    "binance": {
        "description": "Binance Spot/Futures API",
        "weight_limit_per_minute": 1200, # Overall weight limit for the API key per minute
        "endpoint_weights": {
            "klines": {"weight": 1},     # Low weight endpoint
            "depth": {"weight": 2},      # Medium weight endpoint
            "account": {"weight": 10},   # High weight endpoint
        },
    },

    "okx": {
        "description": "OKX REST API",
        "public_endpoints": {
            "candles": {"max_calls": 20, "window_secs": 2},  # 20 calls per 2 seconds
        },
    },
}

Profile Lookup Utility

[42]
def get_rate_limit_profile(
    exchange: str,
    endpoint: str,
    tier: str = "public",
) -> dict:
    """
    Retrieves the rate limit configuration for a specified exchange endpoint and tier.

    Parameters
    ----------
    exchange : str
        The name of the exchange (e.g., 'bybit', 'binance').
    endpoint : str
        The specific API endpoint (e.g., 'get_kline', 'klines').
    tier : str, optional
        The access tier of the endpoint (e.g., 'public', 'private'), defaults to 'public'.

    Returns
    -------
    dict
        A dictionary containing the rate limit parameters for the endpoint,
        or an empty dictionary if the profile is not found.
    """
    profile = EXCHANGE_RATE_LIMITS.get(exchange.lower(), {}) # Retrieve exchange-specific profile.

    key = f"{tier}_endpoints" # Construct key for tier-specific endpoints.

    # Check for tier-specific endpoint configuration.
    if key in profile and endpoint in profile[key]:
        return profile[key][endpoint]

    # Check for weighted endpoint configuration if not found in tier-specific.
    elif (
        "endpoint_weights" in profile
        and endpoint in profile["endpoint_weights"]
    ):
        return profile["endpoint_weights"][endpoint]

    return {} # Return empty dict if no matching profile is found.

Dynamic Rate Limiter Construction

[43]
def build_rate_limiter_from_profile(
    exchange: str,
    endpoint: str,
    tier: str = "public",
):
    """
    Constructs a sliding window rate limiter instance based on predefined exchange profiles.

    Parameters
    ----------
    exchange : str
        The name of the exchange.
    endpoint : str
        The specific API endpoint.
    tier : str, optional
        The access tier of the endpoint (default is 'public').

    Returns
    -------
    dict or None
        A sliding window rate limiter dictionary if a profile is found, otherwise None.
    """
    profile = get_rate_limit_profile(
        exchange,
        endpoint,
        tier,
    )

    if not profile: # If no profile is found, no limiter can be built.
        return None

    # Create and return a sliding window limiter using parameters from the profile.
    return create_sliding_window(
        max_calls=profile["max_calls"],
        window_secs=profile["window_secs"],
    )

Demonstration

[44]
# Construct a sliding window rate limiter for Bybit's 'get_kline' public endpoint.
bybit_kline_limiter = build_rate_limiter_from_profile(
    "bybit",
    "get_kline",
    "public",
)

# Display the created rate limiter's configuration.
logger.info(f"Bybit KLine Limiter: {bybit_kline_limiter}")
INFO:rate_limiter:Bybit KLine Limiter: {'max_calls': 10, 'window_secs': 1, 'call_times': deque([])}

Production Considerations

Best Practices for Deployment

  • Monotonic Clocks: Utilize monotonic clocks for all timing calculations to ensure accuracy independent of system time adjustments.
  • Retry-After Header Adherence: Strictly observe exchange-specific Retry-After HTTP headers to manage delay periods effectively.
  • Endpoint Segregation: Implement distinct rate limiters for public and private API endpoints due to differing limits and criticality.
  • Distributed Limiting: Employ distributed rate limiting solutions for applications operating across multiple processes or instances.
  • Continuous Monitoring: Continuously monitor wait times and the frequency of HTTP 429 responses to identify and address rate limit issues proactively.
  • Configuration Management: Avoid embedding hardcoded exchange limits directly into production systems; externalize configurations for flexibility.
  • Limit Validation: Regularly validate implemented rate limits against official exchange documentation to ensure compliance.

Integrated Production Architecture

ComponentPurpose
Token BucketFacilitates burst handling capabilities.
Sliding WindowProvides strict enforcement for rolling-window rate limits.
Retry WrapperEnables recovery from transient API failures.
Exponential BackoffReduces collision probability during retry attempts.
Monitoring TrackerOffers operational visibility into rate limiting performance.
Exchange ProfilesCentralizes and streamlines configuration management for diverse exchanges.

Conclusion

This notebook has demonstrated the design and implementation of production-grade rate limit handling systems tailored for exchange APIs.

Implemented components include:

  • Token bucket rate limiting for burst control.
  • Sliding window enforcement for precise rolling-window limits.
  • Exponential backoff with jitter for robust retry strategies.
  • Retry wrappers to encapsulate error handling.
  • Decorator-based throttling for transparent rate limit application.
  • Monitoring and statistics tracking for operational visibility.
  • Exchange-specific configuration profiles for flexible management.

These techniques collectively establish a resilient API infrastructure essential for algorithmic trading systems, market data pipelines, and automated execution engines.