This is an automated email from the ASF dual-hosted git repository.

Cole-Greer pushed a commit to branch 3.7-dev
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


The following commit(s) were added to refs/heads/3.7-dev by this push:
     new d429ec0bd8 Cap and expire requests deferred during SASL authentication
d429ec0bd8 is described below

commit d429ec0bd8cba4885b12054b63a44fe14df97afc
Author: Guian Gumpac <[email protected]>
AuthorDate: Tue Sep 1 12:28:15 2026 -0700

    Cap and expire requests deferred during SASL authentication
    
    SaslAuthenticationHandler now bounds how many requests and how many
    request bytes a channel may retain while its SASL authentication is
    pending, and closes a channel whose authentication does not complete in
    time. A request beyond a limit is answered UNAUTHORIZED rather than
    retained, and can be resent once the channel is authenticated.
    
    All three limits are configurable as authentication.maxDeferredRequests
    (64), authentication.maxPreAuthRetainedBytes (2097152) and
    authentication.preAuthTimeout (30000 ms). Each must be greater than
    zero or Gremlin Server fails to start.
    
    MAX_REQUEST_DEFERRABLE_DURATION is now the default for
    authentication.preAuthTimeout, and its value moved from 5 to 30 seconds.
    A request that a failed authentication attempt already answered is no
    longer replayed when a later attempt on the same channel succeeds.
    
    Assisted-by: Claude Code:claude-opus-5
---
 CHANGELOG.asciidoc                                 |   1 +
 docs/src/dev/provider/index.asciidoc               |   8 +-
 docs/src/reference/gremlin-applications.asciidoc   |   3 +
 docs/src/upgrade/release-3.7.x.asciidoc            |  38 +
 .../apache/tinkerpop/gremlin/server/Settings.java  |  37 +
 .../server/handler/SaslAuthenticationHandler.java  | 225 +++++-
 .../tinkerpop/gremlin/server/handler/StateKey.java |  16 +
 .../handler/WsGremlinBinaryRequestDecoder.java     |   3 +
 .../handler/WsGremlinCloseRequestDecoder.java      |   3 +
 .../handler/WsGremlinTextRequestDecoder.java       |   3 +
 .../server/GremlinServerAuthIntegrateTest.java     |  60 +-
 .../handler/SaslAuthenticationHandlerTest.java     | 826 +++++++++++++++++++++
 .../handler/WsGremlinRequestDecoderTest.java       | 126 ++++
 13 files changed, 1286 insertions(+), 63 deletions(-)

diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index f45d47a595..4e0bcae085 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -61,6 +61,7 @@ 
image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
 * Fixed request isolation in HTTP and WebSocket authorization handlers.
 * Fixed `gremlin-python` GraphBinary serialization of 
`BigInteger`/`BigDecimal` negative boundary values (e.g. `-129`) that raised 
`OverflowError`.
 * Fixed `gremlin-go` GraphBinary serialization of zero 
`BigInteger`/`BigDecimal` values, which were encoded with zero length and 
rejected by Java servers.
+* Capped by count and by total size the requests `SaslAuthenticationHandler` 
retains on a channel while its SASL authentication is pending, and closed the 
channel when that authentication does not complete within 30 seconds, all three 
tunable with the new `authentication.maxDeferredRequests`, 
`authentication.maxPreAuthRetainedBytes` and `authentication.preAuthTimeout` 
settings.
 
 [[release-3-7-6]]
 === TinkerPop 3.7.6 (Release Date: April 1, 2026)
diff --git a/docs/src/dev/provider/index.asciidoc 
b/docs/src/dev/provider/index.asciidoc
index 376bc22e83..98c0272e19 100644
--- a/docs/src/dev/provider/index.asciidoc
+++ b/docs/src/dev/provider/index.asciidoc
@@ -1332,15 +1332,17 @@ Other SASL mechanisms supported for selected clients 
are listed in the
 link:https://tinkerpop.apache.org/docs/x.y.z/reference/#security[security 
section of the Gremlin Server reference documentation].
 
 When authentication is enabled, an incoming request is intercepted before it 
is evaluated by the `ScriptEngine`.  The
-request is saved on the server and a `AUTHENTICATE` challenge response (status 
code `407`) is returned to the client.
+request is saved on the server, unless it is too large to be held pending 
authentication, and a `AUTHENTICATE`
+challenge response (status code `407`) is returned to the client.
 
 The client will detect the `AUTHENTICATE` and respond with an `authentication` 
for the `op` and an `arg` named `sasl`.
 In case of the "PLAIN" SASL mechanism the `arg` contains the password.  The 
password should be either, an encoded
 sequence of UTF-8 bytes, delimited by 0 (US-ASCII NUL), where the form is : 
`<NUL>username<NUL>password`, or a Base64
 encoded string of the former (which in this instance would be 
`AHVzZXJuYW1lAHBhc3N3b3Jk`).  Should Gremlin Server be
 able to authenticate with the provided credentials, the server will return the 
results of the original request as it
-normally does without authentication.  If it cannot authenticate given the 
challenge response from the client, it will
-return `UNAUTHORIZED` (status code `401`).
+normally does without authentication.  A request that was not held, either 
because it was too large or because an
+earlier failed authentication attempt already answered it, is not replayed and 
must be resent.  If it cannot
+authenticate given the challenge response from the client, it will return 
`UNAUTHORIZED` (status code `401`).
 
 NOTE: Gremlin Server does not support the "authorization identity" as 
described in link:https://tools.ietf.org/html/rfc4616[RFC4616].
 
diff --git a/docs/src/reference/gremlin-applications.asciidoc 
b/docs/src/reference/gremlin-applications.asciidoc
index 81c157247f..722cf561f9 100644
--- a/docs/src/reference/gremlin-applications.asciidoc
+++ b/docs/src/reference/gremlin-applications.asciidoc
@@ -1028,6 +1028,9 @@ The following table describes the various YAML 
configuration options that Gremli
 |authentication.authenticator |The fully qualified classname of an 
`Authenticator` implementation to use.  If this setting is not present, then 
authentication is effectively disabled. |`AllowAllAuthenticator`
 |authentication.authenticationHandler | The fully qualified classname of an 
`AbstractAuthenticationHandler` implementation to use. If this setting is not 
present, but the `authentication.authenticator` is, it will use that 
authenticator with the default `AbstractAuthenticationHandler` implementation 
for the specified `Channelizer` |_none_
 |authentication.config |A `Map` of configuration settings to be passed to the 
`Authenticator` when it is constructed.  The settings available are dependent 
on the implementation. |_none_
+|authentication.maxDeferredRequests |The number of requests a channel may 
defer while its SASL authentication is pending. A request beyond this number is 
answered with `UNAUTHORIZED` rather than deferred. Must be greater than zero. 
This configuration only applies to the `SaslAuthenticationHandler` and its 
subclasses. |64
+|authentication.maxPreAuthRetainedBytes |The total request bytes a channel may 
have retained while its SASL authentication is pending, covering the request 
that began authentication as well as the deferred ones. A request beyond this 
total is answered with `UNAUTHORIZED` rather than retained. Must be greater 
than zero. This configuration only applies to the `SaslAuthenticationHandler` 
and its subclasses. |2097152
+|authentication.preAuthTimeout |Time in milliseconds a channel may stay 
unauthenticated, covering the whole SASL handshake and not just the deferral 
window, after which anything the channel still has pending is answered with 
`UNAUTHORIZED` and the channel is closed. Must be greater than zero. This 
configuration only applies to the `SaslAuthenticationHandler` and its 
subclasses. |30000
 |authorization.authorizer |The fully qualified classname of an `Authorizer` 
implementation to use. |_none_
 |authorization.config |A `Map` of configuration settings to be passed to the 
`Authorizer` when it is constructed.  The settings available are dependent on 
the implementation. |_none_
 |channelizer |The fully qualified classname of the `Channelizer` 
implementation to use.  A `Channelizer` is a "channel initializer" which 
Gremlin Server uses to define the type of processing pipeline to use.  By 
allowing different `Channelizer` implementations, Gremlin Server can support 
different communication protocols (e.g. WebSocket). |`WebSocketChannelizer`
diff --git a/docs/src/upgrade/release-3.7.x.asciidoc 
b/docs/src/upgrade/release-3.7.x.asciidoc
index 380a70cddd..df795f61d6 100644
--- a/docs/src/upgrade/release-3.7.x.asciidoc
+++ b/docs/src/upgrade/release-3.7.x.asciidoc
@@ -274,6 +274,44 @@ registered by default. An unregistered custom strategy 
produces an error such as
 
 See: 
link:https://tinkerpop.apache.org/docs/3.7.7/reference/#traversalstrategy-registration[TraversalStrategy
 Registration]
 
+==== Pre-Authentication Request Limits
+
+Gremlin Server now bounds what a channel can make it retain while that 
channel's SASL authentication is pending, and
+terminates a channel whose authentication does not complete. Only deployments 
that configure an `Authenticator` are
+affected, as the handler is not in the pipeline for the default 
`AllowAllAuthenticator`.
+
+A channel may defer at most 64 requests, and at most 2 MiB of total request 
bytes covering both the request that began
+authentication and the deferred ones. A request beyond either cap is answered 
with `UNAUTHORIZED` (status code `401`)
+rather than deferred, and can be resent once the channel is authenticated.
+
+A request larger than 2 MiB that arrives first on a channel still receives the 
`AUTHENTICATE` challenge, but it is not
+held, so it is answered the same way. Note that `maxContentLength` defaults to 
10 MiB, so a request the server
+otherwise accepts can be affected. Because drivers open pooled connections on 
demand, the first request on a new
+connection is an ordinary user request, so this can appear intermittently for 
workloads that send bulk data inside a
+query, such as a large `inject()` or a `within()` over many ids.
+
+`MAX_REQUEST_DEFERRABLE_DURATION` was raised from 5 seconds to 30 seconds, and 
a channel that has not completed
+authentication within that duration is now closed. Previously the handler 
never closed such a channel, and
+`idleConnectionTimeout` defaults to `0`, so nothing reaped it. A request that 
a failed authentication attempt already
+answered is no longer replayed when a later attempt on the same channel 
succeeds.
+
+All three limits are configurable, as `authentication.maxDeferredRequests`, 
`authentication.maxPreAuthRetainedBytes`
+and `authentication.preAuthTimeout`, the last in milliseconds. Each must be 
greater than zero and Gremlin Server fails
+to start otherwise. The defaults are the values described above, so a 
configuration that sets none of them behaves as
+described.
+
+Each of the errors below is a resource limit rather than a credential failure, 
so an operator seeing the `UNAUTHORIZED`
+status code they share need not investigate credentials. The messages show the 
default limits.
+
+[cols="1,3,3", options="header"]
+|=========================================================
+|Status Code |Message |Remedy
+|`401` |`Too many requests were deferred pending authentication (64 maximum).` 
|Resend once authenticated, or raise `authentication.maxDeferredRequests`.
+|`401` |`Too many bytes were retained pending authentication (2097152 bytes 
maximum).` |Resend once authenticated, or raise 
`authentication.maxPreAuthRetainedBytes`.
+|`401` |`Request is too large to hold pending authentication (2097152 bytes 
maximum).` |Resend once authenticated, or raise 
`authentication.maxPreAuthRetainedBytes`.
+|`401` |`Authentication did not finish in the allowed duration (30000 ms).` 
|Complete the SASL handshake sooner, or raise `authentication.preAuthTimeout`.
+|=========================================================
+
 === Upgrading for Providers
 
 ==== Graph System Providers
