org.sputnik.ratelimit.service.RateLimiter Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of java-rate-limit Show documentation
Show all versions of java-rate-limit Show documentation
Java library to help to implement rate limits.
The newest version!
package org.sputnik.ratelimit.service;
import java.io.Closeable;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.LoggerFactory;
import org.sputnik.ratelimit.dao.EventsRedisRepository;
import org.sputnik.ratelimit.domain.CanDoResponse;
import org.sputnik.ratelimit.domain.CanDoResponse.CanDoResponseBuilder;
import org.sputnik.ratelimit.domain.CanDoResponse.Reason;
import org.sputnik.ratelimit.exeception.DuplicatedEventKeyException;
import org.sputnik.ratelimit.util.EventConfig;
import org.sputnik.ratelimit.util.Hasher;
import redis.clients.jedis.JedisPool;
public class RateLimiter implements Closeable {
private static final org.slf4j.Logger logger = LoggerFactory.getLogger(RateLimiter.class);
private final EventsRedisRepository eventsRedisRepository;
private final String hashingSecret;
private final Map eventsConfig = new HashMap<>();
private final JedisPool jedisPool;
/**
* Constructor.
*
* @param jedisConf Jedis configuration.
* @param hashingSecret secret for hashing values
* @param eventConfigs Events configuration.
*/
public RateLimiter(JedisConfiguration jedisConf, String hashingSecret, EventConfig... eventConfigs) {
this.hashingSecret = hashingSecret;
jedisPool = new JedisPool(jedisConf.getPoolConfig(), jedisConf.getHost(), jedisConf.getPort(),
jedisConf.getTimeout(), jedisConf.getPassword(), jedisConf.getDatabase(), jedisConf.getClientName());
eventsRedisRepository = new EventsRedisRepository(jedisPool);
validateEventsConfig(eventConfigs);
eventsConfig.putAll(Stream.of(eventConfigs).collect(Collectors.toMap(EventConfig::getEventId, Function.identity())));
}
/**
* Constructor.
*
* @param host Redis host.
* @param port Redis port.
* @param hashingSecret secret for hashing values
* @param eventConfigs Events configuration.
*/
public RateLimiter(String host, int port, String hashingSecret, EventConfig... eventConfigs) {
this(JedisConfiguration.builder().host(host).port(port).build(), hashingSecret, eventConfigs);
}
/**
* Checks if the event can be done without exceeding the configured limits.
*
* @param eventId Event identifier.
* @param key event execution key.
* @return Response object with information about if the event can be done, the reason, and wait time if it cannot be done because
* exceeding event limits.
*/
public CanDoResponse canDoEvent(String eventId, String key) {
CanDoResponseBuilder builder = CanDoResponse.builder();
if (isValidRequest(eventId, key)) {
String hashedKey = hashText(key);
logger.debug("Event ({}) exists, checking if it could be performed", eventId);
EventConfig eventConfig = eventsConfig.get(eventId);
Duration eventTime = eventConfig.getMinTime();
Long eventMaxIntents = eventConfig.getMaxIntents();
Long eventIntents = eventsRedisRepository.getListLength(eventId, hashedKey);
if (eventIntents != null && eventIntents >= eventMaxIntents) {
logger.debug("Checking dates");
builder.eventsIntents(eventIntents);
Instant firstDate = eventsRedisRepository.getListFirstEventElement(eventId, hashedKey, eventMaxIntents);
long millisDifference = ChronoUnit.MILLIS.between(firstDate, Instant.now());
if (millisDifference > eventTime.toMillis()) {
eventsRedisRepository.removeListFirstElement(eventId, hashedKey);
logger.info("Event [{}] could be performed [{}/{}]", eventId, eventIntents, eventMaxIntents);
builder.canDo(true);
} else {
builder.reason(Reason.TOO_MANY_EVENTS);
builder.waitMillis(eventTime.toMillis() - millisDifference);
builder.canDo(false);
}
} else {
logger.info("Event [{}] could be performed [{}/{}]", eventId, eventIntents, eventMaxIntents);
builder.eventsIntents(eventIntents != null ? eventIntents : 0L);
builder.canDo(true);
}
} else {
builder.reason(Reason.INVALID_REQUEST);
builder.canDo(false);
}
CanDoResponse response = builder.build();
if (!response.getCanDo()) {
logger.info("The event: {} could NOT be performed", eventId);
}
return response;
}
/**
* Indicates that the event has been done.
*
* @param eventId Event identifier.
* @param key event execution key.
* @return true
if the event execution has been recorded successfully, false
otherwise.
*/
public boolean doEvent(String eventId, String key) {
boolean eventRecorded = false;
if (isValidRequest(eventId, key)) {
EventConfig eventConfig = eventsConfig.get(eventId);
eventsRedisRepository.addEvent(eventId, hashText(key), eventConfig.getMinTime());
logger.info("Event [{}] recorded", eventId);
eventRecorded = true;
}
return eventRecorded;
}
/**
* Clear all the event execution for the provided key.
*
* @param eventId Event identifier.
* @param key event execution key.
* @return true
if the event execution has been cleared successfully, false
otherwise.
*/
public boolean reset(String eventId, String key) {
boolean eventDeleted = false;
if (isValidRequest(eventId, key)) {
eventsRedisRepository.remove(eventId, hashText(key));
logger.info("Event [{}] deleted", eventId);
eventDeleted = true;
}
return eventDeleted;
}
/**
* Get information about an event.
*
* @param eventId Event identifier.
* @return Event configuration.
*/
public Optional getEventConfig(String eventId) {
return Optional.ofNullable(eventsConfig.get(eventId));
}
/**
* Hash text using Hasher utility class.
*
* @param text Texto to be hashed.
* @return Hashed text.
* @see Hasher
*/
private String hashText(String text) {
String hash = text;
try {
logger.debug("Hashing key");
hash = Hasher.convertToHmacSHA256(text, hashingSecret);
} catch (Exception e) {
logger.warn("Error hashing text, using clear text: {}", e.getMessage());
}
return hash;
}
/**
* Validates the request. Checks if the key is not blank, and the eventId is configured.
*/
private boolean isValidRequest(String eventId, String key) {
boolean valid = false;
if (isNotBlank(key)) {
logger.debug("Checking the existence of event: {}", eventId);
if (eventsConfig.containsKey(eventId)) {
valid = true;
} else {
logger.error("Invalid request - The eventId [{}] is not found", eventId);
}
} else {
logger.error("Invalid request - The key is blank");
}
return valid;
}
/**
* Validates no key are duplicated in the events config supplied.
*
* @param eventConfigs List of event configs to validate.
* @throws DuplicatedEventKeyException when a key is duplicated.
*/
private void validateEventsConfig(EventConfig... eventConfigs) {
Set keys = new HashSet<>();
for (EventConfig cfg : eventConfigs) {
String eventId = cfg.getEventId();
if (keys.contains(eventId)) {
throw new DuplicatedEventKeyException(eventId);
}
keys.add(eventId);
}
}
/**
* Checks if a CharSequence is empty (""), null or whitespace only.
*
* Whitespace is defined by {@link Character#isWhitespace(char)}.
*
* Copied from Apache Commons StringUtils
*
*
* isBlank(null) = true
* isBlank("") = true
* isBlank(" ") = true
* isBlank("bob") = false
* isBlank(" bob ") = false
*
*
* @param cs the CharSequence to check, may be null
* @return {@code true} if the CharSequence is null, empty or whitespace only
*/
private boolean isBlank(CharSequence cs) {
int strLen;
if (cs == null || (strLen = cs.length()) == 0) {
return true;
}
for (int i = 0; i < strLen; i++) {
if (!Character.isWhitespace(cs.charAt(i))) {
return false;
}
}
return true;
}
/**
* Checks if a CharSequence is not empty (""), not null and not whitespace only.
*
* Whitespace is defined by {@link Character#isWhitespace(char)}.
*
* Copied from Apache Commons StringUtils
*
*
* isNotBlank(null) = false
* isNotBlank("") = false
* isNotBlank(" ") = false
* isNotBlank("bob") = true
* isNotBlank(" bob ") = true
*
*
* @param cs the CharSequence to check, may be null
* @return {@code true} if the CharSequence is not empty and not null and not whitespace only
*/
private boolean isNotBlank(CharSequence cs) {
return !isBlank(cs);
}
@Override
public void close() {
jedisPool.close();
}
}