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
