---
title: "Designing Rate Limiters for Payment Systems"
date: 2026-04-07
tags: ["distributed-systems", "payments", "go"]
description: "Why payment rate limits can't treat false rejects like a normal API, and how to design for authorization ingress."
---

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:

```mermaid
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.

$$
\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.

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

### Sliding Window Counter

$$
\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

```mermaid
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

```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.
