Rate limiting is a crucial technique for protecting APIs and services from abuse, ensuring fair usage, and maintaining system stability. Combining Open Policy Agent (OPA) with Java applications provides a flexible, policy-based approach to rate limiting that separates enforcement from policy logic.
Why Use OPA for Rate Limiting?
OPA (Open Policy Agent) is a general-purpose policy engine that enables unified, context-aware policy enforcement across your stack. For rate limiting, OPA offers:
- Decoupled Policy Management: Rate limiting rules are defined in Rego (OPA's policy language) separately from application code
- Flexible Rule Definitions: Create complex rate limiting rules based on user ID, IP address, API endpoints, or any other context
- Centralized Policy Control: Manage rate limiting policies across multiple services from a single location
- Dynamic Policy Updates: Change rate limiting rules without redeploying your Java application
Architecture Overview
The typical architecture for OPA-based rate limiting in Java involves:
- Java Application: Makes authorization decisions by querying OPA
- OPA Server: Evaluates policies and returns decisions
- Data Storage: Redis or in-memory storage for tracking rate limit counters
- Rego Policies: Define rate limiting rules and logic
Setting Up Dependencies
First, add the necessary dependencies to your pom.xml:
<dependencies> <!-- HTTP client for OPA communication --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <!-- JSON processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.16.1</version> </dependency> <!-- Redis for distributed rate limiting --> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>5.1.0</version> </dependency> </dependencies>
OPA Policy Definition (Rego)
Create a rate limiting policy in Rego. Save this as rate_limiting.rego:
package ratelimit
import future.keywords.in
# Default rate limit configuration
default_limit = 100
default_window_seconds = 60
# User-specific limits
user_limits := {
"premium": {"limit": 1000, "window": 60},
"standard": {"limit": 100, "window": 60},
"trial": {"limit": 10, "window": 60}
}
# Check if request is within rate limit
allowed := {
"allowed": false,
"reason": "rate limit exceeded",
"limit": limit,
"remaining": 0,
"reset_time": reset_time
} if {
# Get current usage
current_usage := opa.redis.get_count(input.key)
# Check if limit exceeded
current_usage >= limit
reset_time := time.add_date(time.now(), 0, 0, 0, 0, window_seconds)
}
allowed := {
"allowed": true,
"limit": limit,
"remaining": limit - current_usage,
"reset_time": reset_time
} if {
# Get current usage
current_usage := opa.redis.get_count(input.key)
# Check if within limit
current_usage < limit
reset_time := time.add_date(time.now(), 0, 0, 0, 0, window_seconds)
}
# Determine the limit based on user tier
limit := user_limits[input.user_tier].limit if input.user_tier in user_limits
limit := default_limit if not input.user_tier in user_limits
# Determine the window based on user tier
window_seconds := user_limits[input.user_tier].window if input.user_tier in user_limits
window_seconds := default_window_seconds if not input.user_tier in user_limits
# Generate rate limit key
key := sprintf("ratelimit:%s:%s", [input.user_id, input.endpoint])
Java Client Implementation
Here's a comprehensive Java client for integrating with OPA for rate limiting:
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.io.IOException;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
public class OpaRateLimitClient {
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
private final String opaUrl;
private final JedisPool jedisPool;
public OpaRateLimitClient(String opaUrl, String redisUrl) {
this.httpClient = new OkHttpClient();
this.objectMapper = new ObjectMapper();
this.opaUrl = opaUrl;
this.jedisPool = new JedisPool(redisUrl);
}
public static class RateLimitRequest {
public String user_id;
public String user_tier;
public String endpoint;
public String ip_address;
public RateLimitRequest(String userId, String userTier, String endpoint, String ipAddress) {
this.user_id = userId;
this.user_tier = userTier;
this.endpoint = endpoint;
this.ip_address = ipAddress;
}
}
public static class RateLimitResponse {
public boolean allowed;
public String reason;
public int limit;
public int remaining;
public String reset_time;
public boolean isAllowed() {
return allowed;
}
}
public RateLimitResponse checkRateLimit(RateLimitRequest request) throws IOException {
// Prepare input for OPA
Map<String, Object> input = new HashMap<>();
input.put("user_id", request.user_id);
input.put("user_tier", request.user_tier);
input.put("endpoint", request.endpoint);
input.put("ip_address", request.ip_address);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("input", input);
String jsonRequest = objectMapper.writeValueAsString(requestBody);
Request httpRequest = new Request.Builder()
.url(opaUrl + "/v1/data/ratelimit/allowed")
.post(RequestBody.create(jsonRequest, MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(httpRequest).execute()) {
if (!response.isSuccessful()) {
throw new IOException("Unexpected code from OPA: " + response);
}
String responseBody = response.body().string();
JsonNode jsonResponse = objectMapper.readTree(responseBody);
JsonNode result = jsonResponse.get("result");
RateLimitResponse rateLimitResponse = new RateLimitResponse();
rateLimitResponse.allowed = result.get("allowed").asBoolean();
rateLimitResponse.reason = result.has("reason") ? result.get("reason").asText() : "";
rateLimitResponse.limit = result.get("limit").asInt();
rateLimitResponse.remaining = result.get("remaining").asInt();
rateLimitResponse.reset_time = result.get("reset_time").asText();
return rateLimitResponse;
}
}
// Increment counter in Redis
public void incrementCounter(String key, int windowSeconds) {
try (Jedis jedis = jedisPool.getResource()) {
String current = jedis.get(key);
if (current == null) {
jedis.setex(key, windowSeconds, "1");
} else {
jedis.incr(key);
}
}
}
// Get current usage from Redis
public int getCurrentUsage(String key) {
try (Jedis jedis = jedisPool.getResource()) {
String current = jedis.get(key);
return current == null ? 0 : Integer.parseInt(current);
}
}
}
Spring Boot Integration
Here's how to integrate OPA rate limiting into a Spring Boot application:
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final OpaRateLimitClient rateLimitClient;
public RateLimitInterceptor(OpaRateLimitClient rateLimitClient) {
this.rateLimitClient = rateLimitClient;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws IOException {
// Extract user information (in real app, get from JWT or session)
String userId = request.getHeader("X-User-ID");
String userTier = request.getHeader("X-User-Tier") != null ?
request.getHeader("X-User-Tier") : "standard";
String endpoint = request.getRequestURI();
String ipAddress = request.getRemoteAddr();
OpaRateLimitClient.RateLimitRequest rateLimitRequest =
new OpaRateLimitClient.RateLimitRequest(userId, userTier, endpoint, ipAddress);
OpaRateLimitClient.RateLimitResponse rateLimitResponse =
rateLimitClient.checkRateLimit(rateLimitRequest);
if (!rateLimitResponse.isAllowed()) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("X-RateLimit-Limit",
String.valueOf(rateLimitResponse.limit));
response.setHeader("X-RateLimit-Remaining",
String.valueOf(rateLimitResponse.remaining));
response.setHeader("X-RateLimit-Reset",
rateLimitResponse.reset_time);
response.getWriter().write("Rate limit exceeded");
return false;
}
// Add rate limit headers to successful responses
response.setHeader("X-RateLimit-Limit",
String.valueOf(rateLimitResponse.limit));
response.setHeader("X-RateLimit-Remaining",
String.valueOf(rateLimitResponse.remaining));
response.setHeader("X-RateLimit-Reset",
rateLimitResponse.reset_time);
return true;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/public/**");
}
@Bean
public OpaRateLimitClient opaRateLimitClient() {
String opaUrl = System.getenv().getOrDefault("OPA_URL", "http://localhost:8181");
String redisUrl = System.getenv().getOrDefault("REDIS_URL", "localhost");
return new OpaRateLimitClient(opaUrl, redisUrl);
}
}
Advanced Rate Limiting Scenarios
1. Sliding Window Rate Limiting
# Sliding window rate limiting
sliding_window_allowed := {
"allowed": true,
"remaining": limit - current_count
} if {
current_time := time.now_ns()
window_start := current_time - (window_seconds * 1000000000)
# Get count from current window
current_count := opa.redis.zcount(input.key, window_start, current_time)
current_count < limit
}
2. Burst Rate Limiting
# Burst rate limiting with different limits for short and long windows
burst_limits := {
"short_window": {"limit": 10, "window": 1}, # 10 requests per second
"long_window": {"limit": 100, "window": 60} # 100 requests per minute
}
Best Practices
- Use Distributed Storage: For microservices architectures, use Redis or similar distributed storage to share rate limit counters across instances.
- Graceful Degradation: Implement fallback mechanisms when OPA is unavailable.
- Monitor and Adjust: Continuously monitor rate limit hits and adjust limits based on usage patterns.
- Context-Aware Limits: Implement different limits for different user tiers, endpoints, or times of day.
- Cache Policy Decisions: Cache frequent policy decisions to reduce OPA load.
Conclusion
Implementing rate limiting with OPA in Java provides a powerful, flexible approach to API protection. By decoupling policy logic from application code, you gain the ability to dynamically adjust rate limiting rules without redeploying your services. The combination of OPA's expressive policy language and Java's robust ecosystem creates a scalable solution suitable for everything from simple APIs to complex microservices architectures.
The key benefits include centralized policy management, context-aware rate limiting, and the ability to implement sophisticated rate limiting strategies that adapt to your specific business requirements.