— notes

Designing Rate Limiters for Payment Systems

5 min read#distributed-systems#payments#go
On this page
  1. The Architecture
  2. Choosing an Algorithm
  3. Token Bucket
  4. Sliding Window Log
  5. Sliding Window Counter
  6. The Distributed Coordination Problem
  7. Implementation in Go
  8. Key Takeaways

Rate limiting in payment systems is different from rate limiting in a typical web API. A false positive – rejecting a legitimate authorization – is a failed transaction. A customer’s card gets declined at checkout. That is not an acceptable failure mode.

The Architecture

A payment authorization pipeline typically looks like this:

graph LR
    A[Card Network] -->|ISO 8583| B[Parser API]
    B -->|gRPC| C[Distributor API]
    C -->|gRPC| D[Account Service]
    C -->|gRPC| E[Risk Engine]
    C -->|gRPC| F[Ledger]

The rate limiter sits between the Parser API and the Distributor API. It must make a decision in under 1ms.

Warning

Never rate-limit at the card network ingress point. Network protocols like ISO 8583 have strict timeout windows. A delayed response is worse than a fast decline — it triggers a reversal cascade.

Choosing an Algorithm

Token Bucket

The token bucket algorithm maintains a counter that refills at a fixed rate. Each request consumes one token.

tokens(t)=min(C, tokens(t0)+r(tt0))\text{tokens}(t) = \min\left(C,\ \text{tokens}(t_0) + r \cdot (t - t_0)\right)

Tip

Set C to handle your peak burst (Black Friday spike) and r to your sustained p99 throughput. For authorization systems, measure both per-issuer and per-BIN to avoid penalizing an entire bank for one merchant’s spike.

Sliding Window Log

Tracks the exact timestamp of every request in a time window. Precise, but memory-intensive.

rate=nW\text{rate} = \frac{n}{W}

Sliding Window Counter

count=prev×(1telapsedW)+curr\text{count} = \text{prev} \times \left(1 - \frac{t_{\text{elapsed}}}{W}\right) + \text{curr}

Token Bucket: O(1) memory, O(1) time, allows bursts. No per-client fairness without separate buckets. Best for global throughput protection.

Sliding Window: Precise rate tracking, smooth distribution. O(n) memory for log variant, interpolation error for counter variant. Best for per-client or per-issuer fairness.

The Distributed Coordination Problem

sequenceDiagram
    participant Client
    participant Region A
    participant Region B
    participant Redis
    Client->>Region A: Auth Request 1
    Client->>Region B: Auth Request 2
    Region A->>Redis: INCR counter
    Region B->>Redis: INCR counter
    Redis-->>Region A: count=1 (allow)
    Redis-->>Region B: count=2 (allow)

The fundamental tension: strong consistency (single Redis) adds a network hop to every authorization, while eventual consistency allows brief over-admission. For payment systems, brief over-admission is almost always preferable to adding latency.

Implementation in Go

type TokenBucket struct {
    mu       sync.Mutex
    tokens   float64
    capacity float64
    rate     float64
    lastTime time.Time
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()
    now := time.Now()
    elapsed := now.Sub(tb.lastTime).Seconds()
    tb.tokens = math.Min(tb.capacity, tb.tokens+tb.rate*elapsed)
    tb.lastTime = now
    if tb.tokens >= 1 {
        tb.tokens--
        return true
    }
    return false
}

This implementation uses a mutex for simplicity. In production, consider sync/atomic with CAS operations for lock-free performance, or a sharded bucket per goroutine with periodic merging.

Key Takeaways

Design decisions for payment rate limiters:

  • Token bucket for global protection, sliding window for per-client fairness
  • Local-first with async sync beats centralized coordination for latency
  • Set capacity from measured burst patterns, not theoretical maximums
  • Monitor rejection rate as a business metric, not just an infra metric

Web API Rate Limiting: Return 429 Too Many Requests. Client retries with backoff. False positives are annoying but recoverable.

Payment Rate Limiting: Return decline response code in ISO 8583. No retry – transaction is failed. False positives are declined cards at checkout.

The difference between rate limiting a REST API and rate limiting an authorization pipeline is the cost of a false positive. In payments, you are not protecting a server – you are deciding whether someone’s groceries get paid for.