API Rate Limits: Token Bucket Throttling and Exponential Backoff

🌐 한국어

Your bot runs fine, then suddenly every request fails. You check the logs: 429 Too Many Requests. Exchange and brokerage APIs impose rate limits on calls per second and per minute, and block you for a while when you exceed them. Fail to manage this and your bot is tied up at exactly the moment that matters.

⚠️ Live automated trading can incur losses from software and network failures. This article is for educational and informational purposes.

1. What Rate Limits Look Like

Limits usually take these shapes: "N calls per second," "M calls per minute," "K concurrent connections." Korean brokerage REST APIs tend to be strict on calls per second, while exchanges often use a weight system where heavier calls consume more of your quota. One principle is common to all of them: you have to throttle yourself before the server throttles you.

2. Token Bucket — The Faucet on Your Call Rate

The most widely used throttle is the token bucket. Tokens refill into a bucket at a fixed rate, and each request spends one. No tokens left? You wait until it refills. The advantage is that it's smooth in steady state while still permitting a burst up to the bucket size.

// Using golang.org/x/time/rate, the quasi-standard Go package

import "golang.org/x/time/rate"

// Refill 8 per second, burst up to 8

limiter := rate.NewLimiter(rate.Limit(8), 8)

func callAPI(ctx context.Context) error {

    if err := limiter.Wait(ctx); err != nil { // block while no token is available

        return err

    }

    return doRequest() // fire the actual call once a token is secured

}

Rolling your own is simple in principle. Remember the last refill time and add tokens proportional to the elapsed time.

func (b *Bucket) allow() bool {

    now := time.Now()

    // Add elapsed_time × refill_per_second tokens (capped at capacity)

    b.tokens += now.Sub(b.last).Seconds() * b.refillPerSec

    if b.tokens > b.capacity {

        b.tokens = b.capacity

    }

    b.last = now

    if b.tokens >= 1 {

        b.tokens -= 1

        return true // allowed through

    }

    return false     // out of tokens → wait or defer

}

If multiple goroutines share it, allow() needs to be wrapped in a mutex. Production bots often split WebSocket (live quotes) from REST (orders and queries), giving the order path its own dedicated bucket.

3. Exponential Backoff on Retry

When you hit a limit or a transient network error, retrying immediately makes things worse—everyone retries at once and you get a thundering herd. So you retry while doubling the wait each time.

func withRetry(ctx context.Context, fn func() error) error {

    backoff := 500 * time.Millisecond

    for attempt := 0; attempt < 5; attempt++ {

        err := fn()

        if err == nil {

            return nil

        }

        if !isRetryable(err) { // no point retrying insufficient balance, etc.

            return err

        }

        jitter := time.Duration(rand.Int63n(int64(backoff / 2)))

        time.Sleep(backoff + jitter) // jitter spreads out the herd

        backoff *= 2                 // 0.5s → 1s → 2s → 4s ...

    }

    return errors.New("retries exhausted")

}

Jitter—a small random perturbation—matters. If many requests retry at exactly the same interval, they pile up simultaneously again and get blocked again. Adding a little random delay spreads them out.

4. Handling 429 Correctly

When a server returns 429, it often tells you via the Retry-After header how many seconds to wait before coming back. Don't ignore it and stubbornly follow your own backoff—the server's instruction takes precedence.

if resp.StatusCode == 429 {

    wait := backoff

    if ra := resp.Header.Get("Retry-After"); ra != "" {

        if sec, err := strconv.Atoi(ra); err == nil {

            wait = time.Duration(sec) * time.Second // server's instruction wins

        }

    }

    time.Sleep(wait)

    continue // retry

}

  • Errors worth retrying: 429, 5xx (transient server failure), timeouts and dropped connections.
  • Errors you must not retry: most 4xx (bad parameters, auth failure, insufficient balance). Resending the same request gives the same result and only raises the risk of duplicate orders.

Summary

  • APIs have call limits — throttle yourself first and you won't get blocked
  • Use a token bucket to stay smooth normally while still allowing bursts
  • Retry failures with exponential backoff plus jitter to avoid the thundering herd
  • Retry-After on a 429 outranks your own backoff — respect the server's instruction
  • Separate retryable errors from non-retryable ones — duplicate orders are the trap hidden inside retries

Handling rate limits is less about defense and more about manners. Call with respect for the server's resources and your bot won't be blocked at the decisive moment. On the order path especially, "once, reliably" always beats "fast and often."

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기