— notes
Designing Rate Limiters for Payment Systems
On this page
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.
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.
Sliding Window Counter
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/atomicwith 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.