Forhopp

ForhoppPay Connect — Rate Limiting

Feature Reference: FR-3 | Service: ConnectRateLimiter, ConnectRateLimitFilter


1. Overview

ForhoppPay Connect enforces per-API-key rate limits using a Redis sliding window algorithm. Live keys get 100 requests/second; test keys get 25 requests/second. Rate limit state is tracked atomically via a Lua script executed inside Redis.


2. Rate Limit Tiers

Environment Default Limit Window Configurable
Live (sk_live_*) 100 req/sec 1 second Yes (application.yml)
Test (sk_test_*) 25 req/sec 1 second Yes (application.yml)

Custom per-key limits can be set via rate_limit_per_second on the API key entity, capped at the environment default.


3. Algorithm: Redis Sliding Window

3.1 Data Structure

Each API key gets a Redis sorted set:

Key:    connect:ratelimit:{apiKeyId}
Score:  timestamp in milliseconds
Member: {timestamp}:{random_suffix}
TTL:    window_size + 1 second

3.2 Lua Script (Atomic Operation)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local windowStart = now - window

-- 1. Remove expired entries
redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)

-- 2. Count current requests in window
local currentCount = redis.call('ZCARD', key)

-- 3. Check if limit exceeded
if currentCount >= limit then
    local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
    local resetTime = oldest[2] and (tonumber(oldest[2]) + window) or (now + window)
    return {0, limit - currentCount, resetTime}
end

-- 4. Add current request
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))

-- 5. Set expiry
redis.call('EXPIRE', key, window + 1)

-- 6. Return result
return {1, limit - currentCount - 1, now + window}

Returns: [allowed (0/1), remaining, resetTime]

3.3 Complexity

  • Add/remove: O(log N)
  • Count: O(log N)
  • Atomic via single Lua script execution
  • No race conditions between concurrent requests

4. Response Headers

Every API response includes rate limit headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1711814400000
Header Type Description
X-RateLimit-Limit integer Maximum requests per window
X-RateLimit-Remaining integer Remaining requests in current window
X-RateLimit-Reset long Unix timestamp (ms) when window resets

5. Rate Limit Exceeded Response

When the limit is exceeded, the API returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711814400000
Content-Type: application/json

{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limit_exceeded",
    "message": "Too many requests. Please retry after 1 second.",
    "doc_url": "https://docs.pay.forhopp.com/api/errors#rate_limit_exceeded"
  }
}

The Retry-After header value is in seconds.


6. Fail-Open Policy

If Redis is unavailable or returns an unexpected result, the rate limiter fails open — the request is allowed through. This prevents Redis outages from blocking all API traffic.

} catch (Exception e) {
    log.error("Redis error during rate limit check: {}", e.getMessage());
    return RateLimitResult.allowed(limit, limit, getResetTime());
}

7. Filter Implementation

ConnectRateLimitFilter runs after ApiKeyAuthenticationFilter:

1. Get MerchantContext from ThreadLocal
2. Extract API key
3. Call ConnectRateLimiter.isAllowed(apiKey)
4. Add rate limit headers to response
5. If denied: return 429 with Retry-After
6. If allowed: continue filter chain

8. Configuration

# application.yml
connect:
  rate-limit:
    enabled: true
    live-requests-per-second: 100
    test-requests-per-second: 25
    window-size-seconds: 1

Set enabled: false to disable rate limiting entirely (useful for load testing).


9. RateLimitResult Record

public record RateLimitResult(
    boolean allowed,
    int limit,
    int remaining,
    long resetTime
) {
    public long getRetryAfterSeconds() {
        return Math.max(1, (resetTime - Instant.now().toEpochMilli()) / 1000);
    }
}

10. Correctness Properties

# Property Validates
6 Requests exceeding limit return 429 with valid Retry-After FR-3.1, FR-3.2, FR-3.3

11. Monitoring

  • Log warning when rate limit is exceeded for any key
  • Redis connection errors logged at ERROR level
  • Metrics: connect.ratelimit.allowed, connect.ratelimit.denied (per environment)

Last Updated: March 2026

Was this page helpful?

Edit this page