diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfig.java index 9530eccb5..c28aa2b10 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfig.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfig.java @@ -5,14 +5,29 @@ package org.whispersystems.textsecuregcm.limits; -public record RateLimiterConfig(int bucketSize, double leakRatePerMinute) { - public RateLimiterConfig { - if (leakRatePerMinute <= 0) { - throw new IllegalArgumentException("leakRatePerMinute cannot be less than or equal to zero"); +import javax.validation.constraints.AssertTrue; +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalDouble; + +public record RateLimiterConfig(int bucketSize, OptionalDouble leakRatePerMinute, Optional permitRegenerationDuration) { + + public double leakRatePerMillis() { + if (leakRatePerMinute.isPresent()) { + return leakRatePerMinute.getAsDouble() / (60.0 * 1000.0); + } else { + return permitRegenerationDuration.map(duration -> 1.0 / duration.toMillis()) + .orElseThrow(() -> new AssertionError("Configuration must have leak rate per minute or permit regeneration duration")); } } - public double leakRatePerMillis() { - return leakRatePerMinute / (60.0 * 1000.0); + @AssertTrue + public boolean hasExactlyOneRegenerationRate() { + return leakRatePerMinute.isPresent() ^ permitRegenerationDuration().isPresent(); + } + + @AssertTrue + public boolean hasPositiveRegenerationRate() { + return leakRatePerMillis() > 0; } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index 3ad20b4d7..642fa4084 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.limits; import com.google.common.annotations.VisibleForTesting; import java.time.Clock; import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; @@ -16,65 +18,68 @@ import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; public class RateLimiters extends BaseRateLimiters { public enum For implements RateLimiterDescriptor { - BACKUP_AUTH_CHECK("backupAuthCheck", false, new RateLimiterConfig(100, 100 / (24.0 * 60.0))), + BACKUP_AUTH_CHECK("backupAuthCheck", false, new RateLimiterConfig(100, OptionalDouble.of(100 / (24.0 * 60.0)), Optional.empty())), - SMS_DESTINATION("smsDestination", false, new RateLimiterConfig(2, 2)), + SMS_DESTINATION("smsDestination", false, new RateLimiterConfig(2, OptionalDouble.of(2), Optional.empty())), - VOICE_DESTINATION("voxDestination", false, new RateLimiterConfig(2, 1.0 / 2.0)), + VOICE_DESTINATION("voxDestination", false, new RateLimiterConfig(2, OptionalDouble.of(1.0 / 2.0), Optional.empty())), - VOICE_DESTINATION_DAILY("voxDestinationDaily", false, new RateLimiterConfig(10, 10.0 / (24.0 * 60.0))), + VOICE_DESTINATION_DAILY("voxDestinationDaily", false, new RateLimiterConfig(10, OptionalDouble.of(10.0 / (24.0 * 60.0)), + Optional.empty())), - SMS_VOICE_IP("smsVoiceIp", false, new RateLimiterConfig(1000, 1000)), + SMS_VOICE_IP("smsVoiceIp", false, new RateLimiterConfig(1000, OptionalDouble.of(1000), Optional.empty())), - SMS_VOICE_PREFIX("smsVoicePrefix", false, new RateLimiterConfig(1000, 1000)), + SMS_VOICE_PREFIX("smsVoicePrefix", false, new RateLimiterConfig(1000, OptionalDouble.of(1000), Optional.empty())), - VERIFY("verify", false, new RateLimiterConfig(6, 2)), + VERIFY("verify", false, new RateLimiterConfig(6, OptionalDouble.of(2), Optional.empty())), - PIN("pin", false, new RateLimiterConfig(10, 1 / (24.0 * 60.0))), + PIN("pin", false, new RateLimiterConfig(10, OptionalDouble.of(1 / (24.0 * 60.0)), Optional.empty())), - ATTACHMENT("attachmentCreate", false, new RateLimiterConfig(50, 50)), + ATTACHMENT("attachmentCreate", false, new RateLimiterConfig(50, OptionalDouble.of(50), Optional.empty())), - PRE_KEYS("prekeys", false, new RateLimiterConfig(6, 1.0 / 10.0)), + PRE_KEYS("prekeys", false, new RateLimiterConfig(6, OptionalDouble.of(1.0 / 10.0), Optional.empty())), - MESSAGES("messages", false, new RateLimiterConfig(60, 60)), + MESSAGES("messages", false, new RateLimiterConfig(60, OptionalDouble.of(60), Optional.empty())), - ALLOCATE_DEVICE("allocateDevice", false, new RateLimiterConfig(2, 1.0 / 2.0)), + ALLOCATE_DEVICE("allocateDevice", false, new RateLimiterConfig(2, OptionalDouble.of(1.0 / 2.0), Optional.empty())), - VERIFY_DEVICE("verifyDevice", false, new RateLimiterConfig(6, 1.0 / 10.0)), + VERIFY_DEVICE("verifyDevice", false, new RateLimiterConfig(6, OptionalDouble.of(1.0 / 10.0), Optional.empty())), - TURN("turnAllocate", false, new RateLimiterConfig(60, 60)), + TURN("turnAllocate", false, new RateLimiterConfig(60, OptionalDouble.of(60), Optional.empty())), - PROFILE("profile", false, new RateLimiterConfig(4320, 3)), + PROFILE("profile", false, new RateLimiterConfig(4320, OptionalDouble.of(3), Optional.empty())), - STICKER_PACK("stickerPack", false, new RateLimiterConfig(50, 20 / (24.0 * 60.0))), + STICKER_PACK("stickerPack", false, new RateLimiterConfig(50, OptionalDouble.of(20 / (24.0 * 60.0)), Optional.empty())), - ART_PACK("artPack", false, new RateLimiterConfig(50, 20 / (24.0 * 60.0))), + ART_PACK("artPack", false, new RateLimiterConfig(50, OptionalDouble.of(20 / (24.0 * 60.0)), Optional.empty())), - USERNAME_LOOKUP("usernameLookup", false, new RateLimiterConfig(100, 100 / (24.0 * 60.0))), + USERNAME_LOOKUP("usernameLookup", false, new RateLimiterConfig(100, OptionalDouble.of(100 / (24.0 * 60.0)), Optional.empty())), - USERNAME_SET("usernameSet", false, new RateLimiterConfig(100, 100 / (24.0 * 60.0))), + USERNAME_SET("usernameSet", false, new RateLimiterConfig(100, OptionalDouble.of(100 / (24.0 * 60.0)), Optional.empty())), - USERNAME_RESERVE("usernameReserve", false, new RateLimiterConfig(100, 100 / (24.0 * 60.0))), + USERNAME_RESERVE("usernameReserve", false, new RateLimiterConfig(100, OptionalDouble.of(100 / (24.0 * 60.0)), Optional.empty())), - CHECK_ACCOUNT_EXISTENCE("checkAccountExistence", false, new RateLimiterConfig(1_000, 1_000 / 60.0)), + CHECK_ACCOUNT_EXISTENCE("checkAccountExistence", false, new RateLimiterConfig(1_000, OptionalDouble.of(1_000 / 60.0), Optional.empty())), - REGISTRATION("registration", false, new RateLimiterConfig(6, 2)), + REGISTRATION("registration", false, new RateLimiterConfig(6, OptionalDouble.of(2), Optional.empty())), - VERIFICATION_PUSH_CHALLENGE("verificationPushChallenge", false, new RateLimiterConfig(5, 2)), + VERIFICATION_PUSH_CHALLENGE("verificationPushChallenge", false, new RateLimiterConfig(5, OptionalDouble.of(2), Optional.empty())), - VERIFICATION_CAPTCHA("verificationCaptcha", false, new RateLimiterConfig(10, 2)), + VERIFICATION_CAPTCHA("verificationCaptcha", false, new RateLimiterConfig(10, OptionalDouble.of(2), Optional.empty())), - RATE_LIMIT_RESET("rateLimitReset", true, new RateLimiterConfig(2, 2.0 / (60 * 24))), + RATE_LIMIT_RESET("rateLimitReset", true, new RateLimiterConfig(2, OptionalDouble.of(2.0 / (60 * 24)), Optional.empty())), - RECAPTCHA_CHALLENGE_ATTEMPT("recaptchaChallengeAttempt", true, new RateLimiterConfig(10, 10.0 / (60 * 24))), + RECAPTCHA_CHALLENGE_ATTEMPT("recaptchaChallengeAttempt", true, new RateLimiterConfig(10, OptionalDouble.of(10.0 / (60 * 24)), + Optional.empty())), - RECAPTCHA_CHALLENGE_SUCCESS("recaptchaChallengeSuccess", true, new RateLimiterConfig(2, 2.0 / (60 * 24))), + RECAPTCHA_CHALLENGE_SUCCESS("recaptchaChallengeSuccess", true, new RateLimiterConfig(2, OptionalDouble.of(2.0 / (60 * 24)), + Optional.empty())), - PUSH_CHALLENGE_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, 10.0 / (60 * 24))), + PUSH_CHALLENGE_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, OptionalDouble.of(10.0 / (60 * 24)), Optional.empty())), - PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, 2.0 / (60 * 24))), + PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, OptionalDouble.of(2.0 / (60 * 24)), Optional.empty())), - CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, 100.0 / (60 * 24))); + CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, OptionalDouble.of(100.0 / (60 * 24)), Optional.empty())); ; private final String id; diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java index de6916a22..ef0a63e6f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java @@ -13,10 +13,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.vdurmont.semver4j.Semver; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalDouble; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -297,19 +299,39 @@ class DynamicConfigurationTest { @Test void testParseLimits() throws JsonProcessingException { - final String limitsConfig = REQUIRED_CONFIG.concat(""" + { + final String limitsConfig = REQUIRED_CONFIG.concat(""" limits: rateLimitReset: bucketSize: 17 leakRatePerMinute: 44 """); - final RateLimiterConfig resetRateLimiterConfig = - DynamicConfigurationManager.parseConfiguration(limitsConfig, DynamicConfiguration.class).orElseThrow() - .getLimits().get(RateLimiters.For.RATE_LIMIT_RESET.id()); + final RateLimiterConfig resetRateLimiterConfig = + DynamicConfigurationManager.parseConfiguration(limitsConfig, DynamicConfiguration.class).orElseThrow() + .getLimits().get(RateLimiters.For.RATE_LIMIT_RESET.id()); - assertThat(resetRateLimiterConfig.bucketSize()).isEqualTo(17); - assertThat(resetRateLimiterConfig.leakRatePerMinute()).isEqualTo(44); + assertThat(resetRateLimiterConfig.bucketSize()).isEqualTo(17); + assertThat(resetRateLimiterConfig.leakRatePerMinute()).isEqualTo(OptionalDouble.of(44)); + assertThat(resetRateLimiterConfig.permitRegenerationDuration()).isEmpty(); + } + + { + final String limitsConfig = REQUIRED_CONFIG.concat(""" + limits: + rateLimitReset: + bucketSize: 17 + permitRegenerationDuration: PT4S + """); + + final RateLimiterConfig resetRateLimiterConfig = + DynamicConfigurationManager.parseConfiguration(limitsConfig, DynamicConfiguration.class).orElseThrow() + .getLimits().get(RateLimiters.For.RATE_LIMIT_RESET.id()); + + assertThat(resetRateLimiterConfig.bucketSize()).isEqualTo(17); + assertThat(resetRateLimiterConfig.leakRatePerMinute()).isEmpty(); + assertThat(resetRateLimiterConfig.permitRegenerationDuration()).isEqualTo(Optional.of(Duration.ofSeconds(4))); + } } @Test diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfigTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfigTest.java new file mode 100644 index 000000000..74a1c0b29 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfigTest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.limits; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalDouble; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimiterConfigTest { + + @Test + void leakRatePerMillis() { + assertEquals(0.001, new RateLimiterConfig(1, OptionalDouble.of(60), Optional.empty()).leakRatePerMillis()); + assertEquals(0.001, new RateLimiterConfig(1, OptionalDouble.empty(), Optional.of(Duration.ofSeconds(1))).leakRatePerMillis()); + } + + @Test + void hasExactlyOneRegenerationRate() { + assertTrue(new RateLimiterConfig(1, OptionalDouble.of(1), Optional.empty()).hasExactlyOneRegenerationRate()); + assertTrue(new RateLimiterConfig(1, OptionalDouble.empty(), Optional.of(Duration.ofSeconds(1))).hasExactlyOneRegenerationRate()); + assertFalse(new RateLimiterConfig(1, OptionalDouble.of(1), Optional.of(Duration.ofSeconds(1))).hasExactlyOneRegenerationRate()); + assertFalse(new RateLimiterConfig(1, OptionalDouble.empty(), Optional.empty()).hasExactlyOneRegenerationRate()); + } + + @Test + void isRegenerationRatePositive() { + assertTrue(new RateLimiterConfig(1, OptionalDouble.of(1), Optional.empty()).hasPositiveRegenerationRate()); + assertTrue(new RateLimiterConfig(1, OptionalDouble.empty(), Optional.of(Duration.ofSeconds(1))).hasPositiveRegenerationRate()); + assertFalse(new RateLimiterConfig(1, OptionalDouble.of(-1), Optional.empty()).hasPositiveRegenerationRate()); + assertFalse(new RateLimiterConfig(1, OptionalDouble.empty(), Optional.of(Duration.ofSeconds(-1))).hasPositiveRegenerationRate()); + assertFalse(new RateLimiterConfig(1, OptionalDouble.of(1 / 10), Optional.empty()).hasPositiveRegenerationRate()); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersLuaScriptTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersLuaScriptTest.java index 33601538c..3a09408b2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersLuaScriptTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersLuaScriptTest.java @@ -19,6 +19,7 @@ import java.time.Clock; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalDouble; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; @@ -56,7 +57,7 @@ public class RateLimitersLuaScriptTest { final RateLimiters.For descriptor = RateLimiters.For.REGISTRATION; final FaultTolerantRedisCluster redisCluster = REDIS_CLUSTER_EXTENSION.getRedisCluster(); final RateLimiters limiters = new RateLimiters( - Map.of(descriptor.id(), new RateLimiterConfig(60, 60)), + Map.of(descriptor.id(), new RateLimiterConfig(60, OptionalDouble.of(60), Optional.empty())), dynamicConfig, RateLimiters.defaultScript(redisCluster), redisCluster, @@ -73,7 +74,7 @@ public class RateLimitersLuaScriptTest { final RateLimiters.For descriptor = RateLimiters.For.REGISTRATION; final FaultTolerantRedisCluster redisCluster = REDIS_CLUSTER_EXTENSION.getRedisCluster(); final RateLimiters limiters = new RateLimiters( - Map.of(descriptor.id(), new RateLimiterConfig(60, 60)), + Map.of(descriptor.id(), new RateLimiterConfig(60, OptionalDouble.of(60), Optional.empty())), dynamicConfig, RateLimiters.defaultScript(redisCluster), redisCluster, @@ -117,7 +118,7 @@ public class RateLimitersLuaScriptTest { final RateLimiters.For descriptor = RateLimiters.For.REGISTRATION; final FaultTolerantRedisCluster redisCluster = REDIS_CLUSTER_EXTENSION.getRedisCluster(); final RateLimiters limiters = new RateLimiters( - Map.of(descriptor.id(), new RateLimiterConfig(1000, 60)), + Map.of(descriptor.id(), new RateLimiterConfig(1000, OptionalDouble.of(60), Optional.empty())), dynamicConfig, RateLimiters.defaultScript(redisCluster), redisCluster, @@ -168,7 +169,7 @@ public class RateLimitersLuaScriptTest { final RateLimiters.For descriptor = RateLimiters.For.REGISTRATION; final FaultTolerantRedisCluster redisCluster = mock(FaultTolerantRedisCluster.class); final RateLimiters limiters = new RateLimiters( - Map.of(descriptor.id(), new RateLimiterConfig(1000, 60)), + Map.of(descriptor.id(), new RateLimiterConfig(1000, OptionalDouble.of(60), Optional.empty())), dynamicConfig, RateLimiters.defaultScript(redisCluster), redisCluster, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersTest.java index a07db7781..571a55a6c 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersTest.java @@ -15,6 +15,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; import javax.validation.Valid; import javax.validation.constraints.NotNull; import org.junit.jupiter.api.Test; @@ -115,9 +117,9 @@ public class RateLimitersTest { @Test void testChangingConfiguration() { - final RateLimiterConfig initialRateLimiterConfig = new RateLimiterConfig(4, 1); - final RateLimiterConfig updatedRateLimiterCongig = new RateLimiterConfig(17, 19); - final RateLimiterConfig baseConfig = new RateLimiterConfig(1, 1); + final RateLimiterConfig initialRateLimiterConfig = new RateLimiterConfig(4, OptionalDouble.of(1), Optional.empty()); + final RateLimiterConfig updatedRateLimiterCongig = new RateLimiterConfig(17, OptionalDouble.of(19), Optional.empty()); + final RateLimiterConfig baseConfig = new RateLimiterConfig(1, OptionalDouble.of(1), Optional.empty()); final Map limitsConfigMap = new HashMap<>(); @@ -145,8 +147,8 @@ public class RateLimitersTest { @Test public void testRateLimiterHasItsPrioritiesStraight() throws Exception { final RateLimiters.For descriptor = RateLimiters.For.RECAPTCHA_CHALLENGE_ATTEMPT; - final RateLimiterConfig configForDynamic = new RateLimiterConfig(1, 1); - final RateLimiterConfig configForStatic = new RateLimiterConfig(2, 2); + final RateLimiterConfig configForDynamic = new RateLimiterConfig(1, OptionalDouble.of(1), Optional.empty()); + final RateLimiterConfig configForStatic = new RateLimiterConfig(2, OptionalDouble.of(2), Optional.empty()); final RateLimiterConfig defaultConfig = descriptor.defaultConfig(); final Map mapForDynamic = new HashMap<>(); @@ -187,7 +189,7 @@ public class RateLimitersTest { @Override public RateLimiterConfig defaultConfig() { - return new RateLimiterConfig(1, 1); + return new RateLimiterConfig(1, OptionalDouble.of(1), Optional.empty()); } } }