diff --git 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
index 210bee930b..b60f2f337e 100644
--- 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
+++ 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/Settings.java
@@ -31,6 +31,7 @@ import org.apache.tinkerpop.gremlin.server.authz.Authorizer;
 import org.apache.tinkerpop.gremlin.server.channel.UnifiedChannelizer;
 import org.apache.tinkerpop.gremlin.server.channel.WebSocketChannelizer;
 import 
org.apache.tinkerpop.gremlin.server.handler.AbstractAuthenticationHandler;
+import org.apache.tinkerpop.gremlin.server.handler.SaslAuthenticationHandler;
 import org.apache.tinkerpop.gremlin.server.handler.Session;
 import org.apache.tinkerpop.gremlin.server.util.DefaultGraphManager;
 import org.apache.tinkerpop.gremlin.server.util.LifeCycleHook;
@@ -491,6 +492,22 @@ public class Settings {
      * Settings for the {@link Authenticator} implementation.
      */
     public static class AuthenticationSettings {
+        /**
+         * Default for {@link #maxDeferredRequests}.
+         */
+        public static final int DEFAULT_MAX_DEFERRED_REQUESTS = 64;
+
+        /**
+         * Default for {@link #maxPreAuthRetainedBytes}.
+         */
+        public static final long DEFAULT_MAX_PRE_AUTH_RETAINED_BYTES = 2L * 
1024 * 1024;
+
+        /**
+         * Default for {@link #preAuthTimeout}.
+         */
+        public static final long DEFAULT_PRE_AUTH_TIMEOUT =
+                
SaslAuthenticationHandler.MAX_REQUEST_DEFERRABLE_DURATION.toMillis();
+
         /**
          * The fully qualified class name of the {@link Authenticator} 
implementation. This class name will be
          * used to load the implementation from the classpath. Defaults to 
{@link AllowAllAuthenticator} when
@@ -510,6 +527,26 @@ public class Settings {
          * {@link Authenticator} implementation for specifics on what 
configurations are expected.
          */
         public Map<String, Object> config = null;
+
+        /**
+         * Number of requests a channel may defer while its SASL 
authentication is pending. Must be greater than zero.
+         * Only applies to {@link SaslAuthenticationHandler} and its 
subclasses.
+         */
+        public int maxDeferredRequests = DEFAULT_MAX_DEFERRED_REQUESTS;
+
+        /**
+         * Total wire bytes a channel may have retained while its SASL 
authentication is pending, covering the request
+         * that started it as well as the deferred ones. Deserialized, their 
heap footprint is a multiple of this. Must
+         * be greater than zero. Only applies to {@link 
SaslAuthenticationHandler} and its subclasses.
+         */
+        public long maxPreAuthRetainedBytes = 
DEFAULT_MAX_PRE_AUTH_RETAINED_BYTES;
+
+        /**
+         * Time in milliseconds a channel may stay unauthenticated, covering 
the whole handshake and not just the
+         * deferral window, after which it is closed. Must be greater than 
zero. Only applies to
+         * {@link SaslAuthenticationHandler} and its subclasses.
+         */
+        public long preAuthTimeout = DEFAULT_PRE_AUTH_TIMEOUT;
     }
 
     /**
diff --git 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/SaslAuthenticationHandler.java
 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/SaslAuthenticationHandler.java
index f22d465e29..50ac3b9fc5 100644
--- 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/SaslAuthenticationHandler.java
+++ 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/SaslAuthenticationHandler.java
@@ -22,6 +22,7 @@ import io.netty.channel.Channel;
 import io.netty.channel.ChannelHandler;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.util.Attribute;
+import io.netty.util.concurrent.ScheduledFuture;
 import org.apache.commons.lang3.tuple.ImmutablePair;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.tinkerpop.gremlin.server.GremlinServer;
@@ -44,8 +45,10 @@ import java.time.Duration;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Base64;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 
 /**
@@ -59,7 +62,11 @@ public class SaslAuthenticationHandler extends 
AbstractAuthenticationHandler {
     private static final Logger logger = 
LoggerFactory.getLogger(SaslAuthenticationHandler.class);
     private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder();
     private static final Base64.Encoder BASE64_ENCODER = Base64.getEncoder();
-    public static final Duration MAX_REQUEST_DEFERRABLE_DURATION = 
Duration.ofSeconds(5);
+    /**
+     * Default for {@code settings.authentication.preAuthTimeout}, how long a 
channel may stay unauthenticated,
+     * covering the whole handshake, not just the deferral window.
+     */
+    public static final Duration MAX_REQUEST_DEFERRABLE_DURATION = 
Duration.ofSeconds(30);
     private static final Logger auditLogger = 
LoggerFactory.getLogger(GremlinServer.AUDIT_LOGGER_NAME);
 
     protected final Settings settings;
@@ -74,6 +81,23 @@ public class SaslAuthenticationHandler extends 
AbstractAuthenticationHandler {
 
     public SaslAuthenticationHandler(final Authenticator authenticator, final 
Authorizer authorizer, final Settings settings) {
         super(authenticator, authorizer);
+
+        // rejected here so that a bad configuration fails startup rather than 
a request
+        if (settings.authentication.maxDeferredRequests < 1)
+            throw new IllegalStateException(String.format(
+                    "authentication.maxDeferredRequests must be greater than 
zero but was %s",
+                    settings.authentication.maxDeferredRequests));
+
+        if (settings.authentication.maxPreAuthRetainedBytes < 1)
+            throw new IllegalStateException(String.format(
+                    "authentication.maxPreAuthRetainedBytes must be greater 
than zero but was %s",
+                    settings.authentication.maxPreAuthRetainedBytes));
+
+        if (settings.authentication.preAuthTimeout < 1)
+            throw new IllegalStateException(String.format(
+                    "authentication.preAuthTimeout must be greater than zero 
but was %s",
+                    settings.authentication.preAuthTimeout));
+
         this.settings = settings;
     }
 
@@ -91,15 +115,43 @@ public class SaslAuthenticationHandler extends 
AbstractAuthenticationHandler {
         final Attribute<Authenticator.SaslNegotiator> negotiator = 
ctx.channel().attr(StateKey.NEGOTIATOR);
         final Attribute<RequestMessage> request = 
ctx.channel().attr(StateKey.REQUEST_MESSAGE);
         final Attribute<Pair<LocalDateTime, List<RequestMessage>>> 
deferredRequests = ctx.channel().attr(StateKey.DEFERRED_REQUEST_MESSAGES);
+        final Attribute<Long> retainedBytes = 
ctx.channel().attr(StateKey.DEFERRED_REQUEST_BYTES);
+
+        final long maxRetainedBytes = 
settings.authentication.maxPreAuthRetainedBytes;
 
         if (negotiator.get() == null) {
+            final long incomingRequestSize = 
incomingRequestSize(ctx.channel());
+
+            // the request is held until authentication completes, so on its 
own it has to fit what may be retained
+            final boolean tooLargeToRetain = incomingRequestSize > 
maxRetainedBytes;
+
+            if (tooLargeToRetain) {
+                logger.debug("Not retaining the {} byte request from {} 
pending authentication - {} bytes maximum",
+                        incomingRequestSize, ctx.channel().remoteAddress(), 
maxRetainedBytes);
+            }
+
             try {
                 // First time through so save the request and send an 
AUTHENTICATE challenge with no data
                 
negotiator.set(authenticator.newSaslNegotiator(getRemoteInetAddress(ctx)));
-                request.set(requestMessage);
+
+                // retention starts here, so the deadline on it does too
+                armDeadline(ctx);
+
+                if (!tooLargeToRetain) {
+                    request.set(requestMessage);
+                    retainedBytes.set(incomingRequestSize);
+                }
+
                 final ResponseMessage authenticate = 
ResponseMessage.build(requestMessage)
                         .code(ResponseStatusCode.AUTHENTICATE).create();
                 ctx.writeAndFlush(authenticate);
+
+                // answered after the challenge, so authentication can still 
complete and the request be resent
+                if (tooLargeToRetain) {
+                    ctx.writeAndFlush(ResponseMessage.build(requestMessage)
+                            .statusMessage("Request is too large to hold 
pending authentication (" + maxRetainedBytes + " bytes maximum).")
+                            .code(ResponseStatusCode.UNAUTHORIZED).create());
+                }
             } catch (Exception ex) {
                 // newSaslNegotiator can cause troubles - if we don't catch 
and respond nicely the driver seems
                 // to hang until timeout which isn't so nice. treating this 
like a server error as it means that
@@ -116,20 +168,45 @@ public class SaslAuthenticationHandler extends 
AbstractAuthenticationHandler {
             return;
         } else if (!requestMessage.getOp().equals(Tokens.OPS_AUTHENTICATION)) {
             // If authentication negotiation is pending, store subsequent 
non-authentication requests for later processing
-            deferredRequests.setIfAbsent(new 
ImmutablePair<>(LocalDateTime.now(), new ArrayList<>()));
-            deferredRequests.get().getValue().add(requestMessage);
+            final Pair<LocalDateTime, List<RequestMessage>> deferred = 
deferredRequests.get();
 
-            final Duration deferredDuration = 
Duration.between(deferredRequests.get().getKey(), LocalDateTime.now());
+            // bounds what an unauthenticated channel can make the server 
retain, by count and by size
+            final long alreadyRetainedBytes = retainedBytes.get() == null ? 0L 
: retainedBytes.get();
+            final long incomingRequestSize = 
incomingRequestSize(ctx.channel());
 
-            if (deferredDuration.compareTo(MAX_REQUEST_DEFERRABLE_DURATION) > 
0) {
-                respondWithError(
-                        requestMessage,
-                        builder -> builder.statusMessage("Authentication did 
not finish in the allowed duration (" + MAX_REQUEST_DEFERRABLE_DURATION + "s).")
-                                    .code(ResponseStatusCode.UNAUTHORIZED),
-                        ctx);
+            final int maxDeferredRequests = 
settings.authentication.maxDeferredRequests;
+
+            final String breachMessage;
+            if (deferred != null && deferred.getValue().size() >= 
maxDeferredRequests) {
+                breachMessage = "Too many requests were deferred pending 
authentication (" + maxDeferredRequests + " maximum).";
+            } else if (alreadyRetainedBytes + incomingRequestSize > 
maxRetainedBytes) {
+                breachMessage = "Too many bytes were retained pending 
authentication (" + maxRetainedBytes + " bytes maximum).";
+            } else {
+                breachMessage = null;
+            }
+
+            if (breachMessage != null) {
+                logger.debug("Rejecting the request from {} - {}", 
ctx.channel().remoteAddress(), breachMessage);
+
+                // dropped rather than buffered for a peer that has stopped 
reading
+                if (ctx.channel().isWritable()) {
+                    ctx.writeAndFlush(ResponseMessage.build(requestMessage)
+                            
.statusMessage(breachMessage).code(ResponseStatusCode.UNAUTHORIZED).create());
+                }
+                return;
+            }
+
+            if (deferred == null) {
+                deferredRequests.set(new ImmutablePair<>(LocalDateTime.now(), 
new ArrayList<>()));
+            } else if (Duration.between(deferred.getKey(), 
LocalDateTime.now()).toMillis() > preAuthTimeout()) {
+                // answered here rather than deferred, so that one request id 
cannot be answered twice
+                respondWithError(requestMessage, this::didNotFinishInTime, 
ctx);
                 return;
             }
 
+            deferredRequests.get().getValue().add(requestMessage);
+            retainedBytes.set(alreadyRetainedBytes + incomingRequestSize);
+
             return;
         } else if (!requestMessage.getArgs().containsKey(Tokens.ARGS_SASL)) {
             // This is an authentication request that is missing a "sasl" 
argument.
@@ -169,6 +246,7 @@ public class SaslAuthenticationHandler extends 
AbstractAuthenticationHandler {
 
             final org.apache.tinkerpop.gremlin.server.auth.AuthenticatedUser 
user = negotiator.get().getAuthenticatedUser();
             ctx.channel().attr(StateKey.AUTHENTICATED_USER).set(user);
+            cancelDeadline(ctx.channel());
             // User name logged with the remote socket address and 
authenticator classname for audit logging
             if (settings.enableAuditLog) {
                 String address = ctx.channel().remoteAddress().toString();
@@ -177,16 +255,20 @@ public class SaslAuthenticationHandler extends 
AbstractAuthenticationHandler {
                 auditLogger.info("User {} with address {} authenticated by {}",
                         user.getName(), address, 
authClassParts[authClassParts.length - 1]);
             }
+            final List<RequestMessage> deferred = 
releaseDeferredRequests(ctx.channel());
+
             // If we have got here we are authenticated so remove the handler 
and pass
             // the original message down the pipeline for processing
             ctx.pipeline().remove(this);
             final RequestMessage original = request.get();
-            ctx.fireChannelRead(original);
 
-            // Also send deferred requests if there are any down the pipeline 
for processing
-            if (deferredRequests.get() != null) {
-                
deferredRequests.getAndSet(null).getValue().forEach(ctx::fireChannelRead);
+            // null when an earlier failed attempt already answered it
+            if (original != null) {
+                ctx.fireChannelRead(original);
             }
+
+            // Also send deferred requests if there are any down the pipeline 
for processing
+            deferred.forEach(ctx::fireChannelRead);
         } catch (AuthenticationException ae) {
             respondWithError(
                     requestMessage,
@@ -195,27 +277,114 @@ public class SaslAuthenticationHandler extends 
AbstractAuthenticationHandler {
         }
     }
 
-    private void respondWithError(final RequestMessage requestMessage, final 
Function<ResponseMessage.Builder, ResponseMessage.Builder> buildResponse, final 
ChannelHandlerContext ctx) {
-        final Attribute<RequestMessage> originalRequest = 
ctx.channel().attr(StateKey.REQUEST_MESSAGE);
-        final Attribute<Pair<LocalDateTime, List<RequestMessage>>> 
deferredRequests = ctx.channel().attr(StateKey.DEFERRED_REQUEST_MESSAGES);
+    /**
+     * Milliseconds a channel may stay unauthenticated.
+     */
+    private long preAuthTimeout() {
+        return settings.authentication.preAuthTimeout;
+    }
+
+    /**
+     * Schedules the task that ends an authentication that does not finish in 
{@link #preAuthTimeout()}.
+     */
+    private void armDeadline(final ChannelHandlerContext ctx) {
+        final Channel channel = ctx.channel();
+        final Attribute<ScheduledFuture<?>> deadline = 
channel.attr(StateKey.PREAUTH_DEADLINE);
 
+        // one task and one close listener per channel, however often a null 
negotiator sends us back here
+        if (deadline.get() != null) return;
+
+        // so that the set below can never orphan a live task
+        cancelDeadline(channel);
+
+        deadline.set(channel.eventLoop().schedule(() -> expireDeadline(ctx),
+                preAuthTimeout(), TimeUnit.MILLISECONDS));
+        channel.closeFuture().addListener(future -> cancelDeadline(channel));
+    }
+
+    /**
+     * Cancels the deadline, if one is armed. Only authentication succeeding 
and the channel closing get here.
+     */
+    private static void cancelDeadline(final Channel channel) {
+        final ScheduledFuture<?> deadline = 
channel.attr(StateKey.PREAUTH_DEADLINE).getAndSet(null);
+
+        if (deadline != null) deadline.cancel(false);
+    }
+
+    /**
+     * Answers whatever the channel still has pending and closes it.
+     */
+    private void expireDeadline(final ChannelHandlerContext ctx) {
+        final Channel channel = ctx.channel();
+
+        if (channel.attr(StateKey.AUTHENTICATED_USER).get() != null || 
!channel.isActive()) return;
+
+        logger.debug("Closing the channel to {} - authentication did not 
finish in {} ms",
+                channel.remoteAddress(), preAuthTimeout());
+
+        answerPendingRequests(this::didNotFinishInTime, ctx);
+
+        // unconditional, as a peer that never accepts the responses must not 
be able to hold the channel open
+        ctx.close();
+    }
+
+    private ResponseMessage.Builder didNotFinishInTime(final 
ResponseMessage.Builder builder) {
+        return builder.statusMessage("Authentication did not finish in the 
allowed duration (" + preAuthTimeout() + " ms).")
+                .code(ResponseStatusCode.UNAUTHORIZED);
+    }
+
+    /**
+     * Size of the frame the request being processed was decoded from, or zero 
when the transport recorded none.
+     */
+    private static long incomingRequestSize(final Channel channel) {
+        final Integer requestSize = channel.attr(StateKey.REQUEST_SIZE).get();
+
+        return requestSize == null ? 0L : requestSize;
+    }
+
+    /**
+     * Drops the running byte total, returning the requests the channel had 
deferred.
+     */
+    private static List<RequestMessage> releaseDeferredRequests(final Channel 
channel) {
+        final Pair<LocalDateTime, List<RequestMessage>> deferred =
+                
channel.attr(StateKey.DEFERRED_REQUEST_MESSAGES).getAndSet(null);
+        channel.attr(StateKey.DEFERRED_REQUEST_BYTES).set(0L);
+
+        return deferred == null ? Collections.emptyList() : 
deferred.getValue();
+    }
+
+    /**
+     * Answers the stashed and deferred requests, releasing both, plus {@code 
requestMessage} unless it is an
+     * authentication request.
+     */
+    private void respondWithError(final RequestMessage requestMessage, final 
Function<ResponseMessage.Builder, ResponseMessage.Builder> buildResponse, final 
ChannelHandlerContext ctx) {
         if (!requestMessage.getOp().equals(Tokens.OPS_AUTHENTICATION)) {
             
ctx.write(buildResponse.apply(ResponseMessage.build(requestMessage)).create());
         }
 
-        if (originalRequest.get() != null) {
-            
ctx.write(buildResponse.apply(ResponseMessage.build(originalRequest.get())).create());
-        }
+        answerPendingRequests(buildResponse, ctx);
+    }
+
+    /**
+     * Answers the stashed and deferred requests, releasing both.
+     */
+    private static void answerPendingRequests(final 
Function<ResponseMessage.Builder, ResponseMessage.Builder> buildResponse, final 
ChannelHandlerContext ctx) {
+        final Attribute<RequestMessage> originalRequest = 
ctx.channel().attr(StateKey.REQUEST_MESSAGE);
 
-        if (deferredRequests.get() != null) {
-            deferredRequests
-                    .getAndSet(null).getValue().stream()
-                    .map(ResponseMessage::build)
-                    .map(buildResponse)
-                    .map(ResponseMessage.Builder::create)
-                    .forEach(ctx::write);
+        // cleared as it is answered so that one request id cannot be answered 
twice
+        final RequestMessage stashedRequest = originalRequest.getAndSet(null);
+
+        if (stashedRequest != null) {
+            
ctx.write(buildResponse.apply(ResponseMessage.build(stashedRequest)).create());
         }
 
+        // this also drops the stashed request's share of the retained byte 
total
+        releaseDeferredRequests(ctx.channel()).stream()
+                .map(ResponseMessage::build)
+                .map(buildResponse)
+                .map(ResponseMessage.Builder::create)
+                .forEach(ctx::write);
+
         ctx.flush();
     }
 
diff --git 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/StateKey.java
 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/StateKey.java
index 2eb2bc8655..8a71dbaabb 100644
--- 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/StateKey.java
+++ 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/StateKey.java
@@ -25,6 +25,7 @@ import 
org.apache.tinkerpop.gremlin.server.auth.AuthenticatedUser;
 import org.apache.tinkerpop.gremlin.server.auth.Authenticator;
 import org.apache.tinkerpop.gremlin.server.op.session.Session;
 import io.netty.util.AttributeKey;
+import io.netty.util.concurrent.ScheduledFuture;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -72,4 +73,19 @@ public final class StateKey {
      * The key for the current {@link AuthenticatedUser}.
      */
     public static final AttributeKey<AuthenticatedUser> AUTHENTICATED_USER = 
AttributeKey.valueOf("authenticatedUser");
+
+    /**
+     * The key for the size in bytes of the frame the current request was 
decoded from. Not public by design.
+     */
+    static final AttributeKey<Integer> REQUEST_SIZE = 
AttributeKey.valueOf("requestSize");
+
+    /**
+     * The key for the running total of the sizes of the requests retained 
pending authentication. Not public by design.
+     */
+    static final AttributeKey<Long> DEFERRED_REQUEST_BYTES = 
AttributeKey.valueOf("deferredRequestBytes");
+
+    /**
+     * The key for the task that closes the channel if authentication does not 
complete in time. Not public by design.
+     */
+    static final AttributeKey<ScheduledFuture<?>> PREAUTH_DEADLINE = 
AttributeKey.valueOf("preAuthDeadline");
 }
diff --git 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinBinaryRequestDecoder.java
 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinBinaryRequestDecoder.java
index cd645de839..ea9a708016 100644
--- 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinBinaryRequestDecoder.java
+++ 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinBinaryRequestDecoder.java
@@ -53,6 +53,9 @@ public class WsGremlinBinaryRequestDecoder extends 
MessageToMessageDecoder<Binar
 
     @Override
     protected void decode(final ChannelHandlerContext channelHandlerContext, 
final BinaryWebSocketFrame frame, final List<Object> objects) throws Exception {
+        // recorded before anything reads from the frame as readableBytes() is 
relative to the reader index
+        
channelHandlerContext.channel().attr(StateKey.REQUEST_SIZE).set(frame.content().readableBytes());
+
         final ByteBuf messageBytes = frame.content();
         final byte len = messageBytes.readByte();
         if (len <= 0) {
diff --git 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinCloseRequestDecoder.java
 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinCloseRequestDecoder.java
index f73dab5be0..3324e09026 100644
--- 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinCloseRequestDecoder.java
+++ 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinCloseRequestDecoder.java
@@ -52,6 +52,9 @@ public class WsGremlinCloseRequestDecoder extends 
MessageToMessageDecoder<CloseW
 
     @Override
     protected void decode(final ChannelHandlerContext channelHandlerContext, 
final CloseWebSocketFrame frame, final List<Object> objects) throws Exception {
+        // recorded before anything reads from the frame as readableBytes() is 
relative to the reader index
+        
channelHandlerContext.channel().attr(StateKey.REQUEST_SIZE).set(frame.content().readableBytes());
+
         final ByteBuf messageBytes = frame.content();
         final byte len = messageBytes.readByte();
         if (len <= 0) {
diff --git 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinTextRequestDecoder.java
 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinTextRequestDecoder.java
index 79cf554dc3..3313177a5e 100644
--- 
a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinTextRequestDecoder.java
+++ 
b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinTextRequestDecoder.java
@@ -50,6 +50,9 @@ public class WsGremlinTextRequestDecoder extends 
MessageToMessageDecoder<TextWeb
 
     @Override
     protected void decode(final ChannelHandlerContext channelHandlerContext, 
final TextWebSocketFrame frame, final List<Object> objects) throws Exception {
+        // recorded before anything reads from the frame as readableBytes() is 
relative to the reader index
+        
channelHandlerContext.channel().attr(StateKey.REQUEST_SIZE).set(frame.content().readableBytes());
+
         try {
             // the default serializer must be a MessageTextSerializer instance 
to be compatible with this decoder
             final MessageTextSerializer<?> serializer = 
(MessageTextSerializer<?>) select("application/json", 
ServerSerializers.DEFAULT_TEXT_SERIALIZER);
diff --git 
a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerAuthIntegrateTest.java
 
b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerAuthIntegrateTest.java
index 2df7863c8e..513ed4aec7 100644
--- 
a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerAuthIntegrateTest.java
+++ 
b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerAuthIntegrateTest.java
@@ -26,7 +26,6 @@ import 
org.apache.tinkerpop.gremlin.driver.exception.NoHostAvailableException;
 import org.apache.tinkerpop.gremlin.driver.exception.ResponseException;
 import org.apache.tinkerpop.gremlin.driver.simple.WebSocketClient;
 import org.apache.tinkerpop.gremlin.server.auth.SimpleAuthenticator;
-import org.apache.tinkerpop.gremlin.server.handler.SaslAuthenticationHandler;
 import org.apache.tinkerpop.gremlin.structure.Property;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
 import org.apache.tinkerpop.gremlin.util.ExceptionHelper;
@@ -58,6 +57,11 @@ import static org.junit.Assert.fail;
  */
 public class GremlinServerAuthIntegrateTest extends 
AbstractGremlinServerIntegrationTest {
 
+    /**
+     * Pre-authentication deadline for the one test that waits it out, kept 
short to keep that test short.
+     */
+    private static final Duration SHORT_PRE_AUTH_TIMEOUT = 
Duration.ofSeconds(2);
+
     /**
      * Configure specific Gremlin Server settings for specific tests.
      */
@@ -83,6 +87,9 @@ public class GremlinServerAuthIntegrateTest extends 
AbstractGremlinServerIntegra
                 sslConfig.keyStorePassword = KEY_PASS;
                 settings.ssl = sslConfig;
                 break;
+            case 
"shouldFailAuthenticateWithUnAuthenticatedRequestAfterMaxDeferrableDuration":
+                authSettings.preAuthTimeout = 
SHORT_PRE_AUTH_TIMEOUT.toMillis();
+                break;
         }
 
         return settings;
@@ -177,38 +184,27 @@ public class GremlinServerAuthIntegrateTest extends 
AbstractGremlinServerIntegra
     @Test
     public void 
shouldFailAuthenticateWithUnAuthenticatedRequestAfterMaxDeferrableDuration() 
throws Exception {
         try (WebSocketClient client = 
TestClientFactory.createWebSocketClient()) {
-            // First request will initiate the authentication handshake
+            // First request will initiate the authentication handshake and be 
held
             // Subsequent requests will be deferred
-            CompletableFuture<List<ResponseMessage>> 
futureOfRequestWithinAuthDuration1  = client.submitAsync("");
-            CompletableFuture<List<ResponseMessage>> 
futureOfRequestWithinAuthDuration2  = client.submitAsync("");
-            CompletableFuture<List<ResponseMessage>> 
futureOfRequestWithinAuthDuration3  = client.submitAsync("");
-
-            // After the maximum allowed deferred request duration,
-            // any non-authenticated request will invalidate all requests with 
429 error
-            CompletableFuture<List<ResponseMessage>> 
futureOfRequestSubmittedTooLate = CompletableFuture.runAsync(() -> {
-                try {
-                    
Thread.sleep(SaslAuthenticationHandler.MAX_REQUEST_DEFERRABLE_DURATION.plus(Duration.ofSeconds(1)).toMillis());
-                } catch (InterruptedException e) {
-                    throw new RuntimeException(e);
-                }
-            }).thenCompose((__) -> {
-                try {
-                    return client.submitAsync("");
-                } catch (Exception e) {
-                    throw new RuntimeException(e);
-                }
-            });
-
-            assertEquals(2, futureOfRequestWithinAuthDuration1.get().size());
-            assertEquals(1, futureOfRequestWithinAuthDuration2.get().size());
-            assertEquals(1, futureOfRequestWithinAuthDuration3.get().size());
-            assertEquals(1, futureOfRequestSubmittedTooLate.get().size());
-
-            assertEquals(ResponseStatusCode.AUTHENTICATE, 
futureOfRequestWithinAuthDuration1.get().get(0).getStatus().getCode());
-            assertEquals(ResponseStatusCode.UNAUTHORIZED, 
futureOfRequestWithinAuthDuration1.get().get(1).getStatus().getCode());
-            assertEquals(ResponseStatusCode.UNAUTHORIZED, 
futureOfRequestWithinAuthDuration2.get().get(0).getStatus().getCode());
-            assertEquals(ResponseStatusCode.UNAUTHORIZED, 
futureOfRequestWithinAuthDuration3.get().get(0).getStatus().getCode());
-            assertEquals(ResponseStatusCode.UNAUTHORIZED, 
futureOfRequestSubmittedTooLate.get().get(0).getStatus().getCode());
+            final CompletableFuture<List<ResponseMessage>> firstRequest = 
client.submitAsync("");
+            final CompletableFuture<List<ResponseMessage>> secondRequest = 
client.submitAsync("");
+            final CompletableFuture<List<ResponseMessage>> thirdRequest = 
client.submitAsync("");
+
+            // authentication is never completed, so the server answers 
everything it held and closes the channel.
+            // waits are bounded because nothing more arrives on a closed 
channel
+            final long timeoutMillis = 
SHORT_PRE_AUTH_TIMEOUT.plus(Duration.ofSeconds(30)).toMillis();
+            final List<ResponseMessage> first = 
firstRequest.get(timeoutMillis, TimeUnit.MILLISECONDS);
+            final List<ResponseMessage> second = 
secondRequest.get(timeoutMillis, TimeUnit.MILLISECONDS);
+            final List<ResponseMessage> third = 
thirdRequest.get(timeoutMillis, TimeUnit.MILLISECONDS);
+
+            assertEquals(2, first.size());
+            assertEquals(1, second.size());
+            assertEquals(1, third.size());
+
+            assertEquals(ResponseStatusCode.AUTHENTICATE, 
first.get(0).getStatus().getCode());
+            assertEquals(ResponseStatusCode.UNAUTHORIZED, 
first.get(1).getStatus().getCode());
+            assertEquals(ResponseStatusCode.UNAUTHORIZED, 
second.get(0).getStatus().getCode());
+            assertEquals(ResponseStatusCode.UNAUTHORIZED, 
third.get(0).getStatus().getCode());
         }
     }
 
diff --git 
a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/handler/SaslAuthenticationHandlerTest.java
 
b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/handler/SaslAuthenticationHandlerTest.java
new file mode 100644
index 0000000000..ebe89b39f3
--- /dev/null
+++ 
b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/handler/SaslAuthenticationHandlerTest.java
@@ -0,0 +1,826 @@
+/*
+ * 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.
+ */
+package org.apache.tinkerpop.gremlin.server.handler;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.EventLoop;
+import io.netty.util.DefaultAttributeMap;
+import io.netty.util.concurrent.Future;
+import io.netty.util.concurrent.GenericFutureListener;
+import io.netty.util.concurrent.ScheduledFuture;
+import org.apache.commons.lang3.tuple.ImmutablePair;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.tinkerpop.gremlin.server.Settings;
+import org.apache.tinkerpop.gremlin.server.auth.AuthenticatedUser;
+import org.apache.tinkerpop.gremlin.server.auth.AuthenticationException;
+import org.apache.tinkerpop.gremlin.server.auth.Authenticator;
+import org.apache.tinkerpop.gremlin.util.Tokens;
+import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
+import org.apache.tinkerpop.gremlin.util.message.ResponseMessage;
+import org.apache.tinkerpop.gremlin.util.message.ResponseStatusCode;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class SaslAuthenticationHandlerTest {
+
+    // the size a request is given when the test does not care about the byte 
budget
+    private static final int SMALL_REQUEST_SIZE = 200;
+
+    // a real AttributeMap backs the channel attributes the handler reads and 
writes
+    private final DefaultAttributeMap attributes = new DefaultAttributeMap();
+    private final Channel channel = Mockito.mock(Channel.class);
+    private final ChannelHandlerContext ctx = 
Mockito.mock(ChannelHandlerContext.class);
+    private final Authenticator authenticator = 
Mockito.mock(Authenticator.class);
+    private final Authenticator.SaslNegotiator negotiator = 
Mockito.mock(Authenticator.SaslNegotiator.class);
+    private final Settings settings = new Settings();
+    private final SaslAuthenticationHandler handler =
+            new SaslAuthenticationHandler(authenticator, null, settings);
+
+    // the limits the handler reads for this channel, left at their defaults 
unless a test sets one
+    private final long budget = 
settings.authentication.maxPreAuthRetainedBytes;
+    private final int maxDeferred = 
settings.authentication.maxDeferredRequests;
+    private final long preAuthTimeout = settings.authentication.preAuthTimeout;
+
+    private final EventLoop eventLoop = Mockito.mock(EventLoop.class);
+    private final ChannelFuture closeFuture = 
Mockito.mock(ChannelFuture.class);
+    private final ChannelFuture writeFuture = 
Mockito.mock(ChannelFuture.class);
+    private final ScheduledFuture<?> deadlineFuture = 
Mockito.mock(ScheduledFuture.class);
+
+    // what the handler handed to the event loop and to the close future, so 
that the test can run them
+    private final List<Runnable> scheduledTasks = new ArrayList<>();
+    private final List<GenericFutureListener<Future<? super Void>>> 
closeListeners = new ArrayList<>();
+
+    @Before
+    public void setupForEachTest() {
+        Mockito.when(channel.remoteAddress()).thenReturn(new 
InetSocketAddress("127.0.0.1", 45678));
+        Mockito.doAnswer(invocation -> 
attributes.attr(invocation.getArgument(0))).when(channel).attr(Mockito.any());
+        Mockito.when(channel.isActive()).thenReturn(true);
+        Mockito.when(channel.isWritable()).thenReturn(true);
+        Mockito.when(channel.eventLoop()).thenReturn(eventLoop);
+        Mockito.when(channel.closeFuture()).thenReturn(closeFuture);
+        Mockito.doAnswer(invocation -> {
+            scheduledTasks.add(invocation.getArgument(0));
+            return deadlineFuture;
+        }).when(eventLoop).schedule(Mockito.any(Runnable.class), 
Mockito.anyLong(), Mockito.any(TimeUnit.class));
+        Mockito.doAnswer(invocation -> {
+            closeListeners.add(invocation.getArgument(0));
+            return closeFuture;
+        }).when(closeFuture).addListener(Mockito.any());
+        Mockito.when(ctx.channel()).thenReturn(channel);
+        Mockito.when(ctx.write(Mockito.any())).thenReturn(writeFuture);
+        Mockito.when(ctx.writeAndFlush(Mockito.any())).thenReturn(writeFuture);
+        
Mockito.when(authenticator.newSaslNegotiator(Mockito.any(InetAddress.class))).thenReturn(negotiator);
+    }
+
+    @Test
+    public void shouldErrorOnlyTheRequestThatBreachedTheCountCap() throws 
Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+
+        RequestMessage breaching = null;
+        for (int ix = 0; ix < maxDeferred + 1; ix++) {
+            breaching = deferRequest(SMALL_REQUEST_SIZE);
+        }
+
+        // the AUTHENTICATE challenge is followed by one error, for the 
breaching request
+        final List<ResponseMessage> flushed = flushedResponses();
+        assertEquals(2, flushed.size());
+        assertEquals(ResponseStatusCode.AUTHENTICATE, 
flushed.get(0).getStatus().getCode());
+
+        final ResponseMessage error = flushed.get(1);
+        assertEquals(breaching.getRequestId(), error.getRequestId());
+        assertEquals(ResponseStatusCode.UNAUTHORIZED, 
error.getStatus().getCode());
+        assertTrue(error.getStatus().getMessage().contains(maxDeferred + " 
maximum"));
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+
+        // the list stays at the cap and the breaching request was never 
appended
+        assertEquals(maxDeferred, deferredRequests().getValue().size());
+        assertFalse(deferredRequests().getValue().contains(breaching));
+        assertEquals((maxDeferred + 1) * (long) SMALL_REQUEST_SIZE, 
retainedBytes());
+
+        // the request that started authentication is still stashed for 
release once authentication completes
+        assertSame(stashed, attributes.attr(StateKey.REQUEST_MESSAGE).get());
+
+        // the channel is left open so authentication can still complete, 
leaving the deadline as all that ends it
+        Mockito.verify(ctx, Mockito.never()).close();
+        assertDeadlineStillArmed();
+    }
+
+    @Test
+    public void shouldErrorOnlyTheRequestThatBreachedTheByteBudget() throws 
Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+
+        // two requests fill what is left of the budget and are far short of 
the cap on the number of requests
+        final int half = (int) (budget - SMALL_REQUEST_SIZE) / 2;
+        deferRequest(half);
+        deferRequest(half);
+        assertEquals(budget, retainedBytes());
+
+        // the check includes the incoming request, otherwise one request 
could pass the budget
+        final RequestMessage breaching = deferRequest(1);
+
+        final List<ResponseMessage> flushed = flushedResponses();
+        assertEquals(2, flushed.size());
+        final ResponseMessage error = flushed.get(1);
+        assertEquals(breaching.getRequestId(), error.getRequestId());
+        assertEquals(ResponseStatusCode.UNAUTHORIZED, 
error.getStatus().getCode());
+        assertTrue(error.getStatus().getMessage().contains(budget + " bytes 
maximum"));
+
+        // only the breaching request was rejected and the channel is left open
+        assertEquals(2, deferredRequests().getValue().size());
+        assertEquals(budget, retainedBytes());
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+        Mockito.verify(ctx, Mockito.never()).close();
+
+        // refusing a request must not disarm the deadline, or breaching the 
budget would hold the channel open
+        assertDeadlineStillArmed();
+    }
+
+    @Test
+    public void shouldChargeTheStashedRequestToTheByteBudget() throws 
Exception {
+        final int stashSize = (int) budget - 100;
+        startAuthentication(stashSize);
+
+        // the request held in StateKey.REQUEST_MESSAGE counts from the moment 
it is stashed
+        assertEquals(stashSize, retainedBytes());
+
+        // so only the remainder of the budget is left to defer into
+        final RequestMessage tooLarge = deferRequest(101);
+        assertNull(deferredRequests());
+
+        final List<ResponseMessage> flushed = flushedResponses();
+        assertEquals(2, flushed.size());
+        assertEquals(tooLarge.getRequestId(), flushed.get(1).getRequestId());
+        assertEquals(ResponseStatusCode.UNAUTHORIZED, 
flushed.get(1).getStatus().getCode());
+
+        final RequestMessage fits = deferRequest(100);
+        assertEquals(1, deferredRequests().getValue().size());
+        assertSame(fits, deferredRequests().getValue().get(0));
+        assertEquals(budget, retainedBytes());
+        Mockito.verify(ctx, Mockito.never()).close();
+    }
+
+    @Test
+    public void 
shouldChallengeButRetainNothingForAFirstRequestTooLargeToStash() throws 
Exception {
+        final RequestMessage tooLarge = 
startAuthenticationWithOversizedRequest();
+
+        // authentication still starts, so the challenge goes out first and 
the request is answered after it
+        final List<ResponseMessage> flushed = flushedResponses();
+        assertEquals(2, flushed.size());
+        assertEquals(tooLarge.getRequestId(), flushed.get(0).getRequestId());
+        assertEquals(ResponseStatusCode.AUTHENTICATE, 
flushed.get(0).getStatus().getCode());
+        assertEquals(tooLarge.getRequestId(), flushed.get(1).getRequestId());
+        assertEquals(ResponseStatusCode.UNAUTHORIZED, 
flushed.get(1).getStatus().getCode());
+        assertTrue(flushed.get(1).getStatus().getMessage().contains("too 
large"));
+
+        // it was answered rather than stashed, and nothing was retained for 
the channel either
+        assertNull(attributes.attr(StateKey.REQUEST_MESSAGE).get());
+        assertNull(deferredRequests());
+        assertEquals(0L, retainedBytes());
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+        Mockito.verify(ctx, Mockito.never()).close();
+    }
+
+    @Test
+    public void 
shouldDeferANormalSizedRequestAfterAFirstRequestTooLargeToStashWasRejected() 
throws Exception {
+        startAuthenticationWithOversizedRequest();
+
+        final RequestMessage deferred = deferRequest(SMALL_REQUEST_SIZE);
+
+        // only the deferred request is charged, as the rejected one was never 
counted
+        assertEquals(1, deferredRequests().getValue().size());
+        assertSame(deferred, deferredRequests().getValue().get(0));
+        assertEquals(SMALL_REQUEST_SIZE, retainedBytes());
+
+        completeAuthentication();
+
+        final List<Object> fired = firedReads();
+        assertEquals(1, fired.size());
+        assertSame(deferred, fired.get(0));
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+        assertNothingLeftToAnswer();
+    }
+
+    @Test
+    public void shouldAnswerTheStashedRequestExactlyOnceAcrossRepeatedErrors() 
throws Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        final RequestMessage deferred = deferRequest(SMALL_REQUEST_SIZE);
+
+        failAuthentication();
+
+        final List<ResponseMessage> errors = writtenResponses();
+        assertEquals(2, errors.size());
+        assertEquals(stashed.getRequestId(), errors.get(0).getRequestId());
+        assertEquals(deferred.getRequestId(), errors.get(1).getRequestId());
+        errors.forEach(error -> assertEquals(ResponseStatusCode.UNAUTHORIZED, 
error.getStatus().getCode()));
+        assertNull("the stashed request must be cleared as it is answered",
+                attributes.attr(StateKey.REQUEST_MESSAGE).get());
+        assertNothingLeftToAnswer();
+
+        // an authentication request with no sasl argument takes 
respondWithError again, with nothing left to answer
+        handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_AUTHENTICATION).create());
+
+        assertEquals(2, writtenResponses().size());
+        assertEquals(1L, answersFor(stashed));
+        Mockito.verify(ctx, Mockito.never()).close();
+    }
+
+    @Test
+    public void 
shouldNotAnswerTheStashedRequestAgainWhenAuthenticationLaterSucceeds() throws 
Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        deferRequest(SMALL_REQUEST_SIZE);
+
+        failAuthentication();
+        assertNull(attributes.attr(StateKey.REQUEST_MESSAGE).get());
+
+        completeAuthentication();
+
+        // the failure already answered it, so nothing more goes out for it 
and nothing null goes down the pipeline
+        assertEquals(1L, answersFor(stashed));
+        assertTrue(firedReads().isEmpty());
+        Mockito.verify(ctx, Mockito.never()).fireChannelRead(null);
+        assertNothingLeftToAnswer();
+    }
+
+    @Test
+    public void shouldRetainNothingOnceAuthenticationSucceeds() throws 
Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        final RequestMessage deferred = deferRequest(SMALL_REQUEST_SIZE);
+
+        completeAuthentication();
+
+        final List<Object> fired = firedReads();
+        assertEquals(2, fired.size());
+        assertSame(stashed, fired.get(0));
+        assertSame(deferred, fired.get(1));
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+        assertNull(deferredRequests());
+        assertEquals(0L, retainedBytes());
+    }
+
+    @Test
+    public void shouldRetainNothingWhenTheSaslArgumentIsMissing() throws 
Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        final RequestMessage deferred = deferRequest(SMALL_REQUEST_SIZE);
+
+        handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_AUTHENTICATION).create());
+
+        // the authentication request itself is not answered, the stashed and 
deferred ones are
+        final List<ResponseMessage> errors = writtenResponses();
+        assertEquals(2, errors.size());
+        assertEquals(stashed.getRequestId(), errors.get(0).getRequestId());
+        assertEquals(deferred.getRequestId(), errors.get(1).getRequestId());
+        errors.forEach(error -> assertEquals(ResponseStatusCode.UNAUTHORIZED, 
error.getStatus().getCode()));
+        assertNothingLeftToAnswer();
+    }
+
+    @Test
+    public void shouldRetainNothingWhenTheSaslArgumentIsNotAString() throws 
Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        final RequestMessage deferred = deferRequest(SMALL_REQUEST_SIZE);
+
+        handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_AUTHENTICATION)
+                .addArg(Tokens.ARGS_SASL, 1234).create());
+
+        final List<ResponseMessage> errors = writtenResponses();
+        assertEquals(2, errors.size());
+        assertEquals(stashed.getRequestId(), errors.get(0).getRequestId());
+        assertEquals(deferred.getRequestId(), errors.get(1).getRequestId());
+        errors.forEach(error ->
+                
assertEquals(ResponseStatusCode.REQUEST_ERROR_MALFORMED_REQUEST, 
error.getStatus().getCode()));
+        assertNothingLeftToAnswer();
+    }
+
+    @Test
+    public void 
shouldAnswerRatherThanDeferARequestOnceTheDeferrableDurationHasPassed() throws 
Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        final RequestMessage deferred = deferRequest(SMALL_REQUEST_SIZE);
+
+        // the deferral window opened longer ago than the allowed duration, 
which the next arrival notices
+        attributes.attr(StateKey.DEFERRED_REQUEST_MESSAGES).set(new 
ImmutablePair<>(
+                
LocalDateTime.now().minus(Duration.ofMillis(preAuthTimeout)).minusSeconds(1),
+                deferredRequests().getValue()));
+
+        final RequestMessage late = deferRequest(SMALL_REQUEST_SIZE);
+
+        // the late request is answered as an arrival, never appended, so no 
request id is answered twice
+        final List<ResponseMessage> errors = writtenResponses();
+        assertEquals(3, errors.size());
+        assertEquals(late.getRequestId(), errors.get(0).getRequestId());
+        assertEquals(stashed.getRequestId(), errors.get(1).getRequestId());
+        assertEquals(deferred.getRequestId(), errors.get(2).getRequestId());
+        errors.forEach(error -> assertEquals(ResponseStatusCode.UNAUTHORIZED, 
error.getStatus().getCode()));
+        assertEquals(1L, answersFor(late));
+        assertEquals(1L, answersFor(stashed));
+        assertEquals(1L, answersFor(deferred));
+        assertNothingLeftToAnswer();
+
+        // the stale window answers rather than closes, so the deadline is 
still what ends the channel
+        Mockito.verify(ctx, Mockito.never()).close();
+        assertDeadlineStillArmed();
+    }
+
+    @Test
+    public void shouldArmNothingWhenTheAuthenticatorIsNotReady() throws 
Exception {
+        
Mockito.when(authenticator.newSaslNegotiator(Mockito.any(InetAddress.class)))
+                .thenThrow(new IllegalStateException("not ready"));
+
+        final RequestMessage first = 
RequestMessage.build(Tokens.OPS_EVAL).create();
+        attributes.attr(StateKey.REQUEST_SIZE).set(SMALL_REQUEST_SIZE);
+        handler.channelRead(ctx, first);
+
+        final List<ResponseMessage> errors = writtenResponses();
+        assertEquals(1, errors.size());
+        assertEquals(first.getRequestId(), errors.get(0).getRequestId());
+        assertEquals(ResponseStatusCode.SERVER_ERROR, 
errors.get(0).getStatus().getCode());
+
+        // no negotiator means nothing is retained, so no deadline is left 
running either
+        assertNull(attributes.attr(StateKey.NEGOTIATOR).get());
+        assertNull(deadline());
+        assertTrue(scheduledTasks.isEmpty());
+        assertNothingLeftToAnswer();
+        Mockito.verify(ctx, Mockito.never()).close();
+    }
+
+    @Test
+    public void shouldCountNothingForARequestTheTransportDidNotSize() throws 
Exception {
+        // StateKey.REQUEST_SIZE is absent on transports that do not record it
+        assertNull(attributes.attr(StateKey.REQUEST_SIZE).get());
+
+        final RequestMessage first = 
RequestMessage.build(Tokens.OPS_AUTHENTICATION).create();
+        handler.channelRead(ctx, first);
+
+        assertSame(first, attributes.attr(StateKey.REQUEST_MESSAGE).get());
+        assertEquals(0L, retainedBytes());
+
+        handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_EVAL).create());
+
+        // the request is still deferred, and still bounded by the cap on the 
number of them
+        assertEquals(1, deferredRequests().getValue().size());
+        assertEquals(0L, retainedBytes());
+        assertEquals(1, flushedResponses().size());
+    }
+
+    @Test
+    public void shouldArmTheDeadlineWhenTheNegotiatorIsCreated() throws 
Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+
+        // retention begins with the stashed request, before anything is 
deferred, so the deadline is already armed
+        assertSame(deadlineFuture, deadline());
+        assertNull(deferredRequests());
+        assertEquals(1, scheduledTasks.size());
+        Mockito.verify(eventLoop).schedule(Mockito.any(Runnable.class),
+                Mockito.eq(preAuthTimeout), Mockito.eq(TimeUnit.MILLISECONDS));
+    }
+
+    @Test
+    public void 
shouldNotArmTheDeadlineAgainWhenTheDeferredRequestsAreRefilled() throws 
Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+        deferRequest(SMALL_REQUEST_SIZE);
+
+        // releases the deferred requests, so the next arrival opens a fresh 
window on the same channel
+        failAuthentication();
+        assertNull(deferredRequests());
+
+        deferRequest(SMALL_REQUEST_SIZE);
+
+        assertEquals(1, deferredRequests().getValue().size());
+        assertEquals(1, scheduledTasks.size());
+        assertEquals(1, closeListeners.size());
+        assertDeadlineStillArmed();
+    }
+
+    @Test
+    public void shouldArmTheDeadlineOnceWhenTheNegotiatorKeepsComingBackNull() 
throws Exception {
+        
Mockito.when(authenticator.newSaslNegotiator(Mockito.any(InetAddress.class))).thenReturn(null);
+
+        for (int ix = 0; ix < 3; ix++) {
+            attributes.attr(StateKey.REQUEST_SIZE).set(SMALL_REQUEST_SIZE);
+            handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_AUTHENTICATION).create());
+        }
+
+        // the branch that creates the negotiator runs again for each request, 
and must arm nothing more
+        assertNull(attributes.attr(StateKey.NEGOTIATOR).get());
+        assertEquals(3, flushedResponses().size());
+        assertEquals(1, scheduledTasks.size());
+        assertEquals(1, closeListeners.size());
+        assertDeadlineStillArmed();
+    }
+
+    /**
+     * The paths that refuse a request rather than fail an authentication one 
keep the deadline armed in
+     * {@link #shouldErrorOnlyTheRequestThatBreachedTheCountCap},
+     * {@link #shouldErrorOnlyTheRequestThatBreachedTheByteBudget} and
+     * {@link 
#shouldAnswerRatherThanDeferARequestOnceTheDeferrableDurationHasPassed}.
+     */
+    @Test
+    public void 
shouldKeepTheDeadlineArmedThroughEveryFailedAuthenticationRequest() throws 
Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+        deferRequest(SMALL_REQUEST_SIZE);
+
+        // an authentication request with no sasl argument
+        handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_AUTHENTICATION).create());
+        assertEquals(2, writtenResponses().size());
+        assertDeadlineStillArmed();
+
+        // an authentication request whose sasl argument is not a String
+        deferRequest(SMALL_REQUEST_SIZE);
+        handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_AUTHENTICATION)
+                .addArg(Tokens.ARGS_SASL, 1234).create());
+        assertEquals(3, writtenResponses().size());
+        assertDeadlineStillArmed();
+
+        // an authentication request the negotiator rejects
+        deferRequest(SMALL_REQUEST_SIZE);
+        failAuthentication();
+        assertEquals(4, writtenResponses().size());
+        assertDeadlineStillArmed();
+
+        // none of them closed the channel either, so the deadline is all that 
ends it
+        Mockito.verify(ctx, Mockito.never()).close();
+    }
+
+    @Test
+    public void shouldCancelTheDeadlineWhenAuthenticationSucceeds() throws 
Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+
+        completeAuthentication();
+
+        assertNull(deadline());
+        Mockito.verify(deadlineFuture).cancel(false);
+    }
+
+    @Test
+    public void shouldCancelTheDeadlineWhenTheChannelCloses() throws Exception 
{
+        startAuthentication(SMALL_REQUEST_SIZE);
+        deferRequest(SMALL_REQUEST_SIZE);
+
+        closeChannel();
+
+        assertNull(deadline());
+        Mockito.verify(deadlineFuture).cancel(false);
+    }
+
+    @Test
+    public void 
shouldAnswerEachPendingRequestExactlyOnceAndCloseWhenTheDeadlineExpires() 
throws Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        final RequestMessage first = deferRequest(SMALL_REQUEST_SIZE);
+        final RequestMessage second = deferRequest(SMALL_REQUEST_SIZE);
+
+        runDeadline();
+
+        // closed as soon as the responses are flushed, never gated on a write 
completing
+        Mockito.verify(ctx).flush();
+        Mockito.verify(ctx).close();
+        Mockito.verify(writeFuture, 
Mockito.never()).addListener(Mockito.any());
+
+        final List<ResponseMessage> errors = writtenResponses();
+        assertEquals(3, errors.size());
+        assertEquals(stashed.getRequestId(), errors.get(0).getRequestId());
+        assertEquals(first.getRequestId(), errors.get(1).getRequestId());
+        assertEquals(second.getRequestId(), errors.get(2).getRequestId());
+        errors.forEach(error -> assertEquals(ResponseStatusCode.UNAUTHORIZED, 
error.getStatus().getCode()));
+        assertEquals(1L, answersFor(stashed));
+        assertEquals(1L, answersFor(first));
+        assertEquals(1L, answersFor(second));
+        assertNothingLeftToAnswer();
+        assertTrue(firedReads().isEmpty());
+    }
+
+    @Test
+    public void 
shouldNotAnswerARequestAnErrorAlreadyAnsweredWhenTheDeadlineExpires() throws 
Exception {
+        final RequestMessage stashed = startAuthentication(SMALL_REQUEST_SIZE);
+        final RequestMessage deferred = deferRequest(SMALL_REQUEST_SIZE);
+
+        failAuthentication();
+        assertEquals(2, writtenResponses().size());
+
+        runDeadline();
+
+        // both were answered and cleared by the failure, so the deadline has 
nothing left to answer
+        assertEquals(2, writtenResponses().size());
+        assertEquals(1L, answersFor(stashed));
+        assertEquals(1L, answersFor(deferred));
+
+        // the channel is still unauthenticated, so it is still closed
+        Mockito.verify(ctx).close();
+    }
+
+    @Test
+    public void shouldNotCloseAnAuthenticatedChannelWhenTheDeadlineExpires() 
throws Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+        completeAuthentication();
+
+        // a task already running when the cancel came in still has to find 
the channel authenticated and stop
+        runDeadline();
+
+        Mockito.verify(ctx, Mockito.never()).close();
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+    }
+
+    @Test
+    public void shouldNotCloseAnInactiveChannelWhenTheDeadlineExpires() throws 
Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+        deferRequest(SMALL_REQUEST_SIZE);
+        Mockito.when(channel.isActive()).thenReturn(false);
+
+        runDeadline();
+
+        Mockito.verify(ctx, Mockito.never()).close();
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+    }
+
+    @Test
+    public void shouldSkipTheRejectionWriteWhenTheChannelIsNotWritable() 
throws Exception {
+        startAuthentication(SMALL_REQUEST_SIZE);
+
+        for (int ix = 0; ix < maxDeferred; ix++) {
+            deferRequest(SMALL_REQUEST_SIZE);
+        }
+
+        Mockito.when(channel.isWritable()).thenReturn(false);
+        final RequestMessage breaching = deferRequest(SMALL_REQUEST_SIZE);
+
+        // the rejection is dropped rather than buffered, so the AUTHENTICATE 
challenge is all that went out
+        assertEquals(1, flushedResponses().size());
+        assertEquals(ResponseStatusCode.AUTHENTICATE, 
flushedResponses().get(0).getStatus().getCode());
+        Mockito.verify(ctx, Mockito.never()).write(Mockito.any());
+
+        // the request is still refused rather than deferred
+        assertEquals(maxDeferred, deferredRequests().getValue().size());
+        assertFalse(deferredRequests().getValue().contains(breaching));
+        assertEquals((maxDeferred + 1) * (long) SMALL_REQUEST_SIZE, 
retainedBytes());
+        Mockito.verify(ctx, Mockito.never()).close();
+    }
+
+    @Test
+    public void shouldCapTheDeferredRequestsAtTheConfiguredCount() throws 
Exception {
+        settings.authentication.maxDeferredRequests = 2;
+
+        startAuthentication(SMALL_REQUEST_SIZE);
+        deferRequest(SMALL_REQUEST_SIZE);
+        deferRequest(SMALL_REQUEST_SIZE);
+
+        // the default of 64 would have deferred this one
+        final RequestMessage breaching = deferRequest(SMALL_REQUEST_SIZE);
+
+        final List<ResponseMessage> flushed = flushedResponses();
+        assertEquals(2, flushed.size());
+        assertEquals(breaching.getRequestId(), flushed.get(1).getRequestId());
+        assertEquals(ResponseStatusCode.UNAUTHORIZED, 
flushed.get(1).getStatus().getCode());
+        assertTrue(flushed.get(1).getStatus().getMessage().contains("2 
maximum"));
+        assertEquals(2, deferredRequests().getValue().size());
+        assertFalse(deferredRequests().getValue().contains(breaching));
+    }
+
+    @Test
+    public void shouldBudgetTheRetainedBytesAtTheConfiguredTotal() throws 
Exception {
+        settings.authentication.maxPreAuthRetainedBytes = 500L;
+
+        startAuthentication(400);
+
+        // the default budget of 2 MiB would have deferred this one
+        final RequestMessage breaching = deferRequest(101);
+
+        final List<ResponseMessage> flushed = flushedResponses();
+        assertEquals(2, flushed.size());
+        assertEquals(breaching.getRequestId(), flushed.get(1).getRequestId());
+        assertEquals(ResponseStatusCode.UNAUTHORIZED, 
flushed.get(1).getStatus().getCode());
+        assertTrue(flushed.get(1).getStatus().getMessage().contains("500 bytes 
maximum"));
+        assertNull(deferredRequests());
+
+        // what is left of the smaller budget is still deferrable
+        final RequestMessage fits = deferRequest(100);
+        assertSame(fits, deferredRequests().getValue().get(0));
+        assertEquals(500L, retainedBytes());
+    }
+
+    @Test
+    public void shouldArmTheDeadlineWithTheConfiguredTimeout() throws 
Exception {
+        settings.authentication.preAuthTimeout = 1234L;
+
+        startAuthentication(SMALL_REQUEST_SIZE);
+
+        Mockito.verify(eventLoop).schedule(Mockito.any(Runnable.class),
+                Mockito.eq(1234L), Mockito.eq(TimeUnit.MILLISECONDS));
+
+        runDeadline();
+
+        assertEquals(1, writtenResponses().size());
+        
assertTrue(writtenResponses().get(0).getStatus().getMessage().contains("1234 
ms"));
+    }
+
+    @Test
+    public void shouldRefuseAMaxDeferredRequestsThatIsNotPositive() {
+        assertRejectedAtConstruction(s -> s.authentication.maxDeferredRequests 
= 0,
+                "authentication.maxDeferredRequests");
+        assertRejectedAtConstruction(s -> s.authentication.maxDeferredRequests 
= -1,
+                "authentication.maxDeferredRequests");
+    }
+
+    @Test
+    public void shouldRefuseAMaxPreAuthRetainedBytesThatIsNotPositive() {
+        assertRejectedAtConstruction(s -> 
s.authentication.maxPreAuthRetainedBytes = 0L,
+                "authentication.maxPreAuthRetainedBytes");
+        assertRejectedAtConstruction(s -> 
s.authentication.maxPreAuthRetainedBytes = -1L,
+                "authentication.maxPreAuthRetainedBytes");
+    }
+
+    @Test
+    public void shouldRefuseAPreAuthTimeoutThatIsNotPositive() {
+        assertRejectedAtConstruction(s -> s.authentication.preAuthTimeout = 0L,
+                "authentication.preAuthTimeout");
+        assertRejectedAtConstruction(s -> s.authentication.preAuthTimeout = 
-1L,
+                "authentication.preAuthTimeout");
+    }
+
+    /**
+     * Asserts that the handler cannot be constructed once {@code 
invalidSetting} has been applied, so that the server
+     * fails to start rather than a request.
+     */
+    private void assertRejectedAtConstruction(final Consumer<Settings> 
invalidSetting, final String settingName) {
+        final Settings invalid = new Settings();
+        invalidSetting.accept(invalid);
+
+        try {
+            new SaslAuthenticationHandler(authenticator, null, invalid);
+            fail("a value that is not positive for " + settingName + " must 
not be accepted");
+        } catch (IllegalStateException ex) {
+            assertTrue(ex.getMessage(), ex.getMessage().contains(settingName + 
" must be greater than zero"));
+        }
+    }
+
+    /**
+     * Sends a first request so that later non-authentication requests take 
the deferral path, returning the request
+     * the handler stashes in {@link StateKey#REQUEST_MESSAGE}.
+     */
+    private RequestMessage startAuthentication(final int requestSize) throws 
Exception {
+        final RequestMessage first = 
RequestMessage.build(Tokens.OPS_AUTHENTICATION).create();
+        attributes.attr(StateKey.REQUEST_SIZE).set(requestSize);
+        handler.channelRead(ctx, first);
+
+        assertNotNull(attributes.attr(StateKey.NEGOTIATOR).get());
+
+        return first;
+    }
+
+    /**
+     * Sends a first request too large to be retained, which still starts 
authentication.
+     */
+    private RequestMessage startAuthenticationWithOversizedRequest() throws 
Exception {
+        final RequestMessage first = 
RequestMessage.build(Tokens.OPS_EVAL).create();
+        attributes.attr(StateKey.REQUEST_SIZE).set((int) budget + 1);
+        handler.channelRead(ctx, first);
+
+        assertNotNull(attributes.attr(StateKey.NEGOTIATOR).get());
+
+        return first;
+    }
+
+    /**
+     * Sends a request that takes the deferral path, sized as the decoders 
size it.
+     */
+    private RequestMessage deferRequest(final int requestSize) throws 
Exception {
+        final RequestMessage request = 
RequestMessage.build(Tokens.OPS_EVAL).create();
+        attributes.attr(StateKey.REQUEST_SIZE).set(requestSize);
+        handler.channelRead(ctx, request);
+
+        return request;
+    }
+
+    /**
+     * Sends a SASL response that the negotiator accepts, releasing the 
deferred requests down the pipeline.
+     */
+    private void completeAuthentication() throws Exception {
+        // doReturn() rather than when() so that it can follow 
failAuthentication() in the one test
+        
Mockito.when(ctx.pipeline()).thenReturn(Mockito.mock(ChannelPipeline.class));
+        Mockito.doReturn(new 
byte[0]).when(negotiator).evaluateResponse(Mockito.any());
+        Mockito.doReturn(true).when(negotiator).isComplete();
+        Mockito.doReturn(new 
AuthenticatedUser("stephen")).when(negotiator).getAuthenticatedUser();
+
+        sendSaslResponse();
+    }
+
+    /**
+     * Sends a SASL response that the negotiator rejects.
+     */
+    private void failAuthentication() throws Exception {
+        Mockito.doThrow(new AuthenticationException("Username and/or password 
are incorrect"))
+                .when(negotiator).evaluateResponse(Mockito.any());
+
+        sendSaslResponse();
+    }
+
+    private void sendSaslResponse() throws Exception {
+        attributes.attr(StateKey.REQUEST_SIZE).set(SMALL_REQUEST_SIZE);
+        handler.channelRead(ctx, 
RequestMessage.build(Tokens.OPS_AUTHENTICATION)
+                .addArg(Tokens.ARGS_SASL, 
Base64.getEncoder().encodeToString(new byte[0])).create());
+    }
+
+    private void assertNothingLeftToAnswer() {
+        assertNull(attributes.attr(StateKey.REQUEST_MESSAGE).get());
+        assertNull(deferredRequests());
+        assertEquals(0L, retainedBytes());
+    }
+
+    private void assertDeadlineStillArmed() {
+        assertSame("the deadline must still be armed", deadlineFuture, 
deadline());
+        Mockito.verify(deadlineFuture, 
Mockito.never()).cancel(Mockito.anyBoolean());
+    }
+
+    /**
+     * Runs the task the handler scheduled, failing rather than doing nothing 
when there is not exactly one of them.
+     */
+    private void runDeadline() {
+        assertEquals("exactly one deadline task must have been scheduled", 1, 
scheduledTasks.size());
+        scheduledTasks.get(0).run();
+    }
+
+    /**
+     * Fires the listener the handler registered on the close future, failing 
when there is not exactly one of them.
+     */
+    private void closeChannel() throws Exception {
+        assertEquals("exactly one close listener must have been registered", 
1, closeListeners.size());
+        closeListeners.get(0).operationComplete(closeFuture);
+    }
+
+    private ScheduledFuture<?> deadline() {
+        return attributes.attr(StateKey.PREAUTH_DEADLINE).get();
+    }
+
+    /**
+     * Number of terminal responses written for {@code request}. The 
AUTHENTICATE challenge is not one of them and
+     * leaves through {@code writeAndFlush()} rather than {@code write()}.
+     */
+    private long answersFor(final RequestMessage request) {
+        return writtenResponses().stream()
+                .filter(response -> 
request.getRequestId().equals(response.getRequestId()))
+                .count();
+    }
+
+    private Pair<LocalDateTime, List<RequestMessage>> deferredRequests() {
+        return attributes.attr(StateKey.DEFERRED_REQUEST_MESSAGES).get();
+    }
+
+    private long retainedBytes() {
+        final Long bytes = 
attributes.attr(StateKey.DEFERRED_REQUEST_BYTES).get();
+        return bytes == null ? 0L : bytes;
+    }
+
+    private List<ResponseMessage> flushedResponses() {
+        final ArgumentCaptor<Object> captor = 
ArgumentCaptor.forClass(Object.class);
+        Mockito.verify(ctx, 
Mockito.atLeast(0)).writeAndFlush(captor.capture());
+        return 
captor.getAllValues().stream().map(ResponseMessage.class::cast).collect(Collectors.toList());
+    }
+
+    private List<ResponseMessage> writtenResponses() {
+        final ArgumentCaptor<Object> captor = 
ArgumentCaptor.forClass(Object.class);
+        Mockito.verify(ctx, Mockito.atLeast(0)).write(captor.capture());
+        return 
captor.getAllValues().stream().map(ResponseMessage.class::cast).collect(Collectors.toList());
+    }
+
+    private List<Object> firedReads() {
+        final ArgumentCaptor<Object> captor = 
ArgumentCaptor.forClass(Object.class);
+        Mockito.verify(ctx, 
Mockito.atLeast(0)).fireChannelRead(captor.capture());
+        return captor.getAllValues();
+    }
+}
diff --git 
a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinRequestDecoderTest.java
 
b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinRequestDecoderTest.java
new file mode 100644
index 0000000000..bf62c1d697
--- /dev/null
+++ 
b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/handler/WsGremlinRequestDecoderTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.
+ */
+package org.apache.tinkerpop.gremlin.server.handler;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.UnpooledByteBufAllocator;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
+import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
+import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
+import org.apache.tinkerpop.gremlin.util.MessageSerializer;
+import org.apache.tinkerpop.gremlin.util.Tokens;
+import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
+import org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV1;
+import org.apache.tinkerpop.gremlin.util.ser.GraphSONMessageSerializerV3;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Covers the frame size the WebSocket decoders record for {@link 
StateKey#REQUEST_SIZE}.
+ */
+public class WsGremlinRequestDecoderTest {
+
+    private final GraphBinaryMessageSerializerV1 binarySerializer = new 
GraphBinaryMessageSerializerV1();
+    private final GraphSONMessageSerializerV3 textSerializer = new 
GraphSONMessageSerializerV3();
+
+    @Test
+    public void shouldRecordTheFullFrameSizeOfABinaryFrame() throws Exception {
+        final RequestMessage request = RequestMessage.build(Tokens.OPS_EVAL)
+                .addArg(Tokens.ARGS_GREMLIN, "g.V()").create();
+        final ByteBuf content = 
binarySerializer.serializeRequestAsBinary(request, 
UnpooledByteBufAllocator.DEFAULT);
+        final int frameSize = content.readableBytes();
+
+        final EmbeddedChannel channel = new EmbeddedChannel(new 
WsGremlinBinaryRequestDecoder(serializers(binarySerializer)));
+        try {
+            channel.writeInbound(new BinaryWebSocketFrame(content));
+
+            assertEquals(request.getRequestId(), ((RequestMessage) 
channel.readInbound()).getRequestId());
+            assertEquals(frameSize, requestSize(channel));
+
+            // not the remainder that is left once the mime type length byte 
has been read
+            assertTrue(frameSize > 1);
+            assertNotEquals(frameSize - 1, requestSize(channel));
+        } finally {
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    @Test
+    public void shouldRecordTheFullFrameSizeOfACloseFrame() throws Exception {
+        final RequestMessage request = 
RequestMessage.build(Tokens.OPS_CLOSE).create();
+        final ByteBuf content = 
binarySerializer.serializeRequestAsBinary(request, 
UnpooledByteBufAllocator.DEFAULT);
+        final int frameSize = content.readableBytes();
+
+        final EmbeddedChannel channel = new EmbeddedChannel(new 
WsGremlinCloseRequestDecoder(serializers(binarySerializer)));
+        try {
+            channel.writeInbound(new CloseWebSocketFrame(true, 0, content));
+
+            assertEquals(request.getRequestId(), ((RequestMessage) 
channel.readInbound()).getRequestId());
+            assertEquals(frameSize, requestSize(channel));
+
+            // not the remainder that is left once the mime type length byte 
has been read
+            assertTrue(frameSize > 1);
+            assertNotEquals(frameSize - 1, requestSize(channel));
+        } finally {
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    @Test
+    public void shouldRecordTheFullFrameSizeOfATextFrame() throws Exception {
+        // the non-ascii argument makes the frame longer than the character 
count of its text
+        final RequestMessage request = RequestMessage.build(Tokens.OPS_EVAL)
+                .addArg(Tokens.ARGS_GREMLIN, "g.V().has('name','é')").create();
+        final String text = textSerializer.serializeRequestAsString(request, 
UnpooledByteBufAllocator.DEFAULT);
+        final int frameSize = text.getBytes(StandardCharsets.UTF_8).length;
+
+        final EmbeddedChannel channel = new EmbeddedChannel(new 
WsGremlinTextRequestDecoder(serializers(textSerializer)));
+        try {
+            channel.writeInbound(new TextWebSocketFrame(text));
+
+            assertEquals(request.getRequestId(), ((RequestMessage) 
channel.readInbound()).getRequestId());
+            assertEquals(frameSize, requestSize(channel));
+            assertNotEquals(text.length(), requestSize(channel));
+        } finally {
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    private static int requestSize(final EmbeddedChannel channel) {
+        return channel.attr(StateKey.REQUEST_SIZE).get();
+    }
+
+    private static Map<String, MessageSerializer<?>> serializers(final 
MessageSerializer<?> serializer) {
+        final Map<String, MessageSerializer<?>> serializers = new HashMap<>();
+
+        for (final String mimeType : serializer.mimeTypesSupported()) {
+            serializers.put(mimeType, serializer);
+        }
+
+        return serializers;
+    }
+}

Reply via email to