diff --git a/core/src/main/java/google/registry/eppserver/EppProtocolModule.java b/core/src/main/java/google/registry/eppserver/EppProtocolModule.java index c8670c15edb..576ea8f0796 100644 --- a/core/src/main/java/google/registry/eppserver/EppProtocolModule.java +++ b/core/src/main/java/google/registry/eppserver/EppProtocolModule.java @@ -27,6 +27,8 @@ import google.registry.eppserver.quota.EppServerQuotaManager; import google.registry.networking.handler.SslServerInitializer; import google.registry.quota.GenericValkeyQuotaManager; +import google.registry.quota.NoopQuotaManager; +import google.registry.quota.QuotaManager; import io.netty.channel.ChannelHandler; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.LengthFieldBasedFrameDecoder; @@ -140,7 +142,10 @@ static SslServerInitializer provideSslServerInitializer( @CommandQuota static EppServerQuotaManager provideCommandQuotaManager( @Config("eppServerQuota") RegistryConfigSettings.Quota quota, Optional jedis) { - return new EppServerQuotaManager( - quota, new GenericValkeyQuotaManager(jedis.orElse(null), "command")); + QuotaManager quotaManager = + jedis.isPresent() + ? GenericValkeyQuotaManager.create(jedis.get(), "command") + : new NoopQuotaManager(); + return new EppServerQuotaManager(quota, quotaManager); } } diff --git a/core/src/main/java/google/registry/eppserver/quota/EppServerQuotaManager.java b/core/src/main/java/google/registry/eppserver/quota/EppServerQuotaManager.java index 187d0769ee5..67fb2c1dd03 100644 --- a/core/src/main/java/google/registry/eppserver/quota/EppServerQuotaManager.java +++ b/core/src/main/java/google/registry/eppserver/quota/EppServerQuotaManager.java @@ -17,7 +17,7 @@ import com.google.common.collect.ImmutableMap; import google.registry.config.RegistryConfigSettings.Quota; import google.registry.config.RegistryConfigSettings.Quota.QuotaGroup; -import google.registry.quota.GenericValkeyQuotaManager; +import google.registry.quota.QuotaManager; import java.time.Duration; import javax.annotation.concurrent.ThreadSafe; @@ -31,11 +31,11 @@ public class EppServerQuotaManager { private static final Duration DEFAULT_TTL = Duration.ofHours(1); - private final GenericValkeyQuotaManager quotaManager; + private final QuotaManager quotaManager; private final QuotaGroup defaultQuota; private final ImmutableMap customQuotas; - public EppServerQuotaManager(Quota quota, GenericValkeyQuotaManager quotaManager) { + public EppServerQuotaManager(Quota quota, QuotaManager quotaManager) { this.quotaManager = quotaManager; this.defaultQuota = quota.defaultQuota; diff --git a/core/src/main/java/google/registry/quota/GenericValkeyQuotaManager.java b/core/src/main/java/google/registry/quota/GenericValkeyQuotaManager.java index c2f297c633c..246d9e79ac8 100644 --- a/core/src/main/java/google/registry/quota/GenericValkeyQuotaManager.java +++ b/core/src/main/java/google/registry/quota/GenericValkeyQuotaManager.java @@ -15,18 +15,18 @@ package google.registry.quota; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import static java.nio.charset.StandardCharsets.UTF_8; import com.google.common.flogger.FluentLogger; import java.net.URLEncoder; import java.time.Duration; -import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; import redis.clients.jedis.UnifiedJedis; /** Generic quota manager that uses Redis/Valkey as the backing store. */ @ThreadSafe -public class GenericValkeyQuotaManager { +public class GenericValkeyQuotaManager implements QuotaManager { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); @@ -63,16 +63,21 @@ public class GenericValkeyQuotaManager { private final UnifiedJedis jedis; private final String namespace; - public GenericValkeyQuotaManager(@Nullable UnifiedJedis jedis, String namespace) { + public static GenericValkeyQuotaManager create(UnifiedJedis jedis, String namespace) { + // TODO(gbrodman): upload the scripts to Valkey so we can reference them by hash rather than + // uploading the whole script each time + return new GenericValkeyQuotaManager(jedis, namespace); + } + + private GenericValkeyQuotaManager(UnifiedJedis jedis, String namespace) { + checkNotNull(jedis, "UnifiedJedis cannot be null"); this.jedis = jedis; this.namespace = namespace; } /** Attempts to acquire a quota token from Valkey. */ + @Override public boolean acquireQuota(String id, int maxTokenAmount, Duration expirationDuration) { - if (jedis == null) { - return true; // Fail open if no Valkey configured - } checkArgument(expirationDuration.isPositive(), "Duration must be positive"); checkArgument(maxTokenAmount >= 0, "Max token amount must be non-negative"); @@ -95,10 +100,8 @@ public boolean acquireQuota(String id, int maxTokenAmount, Duration expirationDu } /** Refreshes the TTL of an existing quota token. */ + @Override public void refreshQuota(String id, Duration expirationDuration) { - if (jedis == null) { - return; - } checkArgument(expirationDuration.isPositive(), "Duration must be positive"); String key = createValkeyKey(id); @@ -111,10 +114,8 @@ public void refreshQuota(String id, Duration expirationDuration) { } /** Returns a token to the pool (used for connection throttling). */ + @Override public void releaseQuota(String id, int maxTokenAmount) { - if (jedis == null) { - return; - } checkArgument(maxTokenAmount >= 0, "Max token amount must be non-negative"); String key = createValkeyKey(id); diff --git a/core/src/main/java/google/registry/quota/NoopQuotaManager.java b/core/src/main/java/google/registry/quota/NoopQuotaManager.java new file mode 100644 index 00000000000..74d31ca96e4 --- /dev/null +++ b/core/src/main/java/google/registry/quota/NoopQuotaManager.java @@ -0,0 +1,38 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.quota; + +import java.time.Duration; +import javax.annotation.concurrent.ThreadSafe; + +/** + * A {@link QuotaManager} that always returns true and performs no actions. + * + *

Used when Valkey/Jedis are not available. + */ +@ThreadSafe +public class NoopQuotaManager implements QuotaManager { + + @Override + public boolean acquireQuota(String id, int maxTokenAmount, Duration expirationDuration) { + return true; + } + + @Override + public void refreshQuota(String id, Duration expirationDuration) {} + + @Override + public void releaseQuota(String id, int maxTokenAmount) {} +} diff --git a/core/src/main/java/google/registry/quota/QuotaManager.java b/core/src/main/java/google/registry/quota/QuotaManager.java new file mode 100644 index 00000000000..f48d6a337a5 --- /dev/null +++ b/core/src/main/java/google/registry/quota/QuotaManager.java @@ -0,0 +1,29 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.quota; + +import java.time.Duration; + +/** Interface for managing quota. */ +public interface QuotaManager { + /** Attempts to acquire a token (out of the given max amount) with the given TTL. */ + boolean acquireQuota(String id, int maxTokenAmount, Duration expirationDuration); + + /** Refreshes the TTL of an existing token. */ + void refreshQuota(String id, Duration expirationDuration); + + /** Returns a token to the pool (possibly useful for connection throttling). */ + void releaseQuota(String id, int maxTokenAmount); +} diff --git a/core/src/test/java/google/registry/eppserver/quota/EppServerQuotaManagerTest.java b/core/src/test/java/google/registry/eppserver/quota/EppServerQuotaManagerTest.java index fba4c443971..dc4cec670e3 100644 --- a/core/src/test/java/google/registry/eppserver/quota/EppServerQuotaManagerTest.java +++ b/core/src/test/java/google/registry/eppserver/quota/EppServerQuotaManagerTest.java @@ -23,7 +23,7 @@ import com.google.common.collect.ImmutableList; import google.registry.config.RegistryConfigSettings.Quota; import google.registry.config.RegistryConfigSettings.Quota.QuotaGroup; -import google.registry.quota.GenericValkeyQuotaManager; +import google.registry.quota.QuotaManager; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -34,7 +34,7 @@ @ExtendWith(MockitoExtension.class) class EppServerQuotaManagerTest { - @Mock private GenericValkeyQuotaManager quotaManager; + @Mock private QuotaManager quotaManager; private Quota quotaConfig; private EppServerQuotaManager manager; diff --git a/core/src/test/java/google/registry/quota/GenericValkeyQuotaManagerTest.java b/core/src/test/java/google/registry/quota/GenericValkeyQuotaManagerTest.java index a3c222c1a40..62bac2711cf 100644 --- a/core/src/test/java/google/registry/quota/GenericValkeyQuotaManagerTest.java +++ b/core/src/test/java/google/registry/quota/GenericValkeyQuotaManagerTest.java @@ -16,6 +16,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import io.github.ss_bhatt.testcontainers.valkey.ValkeyContainer; import java.time.Duration; @@ -41,7 +42,7 @@ void setUp() { .hostAndPort(new HostAndPort(valkey.getHost(), valkey.getFirstMappedPort())) .build(); jedis.flushAll(); - quotaManager = new GenericValkeyQuotaManager(jedis, "testQuota"); + quotaManager = GenericValkeyQuotaManager.create(jedis, "testQuota"); } @Test @@ -76,7 +77,7 @@ void testAcquireQuota_resetsAfterExpiration() throws Exception { @Test void testAcquireQuota_isolatedByNamespaceAndId() { GenericValkeyQuotaManager otherQuotaManager = - new GenericValkeyQuotaManager(jedis, "otherQuota"); + GenericValkeyQuotaManager.create(jedis, "otherQuota"); assertThat(quotaManager.acquireQuota("user1", 1, Duration.ofMinutes(1))).isTrue(); assertThat(quotaManager.acquireQuota("user1", 1, Duration.ofMinutes(1))).isFalse(); @@ -89,9 +90,9 @@ void testAcquireQuota_isolatedByNamespaceAndId() { } @Test - void testAcquireQuota_nullJedis_failsOpen() { - GenericValkeyQuotaManager nullJedisManager = new GenericValkeyQuotaManager(null, "testQuota"); - assertThat(nullJedisManager.acquireQuota("user2", 10, Duration.ofMinutes(1))).isTrue(); + void testCreate_nullJedis_throwsNpe() { + assertThrows( + NullPointerException.class, () -> GenericValkeyQuotaManager.create(null, "testQuota")); } @Test @@ -114,12 +115,6 @@ void testRefreshQuota_nonexistentKey_noop() { assertThat(jedis.exists("testQuota:nonexistent")).isFalse(); } - @Test - void testRefreshQuota_nullJedis_noop() { - GenericValkeyQuotaManager nullJedisManager = new GenericValkeyQuotaManager(null, "testQuota"); - assertDoesNotThrow(() -> nullJedisManager.refreshQuota("user2", Duration.ofMinutes(1))); - } - @Test void testRefreshQuota_jedisException_handled() { jedis.close(); @@ -156,12 +151,6 @@ void testReleaseQuota_nonexistentKey_noop() { assertThat(jedis.exists("testQuota:nonexistent")).isFalse(); } - @Test - void testReleaseQuota_nullJedis_noop() { - GenericValkeyQuotaManager nullJedisManager = new GenericValkeyQuotaManager(null, "testQuota"); - assertDoesNotThrow(() -> nullJedisManager.releaseQuota("user2", 10)); - } - @Test void testReleaseQuota_jedisException_handled() { jedis.close(); diff --git a/core/src/test/java/google/registry/quota/NoopQuotaManagerTest.java b/core/src/test/java/google/registry/quota/NoopQuotaManagerTest.java new file mode 100644 index 00000000000..1b68fc8ec7a --- /dev/null +++ b/core/src/test/java/google/registry/quota/NoopQuotaManagerTest.java @@ -0,0 +1,41 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.quota; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class NoopQuotaManagerTest { + + private final NoopQuotaManager quotaManager = new NoopQuotaManager(); + + @Test + void testAcquireQuota_returnsTrue() { + assertThat(quotaManager.acquireQuota("user1", 10, Duration.ofMinutes(1))).isTrue(); + } + + @Test + void testRefreshQuota_noop() { + assertDoesNotThrow(() -> quotaManager.refreshQuota("user1", Duration.ofMinutes(1))); + } + + @Test + void testReleaseQuota_noop() { + assertDoesNotThrow(() -> quotaManager.releaseQuota("user1", 10)); + } +}