diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java index 00c68113f..adb6e7717 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java @@ -104,7 +104,7 @@ abstract class AbstractH2StreamMultiplexer implements Identifiable, HttpConnection { - private final H2ControlFrameRateLimiter controlFrameRateLimiter; + private final FrameLimitController frameLimitController; private static final long CONNECTION_WINDOW_LOW_MARK = 10 * 1024 * 1024; @@ -191,7 +191,7 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } final Timeout validateAfterInactivity, final Timeout pingAckTimeout) { this(ioSession, frameFactory, idGenerator, httpProcessor, charCodingConfig, h2Config, streamListener, - validateAfterInactivity, pingAckTimeout, new H2ControlFrameRateLimiter()); + validateAfterInactivity, pingAckTimeout, new FrameLimitController()); } AbstractH2StreamMultiplexer( @@ -204,7 +204,7 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } final H2StreamListener streamListener, final Timeout validateAfterInactivity, final Timeout pingAckTimeout, - final H2ControlFrameRateLimiter controlFrameRateLimiter) { + final FrameLimitController frameLimitController) { this.ioSession = Args.notNull(ioSession, "IO session"); this.frameFactory = Args.notNull(frameFactory, "Frame factory"); this.httpProcessor = Args.notNull(httpProcessor, "HTTP processor"); @@ -233,7 +233,7 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } this.lastActivityTime = System.currentTimeMillis(); this.validateAfterInactivity = validateAfterInactivity; this.pingAckTimeout = Args.notNull(pingAckTimeout, "PING ACK timeout"); - this.controlFrameRateLimiter = Args.notNull(controlFrameRateLimiter, "Control frame rate limiter"); + this.frameLimitController = Args.notNull(frameLimitController, "Frame limit controller"); } @Override @@ -970,6 +970,12 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio final H2Stream stream = streams.lookupSeen(streamId); if (stream != null) { final int errorCode = payload.getInt(); + // Only newly activated peer-initiated streams count. Valid resets of + // long-lived or locally initiated streams remain unaffected. + if (streams.isOtherSide(streamId) && !stream.isReserved() && !stream.isClosed() + && !frameLimitController.acceptRapidReset(stream.getActivatedAtNanos(), nowNanos)) { + throw new H2ConnectionException(H2Error.ENHANCE_YOUR_CALM, "Excessive rapid HTTP/2 stream resets"); + } if (errorCode == H2Error.NO_ERROR.getCode() && allowGracefulAbort(stream)) { stream.abortGracefully(); requestSessionOutput(); @@ -994,10 +1000,10 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio pingHandler.consumeResponse(ping); } } else { - if (!controlFrameRateLimiter.accept(System.nanoTime())) { - throw new H2ConnectionException(H2Error.ENHANCE_YOUR_CALM, "Excessive HTTP/2 PING / SETTINGS frames"); - } - final ByteBuffer pong = ByteBuffer.allocate(ping.remaining()); + if (!frameLimitController.acceptControlFrame(System.nanoTime())) { + throw new H2ConnectionException(H2Error.ENHANCE_YOUR_CALM, "Excessive HTTP/2 PING / SETTINGS frames"); + } + final ByteBuffer pong = ByteBuffer.allocate(ping.remaining()); pong.put(ping); pong.flip(); final RawFrame response = frameFactory.createPingAck(pong); @@ -1030,7 +1036,7 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio throw new H2ConnectionException(H2Error.FRAME_SIZE_ERROR, "Invalid SETTINGS payload"); } - if (!controlFrameRateLimiter.accept(System.nanoTime())) { + if (!frameLimitController.acceptControlFrame(System.nanoTime())) { throw new H2ConnectionException(H2Error.ENHANCE_YOUR_CALM, "Excessive HTTP/2 PING / SETTINGS frames"); } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/FrameLimitController.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/FrameLimitController.java new file mode 100644 index 000000000..d01bd6d9e --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/FrameLimitController.java @@ -0,0 +1,145 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.concurrent.TimeUnit; + +import org.apache.hc.core5.util.Args; + +/** + * Per-connection resource policy for unsolicited HTTP/2 control frames. Non-ACK PING and + * SETTINGS frames share one token bucket, exactly as before this class existed; rapid + * cancellation (RST_STREAM) of peer-initiated streams is charged against its own, + * independent bucket. + * + *

ACK frames, DATA and WINDOW_UPDATE are not charged, as they can represent legitimate + * high-throughput or highly concurrent exchanges. A stream reset is only charged against the + * RST_STREAM bucket when it arrives shortly after the stream was activated; resets of + * long-lived streams are not charged.

+ * + *

Parameters are hardwired for now; they may become user configurable in the future, at + * which point this is the single place that would need a public constructor or setters.

+ * + *

This is a resource safeguard, not an RFC 9113 protocol-validity rule.

+ */ +final class FrameLimitController { + + static final int CONTROL_FRAME_CAPACITY = 256; + static final int CONTROL_FRAME_REFILL_PER_SECOND = 128; + + static final int RST_STREAM_CAPACITY = 128; + static final int RST_STREAM_REFILL_PER_SECOND = 64; + static final long RST_STREAM_MAX_AGE_NANOS = TimeUnit.SECONDS.toNanos(1); + + private final TokenBucket controlFrameBucket; + private final TokenBucket rstStreamBucket; + private final long rstStreamMaxAgeNanos; + + FrameLimitController() { + this(CONTROL_FRAME_CAPACITY, CONTROL_FRAME_REFILL_PER_SECOND, + RST_STREAM_CAPACITY, RST_STREAM_REFILL_PER_SECOND, + RST_STREAM_MAX_AGE_NANOS); + } + + FrameLimitController( + final int controlFrameCapacity, + final int controlFrameRefillPerSecond, + final int rstStreamCapacity, + final int rstStreamRefillPerSecond, + final long rstStreamMaxAgeNanos) { + this.controlFrameBucket = new TokenBucket(controlFrameCapacity, controlFrameRefillPerSecond); + this.rstStreamBucket = new TokenBucket(rstStreamCapacity, rstStreamRefillPerSecond); + this.rstStreamMaxAgeNanos = Args.positive(rstStreamMaxAgeNanos, "Maximum reset age"); + } + + boolean acceptControlFrame(final long nowNanos) { + return controlFrameBucket.accept(nowNanos); + } + + boolean acceptRapidReset(final long activatedAtNanos, final long nowNanos) { + final long age = nowNanos - activatedAtNanos; + return age < 0 || age >= rstStreamMaxAgeNanos || rstStreamBucket.accept(nowNanos); + } + + /** + * Constant-time token bucket, with no locks or per-frame allocation, used to tolerate + * bursts and recover over time. Shared by every bucket kept by {@link FrameLimitController}; + * a new kind of limited frame is just another instance of this class. + */ + static final class TokenBucket { + + private static final long NANOS_PER_SECOND = 1_000_000_000L; + + private final int capacity; + private final int refillPerSecond; + private final long fullRefillNanos; + + private int tokens; + private long previousNanos; + private long remainder; + private boolean initialized; + + TokenBucket(final int capacity, final int refillPerSecond) { + this.capacity = Args.positive(capacity, "Capacity"); + this.refillPerSecond = Args.positive(refillPerSecond, "Refill per second"); + this.fullRefillNanos = (NANOS_PER_SECOND * capacity + refillPerSecond - 1) / refillPerSecond; + this.tokens = capacity; + } + + boolean accept(final long nowNanos) { + if (!initialized) { + initialized = true; + previousNanos = nowNanos; + } else { + final long elapsed = nowNanos - previousNanos; + // Avoid overflow and do not refill on a backward clock jump. + if (elapsed > 0) { + previousNanos = nowNanos; + if (elapsed >= fullRefillNanos) { + tokens = capacity; + remainder = 0; + } else { + final long numerator = elapsed * refillPerSecond + remainder; + final long refill = numerator / NANOS_PER_SECOND; + remainder = numerator % NANOS_PER_SECOND; + if (refill > 0) { + tokens = (int) Math.min(capacity, (long) tokens + refill); + if (tokens == capacity) { + remainder = 0; + } + } + } + } + } + if (tokens == 0) { + return false; + } + tokens--; + return true; + } + } +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2ControlFrameRateLimiter.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2ControlFrameRateLimiter.java deleted file mode 100644 index 77a696ecd..000000000 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2ControlFrameRateLimiter.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - * - */ -package org.apache.hc.core5.http2.impl.nio; - -import org.apache.hc.core5.util.Args; - -/** - * Per-connection resource policy for unsolicited HTTP/2 control frames. - * - *

The limiter is owned and called by a single HTTP/2 I/O session. Only - * non-ACK PING and SETTINGS frames are charged. ACK frames, DATA, - * WINDOW_UPDATE and RST_STREAM are not charged, as they can represent - * legitimate high-throughput or highly concurrent exchanges.

- * - *

Uses a small token bucket to tolerate bursts and recover over time, - * with no locks or per-frame allocation. This is a resource safeguard, - * not an RFC 9113 protocol-validity rule.

- */ -final class H2ControlFrameRateLimiter { - - static final int CAPACITY = 256; - static final int REFILL_PER_SECOND = 128; - private static final long NANOS_PER_SECOND = 1_000_000_000L; - - private final int capacity; - private final int refillPerSecond; - private final long fullRefillNanos; - - private int tokens; - private long previousNanos; - private long remainder; - private boolean initialized; - - H2ControlFrameRateLimiter(final int capacity, final int refillPerSecond) { - this.capacity = Args.positive(capacity, "Capacity"); - this.refillPerSecond = Args.positive(refillPerSecond, "Refill per second"); - this.fullRefillNanos = (NANOS_PER_SECOND * capacity + refillPerSecond - 1) / refillPerSecond; - this.tokens = capacity; - } - - H2ControlFrameRateLimiter() { - this(CAPACITY, REFILL_PER_SECOND); - } - - boolean accept(final long nowNanos) { - if (!initialized) { - initialized = true; - previousNanos = nowNanos; - } else { - final long elapsed = nowNanos - previousNanos; - // Avoid overflow and do not refill on a backward clock jump. - if (elapsed > 0) { - previousNanos = nowNanos; - if (elapsed >= fullRefillNanos) { - tokens = capacity; - remainder = 0; - } else { - final long numerator = elapsed * refillPerSecond + remainder; - final long refill = numerator / NANOS_PER_SECOND; - remainder = numerator % NANOS_PER_SECOND; - if (refill > 0) { - tokens = (int) Math.min(capacity, (long) tokens + refill); - if (tokens == capacity) { - remainder = 0; - } - } - } - } - } - if (tokens == 0) { - return false; - } - tokens--; - return true; - } -} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java index ad080cecc..8aacae579 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java @@ -63,6 +63,7 @@ class H2Stream implements StreamControl { private volatile boolean remoteClosed; private volatile long lastActivityNanos; + private volatile long activatedAtNanos; private volatile Timeout idleTimeout; private volatile PriorityValue priorityValue; @@ -112,9 +113,14 @@ private void triggerClosed() { void activate() { reserved = false; touch(); + activatedAtNanos = lastActivityNanos; triggerOpen(); } + long getActivatedAtNanos() { + return activatedAtNanos; + } + AtomicInteger getOutputWindow() { return channel.getOutputWindow(); } diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java index 54788d68a..68f38b4f8 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java @@ -167,9 +167,9 @@ public H2StreamMultiplexerImpl( final Supplier streamHandlerSupplier, final Timeout validateAfterInactivity, final Timeout pingAckTimeout, - final H2ControlFrameRateLimiter controlFrameRateLimiter) { + final FrameLimitController frameLimitController) { super(ioSession, frameFactory, idGenerator, httpProcessor, charCodingConfig, h2Config, streamListener, - validateAfterInactivity, pingAckTimeout, controlFrameRateLimiter); + validateAfterInactivity, pingAckTimeout, frameLimitController); this.streamHandlerSupplier = streamHandlerSupplier; } @@ -2569,8 +2569,11 @@ void testDataOnReservedRemoteStreamIsConnectionProtocolError() throws Exception void testMalformedSettingsAfterOverheadLimitReportsFrameSizeError() throws Exception { // A limiter without tokens that cannot refill: the multiplexer takes its timestamps // from System#nanoTime(), which is always earlier than the one used here. - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(1, 1); - Assertions.assertTrue(limiter.accept(Long.MAX_VALUE)); + final FrameLimitController limiter = new FrameLimitController( + 1, 1, + FrameLimitController.RST_STREAM_CAPACITY, FrameLimitController.RST_STREAM_REFILL_PER_SECOND, + FrameLimitController.RST_STREAM_MAX_AGE_NANOS); + Assertions.assertTrue(limiter.acceptControlFrame(Long.MAX_VALUE)); final AbstractH2StreamMultiplexer mux = new H2StreamMultiplexerImpl( protocolIOSession, @@ -2601,4 +2604,57 @@ void testMalformedSettingsAfterOverheadLimitReportsFrameSizeError() throws Excep Assertions.assertEquals(H2Error.ENHANCE_YOUR_CALM, H2Error.getByCode(overheadError.getCode())); } + @Test + void testRapidResetRejectsExcessivePeerCancellations() throws Exception { + final H2Config config = H2Config.custom().build(); + final AbstractH2StreamMultiplexer mux = new H2StreamMultiplexerImpl( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.EVEN, httpProcessor, + CharCodingConfig.DEFAULT, config, h2StreamListener, () -> streamHandler, + null, Timeout.ofSeconds(5), + new FrameLimitController( + FrameLimitController.CONTROL_FRAME_CAPACITY, FrameLimitController.CONTROL_FRAME_REFILL_PER_SECOND, + 1, 1, TimeUnit.DAYS.toNanos(1))); + + final HPackEncoder encoder = new HPackEncoder(H2Config.INIT.getHeaderTableSize(), + CharCodingSupport.createEncoder(CharCodingConfig.DEFAULT)); + final ByteArrayBuffer headerBlock = new ByteArrayBuffer(128); + encoder.encodeHeaders(headerBlock, Arrays.asList( + new BasicHeader(":method", "GET"), + new BasicHeader(":scheme", "http"), + new BasicHeader(":path", "/"), + new BasicHeader(":authority", "localhost")), config.isCompressionEnabled()); + + for (final int streamId : new int[] {1, 3}) { + final RawFrame headers = FRAME_FACTORY.createHeaders(streamId, + ByteBuffer.wrap(headerBlock.array(), 0, headerBlock.length()), true, true); + mux.onInput(ByteBuffer.wrap(encodeFrame(headers))); + final RawFrame reset = FRAME_FACTORY.createResetStream(streamId, H2Error.CANCEL); + if (streamId == 1) { + Assertions.assertDoesNotThrow(() -> mux.onInput(ByteBuffer.wrap(encodeFrame(reset)))); + } else { + final H2ConnectionException ex = Assertions.assertThrows(H2ConnectionException.class, + () -> mux.onInput(ByteBuffer.wrap(encodeFrame(reset)))); + Assertions.assertEquals(H2Error.ENHANCE_YOUR_CALM, H2Error.getByCode(ex.getCode())); + } + } + } + + @Test + void testResetOfLocallyInitiatedStreamsDoesNotExhaustRemoteLimit() throws Exception { + final AbstractH2StreamMultiplexer mux = new H2StreamMultiplexerImpl( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD, httpProcessor, + CharCodingConfig.DEFAULT, H2Config.custom().build(), h2StreamListener, + () -> streamHandler, null, Timeout.ofSeconds(5), + new FrameLimitController( + FrameLimitController.CONTROL_FRAME_CAPACITY, FrameLimitController.CONTROL_FRAME_REFILL_PER_SECOND, + 1, 1, TimeUnit.DAYS.toNanos(1))); + + for (final int streamId : new int[] {1, 3}) { + mux.createStream(mux.createChannel(streamId), streamHandler); + final RawFrame reset = FRAME_FACTORY.createResetStream(streamId, H2Error.CANCEL); + Assertions.assertDoesNotThrow(() -> mux.onInput(ByteBuffer.wrap(encodeFrame(reset)))); + } + } + + } \ No newline at end of file diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestFrameLimitController.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestFrameLimitController.java new file mode 100644 index 000000000..f70c0676c --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestFrameLimitController.java @@ -0,0 +1,222 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestFrameLimitController { + + private static final long SECOND = TimeUnit.SECONDS.toNanos(1); + + // A generous bucket for the frame kind a given test is not exercising, so it never + // interferes with the one under test. + private static final int GENEROUS_CAPACITY = 1_000_000; + private static final int GENEROUS_REFILL = 1_000_000; + + private static FrameLimitController withControlFrame(final int capacity, final int refillPerSecond) { + return new FrameLimitController(capacity, refillPerSecond, GENEROUS_CAPACITY, GENEROUS_REFILL, SECOND); + } + + private static FrameLimitController withRapidReset(final int capacity, final int refillPerSecond, final long maxAgeNanos) { + return new FrameLimitController(GENEROUS_CAPACITY, GENEROUS_REFILL, capacity, refillPerSecond, maxAgeNanos); + } + + // ------------------------------------------------------------------------- + // Control frame bucket (PING and SETTINGS share this one, exactly as before + // this class existed: ported from the former TestH2ControlFrameRateLimiter) + // ------------------------------------------------------------------------- + + private static void assertAccepted(final FrameLimitController controller, final int count, final long nowNanos) { + for (int i = 0; i < count; i++) { + Assertions.assertTrue(controller.acceptControlFrame(nowNanos), "frame " + (i + 1) + " of " + count); + } + } + + @Test + void testInitialCapacity() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 4, 0L); + } + + @Test + void testExhaustion() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 4, 0L); + Assertions.assertFalse(controller.acceptControlFrame(0L)); + } + + @Test + void testProportionalRefill() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 4, 0L); + + // 2 frames per second: one second brings back 2 frames, not 4 + assertAccepted(controller, 2, SECOND); + Assertions.assertFalse(controller.acceptControlFrame(SECOND)); + + assertAccepted(controller, 2, 2 * SECOND); + Assertions.assertFalse(controller.acceptControlFrame(2 * SECOND)); + } + + @Test + void testFractionalRefill() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 4, 0L); + + // 0.25 s at 2 frames per second is half a frame + Assertions.assertFalse(controller.acceptControlFrame(SECOND / 4)); + // another 0.25 s completes the frame + Assertions.assertTrue(controller.acceptControlFrame(SECOND / 2)); + Assertions.assertFalse(controller.acceptControlFrame(SECOND / 2)); + } + + @Test + void testRecoveryAfterRejection() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 4, 0L); + Assertions.assertFalse(controller.acceptControlFrame(0L)); + + Assertions.assertTrue(controller.acceptControlFrame(SECOND)); + } + + @Test + void testRefillDoesNotExceedCapacity() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 1, 0L); + + // 3 frames left, one second adds 2: 5 would exceed the capacity of 4 + assertAccepted(controller, 4, SECOND); + Assertions.assertFalse(controller.acceptControlFrame(SECOND)); + } + + @Test + void testIdleTimeRestoresCapacityOnly() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 4, 0L); + + assertAccepted(controller, 4, 10 * SECOND); + Assertions.assertFalse(controller.acceptControlFrame(10 * SECOND)); + } + + @Test + void testCustomParameters() { + final FrameLimitController controller = withControlFrame(10, 5); + + assertAccepted(controller, 10, 0L); + Assertions.assertFalse(controller.acceptControlFrame(0L)); + + assertAccepted(controller, 5, SECOND); + Assertions.assertFalse(controller.acceptControlFrame(SECOND)); + } + + @Test + void testDefaultParameters() { + final FrameLimitController controller = new FrameLimitController(); + + assertAccepted(controller, FrameLimitController.CONTROL_FRAME_CAPACITY, 0L); + Assertions.assertFalse(controller.acceptControlFrame(0L)); + + assertAccepted(controller, FrameLimitController.CONTROL_FRAME_REFILL_PER_SECOND, SECOND); + Assertions.assertFalse(controller.acceptControlFrame(SECOND)); + } + + @Test + void testClockRollbackDoesNotRefill() { + final FrameLimitController controller = withControlFrame(4, 2); + assertAccepted(controller, 4, 100L); + Assertions.assertFalse(controller.acceptControlFrame(99L)); + } + + @Test + void testInvalidParameters() { + Assertions.assertThrows(IllegalArgumentException.class, () -> withControlFrame(0, 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> withControlFrame(-1, 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> withControlFrame(1, 0)); + Assertions.assertThrows(IllegalArgumentException.class, () -> withControlFrame(1, -1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> withRapidReset(1, 1, 0L)); + Assertions.assertThrows(IllegalArgumentException.class, () -> withRapidReset(1, 1, -1L)); + } + + // ------------------------------------------------------------------------- + // Rapid reset has its own, independent bucket + // ------------------------------------------------------------------------- + + @Test + void testRapidResetHasItsOwnBudgetIndependentOfControlFrames() { + final FrameLimitController controller = new FrameLimitController(1, 1, 1, 1, SECOND); + + Assertions.assertTrue(controller.acceptRapidReset(0L, 0L)); + Assertions.assertFalse(controller.acceptRapidReset(0L, 0L)); + // Exhausting RST_STREAM must not affect PING / SETTINGS. + Assertions.assertTrue(controller.acceptControlFrame(0L)); + } + + @Test + void testShortLivedResetsAreLimited() { + final FrameLimitController controller = withRapidReset(2, 1, SECOND); + Assertions.assertTrue(controller.acceptRapidReset(0, 0)); + Assertions.assertTrue(controller.acceptRapidReset(0, 0)); + Assertions.assertFalse(controller.acceptRapidReset(0, 0)); + } + + @Test + void testLongLivedStreamResetIsNotCharged() { + final FrameLimitController controller = withRapidReset(1, 1, SECOND); + Assertions.assertTrue(controller.acceptRapidReset(0, 0)); + Assertions.assertFalse(controller.acceptRapidReset(0, 0)); + Assertions.assertTrue(controller.acceptRapidReset(0, SECOND)); + Assertions.assertFalse(controller.acceptRapidReset(0, 0)); + } + + @Test + void testRapidResetTokensRecoverOverTime() { + final FrameLimitController controller = withRapidReset(2, 1, SECOND); + Assertions.assertTrue(controller.acceptRapidReset(0, 0)); + Assertions.assertTrue(controller.acceptRapidReset(0, 0)); + Assertions.assertFalse(controller.acceptRapidReset(0, 0)); + Assertions.assertTrue(controller.acceptRapidReset(SECOND, SECOND)); + Assertions.assertFalse(controller.acceptRapidReset(SECOND, SECOND)); + } + + @Test + void testIndependentConnectionsHaveIndependentBudgets() { + final FrameLimitController first = withRapidReset(1, 1, SECOND); + final FrameLimitController second = withRapidReset(1, 1, SECOND); + Assertions.assertTrue(first.acceptRapidReset(0, 0)); + Assertions.assertFalse(first.acceptRapidReset(0, 0)); + Assertions.assertTrue(second.acceptRapidReset(0, 0)); + } + + @Test + void testClockMovingBackwardsDoesNotRejectRapidReset() { + final FrameLimitController controller = withRapidReset(1, 1, SECOND); + Assertions.assertTrue(controller.acceptRapidReset(100, 99)); + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2ControlFrameRateLimiter.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2ControlFrameRateLimiter.java deleted file mode 100644 index 26a9256df..000000000 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2ControlFrameRateLimiter.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - * - */ -package org.apache.hc.core5.http2.impl.nio; - -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class TestH2ControlFrameRateLimiter { - - private static final long SECOND = TimeUnit.SECONDS.toNanos(1); - - private static void assertAccepted(final H2ControlFrameRateLimiter limiter, final int count, final long nowNanos) { - for (int i = 0; i < count; i++) { - Assertions.assertTrue(limiter.accept(nowNanos), "frame " + (i + 1) + " of " + count); - } - } - - @Test - void testInitialCapacity() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - - assertAccepted(limiter, 4, 0L); - } - - @Test - void testExhaustion() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - - assertAccepted(limiter, 4, 0L); - Assertions.assertFalse(limiter.accept(0L)); - } - - @Test - void testProportionalRefill() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - assertAccepted(limiter, 4, 0L); - - // 2 frames per second: one second brings back 2 frames, not 4 - assertAccepted(limiter, 2, SECOND); - Assertions.assertFalse(limiter.accept(SECOND)); - - assertAccepted(limiter, 2, 2 * SECOND); - Assertions.assertFalse(limiter.accept(2 * SECOND)); - } - - @Test - void testFractionalRefill() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - assertAccepted(limiter, 4, 0L); - - // 0.25 s at 2 frames per second is half a frame - Assertions.assertFalse(limiter.accept(SECOND / 4)); - // another 0.25 s completes the frame - Assertions.assertTrue(limiter.accept(SECOND / 2)); - Assertions.assertFalse(limiter.accept(SECOND / 2)); - } - - @Test - void testRecoveryAfterRejection() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - assertAccepted(limiter, 4, 0L); - Assertions.assertFalse(limiter.accept(0L)); - - Assertions.assertTrue(limiter.accept(SECOND)); - } - - @Test - void testRefillDoesNotExceedCapacity() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - assertAccepted(limiter, 1, 0L); - - // 3 frames left, one second adds 2: 5 would exceed the capacity of 4 - assertAccepted(limiter, 4, SECOND); - Assertions.assertFalse(limiter.accept(SECOND)); - } - - @Test - void testIdleTimeRestoresCapacityOnly() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - assertAccepted(limiter, 4, 0L); - - assertAccepted(limiter, 4, 10 * SECOND); - Assertions.assertFalse(limiter.accept(10 * SECOND)); - } - - @Test - void testCustomParameters() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(10, 5); - - assertAccepted(limiter, 10, 0L); - Assertions.assertFalse(limiter.accept(0L)); - - assertAccepted(limiter, 5, SECOND); - Assertions.assertFalse(limiter.accept(SECOND)); - } - - @Test - void testDefaultParameters() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(); - - assertAccepted(limiter, H2ControlFrameRateLimiter.CAPACITY, 0L); - Assertions.assertFalse(limiter.accept(0L)); - - assertAccepted(limiter, H2ControlFrameRateLimiter.REFILL_PER_SECOND, SECOND); - Assertions.assertFalse(limiter.accept(SECOND)); - } - - @Test - void testClockRollbackDoesNotRefill() { - final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); - assertAccepted(limiter, 4, 100L); - - Assertions.assertFalse(limiter.accept(99L)); - } - - @Test - void testInvalidParameters() { - Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(0, 1)); - Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(-1, 1)); - Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(1, 0)); - Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(1, -1)); - } - -}