Skip to content

Instantly share code, notes, and snippets.

@rzikm
Last active June 13, 2022 12:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rzikm/16c1147dfb164ec630655c6fdb7f4338 to your computer and use it in GitHub Desktop.
Save rzikm/16c1147dfb164ec630655c6fdb7f4338 to your computer and use it in GitHub Desktop.
QUIC Exception Design

QUIC Exceptions Design

Based on #55619, for QUIC API design, see

Exceptions and their meaning as of current state

  • OperationCanceledException - when the cancellation token for a particular call fires, and it should always contain the relevant cancellation token
  • ObjectDisposedException - when given object was disposed
  • QuicOperationAbortedException - when the local operation was aborted due to the stream/connection being aborted locally.
  • QuicConnectionAbortedException - peer closed the connection with application-level error code
  • QuicStreamAbortedException - peer aborted the read/write direction of the stream

Proposed design

The proposed design is similar to that of SocketException with SocketError.

// derive from IOException to for more consistency (Stream API lists IOException as expected from WriteAsync etc.)
public sealed class QuicException : IOException
{
    // no public ctors, we don't want to allow users to throw this type of exception themselves
    internal QuicException(QuicError error, string message, long? errorCode, Exception innerException)
    {
        // ...
    }

    // Error code for distinguishing the different types of failure.
    public QuicError QuicError { get; }

    // Holds the error specified by the application protocol when the peer
    // closed the stream or connection. This is where e.g. HTTP3 protocol error
    // codes are stored.
    //  - not null only when when QuicError is ConnectionAborted or StreamAborted
    //  - raw msquic status will go into the HResult property of the exception to improve diagnosing unexpected errors
    public long? ApplicationProtocolErrorCode { get; }
}

// Platform-independent error/status codes used to indicate reason of failure
public enum QuicError
{
    InternalError, // used as a catch all for errors for which we don't have a more specific code

    ConnectionAborted,
    StreamAborted,

    AddressInUse,
    InvalidAddress,
    ConnectionTimeout,
    ConnectionIdle,
    HostUnreachable,
    ConnectionRefused,
    VersionNegotiationError,
    ProtocolError, // QUIC-level protocol error

    OperationAborted, // operation was aborted due to locally aborting stream/connection

    // those below may be made unnecessary by latest QuicConnection design changes
    IsConnected, // Already connected
    NotConnected, // ConnectAsync wasn't called yet

    //
    // Following MsQuic statuses have been purposefully left out as they are
    // either not supposed to surface to user because they should be prevented
    // internally or are covered otherwise (e.g. AuthenticationException)
    //
    // **QUIC_STATUS_SUCCESS** | The operation completed successfully.
    // **QUIC_STATUS_PENDING** | The operation is pending.
    // **QUIC_STATUS_CONTINUE** | The operation will continue.
    // **QUIC_STATUS_OUT_OF_MEMORY** | Allocation of memory failed. --> OutOfMemoryException
    // **QUIC_STATUS_HANDSHAKE_FAILED** | Handshake failed. --> AuthenticationException
    // **QUIC_STATUS_INVALID_PARAMETER** | An invalid parameter was encountered.
    // **QUIC_STATUS_ALPN_NEG** | ALPN negotiation failed. --> AuthenticationException
    // **QUIC_STATUS_INVALID_STATE** | The current state was not valid for this operation.
    // **QUIC_STATUS_NOT_SUPPORTED** | The operation was not supported.
    // **QUIC_STATUS_BUFFER_TOO_SMALL** | The buffer was too small for the operation.
    // **QUIC_STATUS_USER_CANCELED** | The peer app/user canceled the connection during the handshake.
    // **QUIC_STATUS_STREAM_LIMIT_REACHED** | A stream failed to start because the peer doesn't allow any more to be open at this time.
    
}

Usage examples

Kestrel (accepting incoming requests)

    public override async ValueTask<ConnectionContext?> AcceptAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            var stream = await _connection.AcceptStreamAsync(cancellationToken);

            // ...
        }
        catch (QuicException ex)
        {
            switch (ex.QuicError)
            {
                case QuicError.ConnectionAborted:
                {
                    // Shutdown initiated by peer, abortive.
                    _error = ex.ApplicationProtocolErrorCode;
                    QuicLog.ConnectionAborted(_log, this, ex.ApplicationProtocolErrorCode, ex);

                    ThreadPool.UnsafeQueueUserWorkItem(state =>
                    {
                        state.CancelConnectionClosedToken();
                    },
                    this,
                    preferLocal: false);

                    // Throw error so consumer sees the connection is aborted by peer.
                    throw new ConnectionResetException(ex.Message, ex);
                }

                case QuicError.OperationAborted:
                {
                    lock (_shutdownLock)
                    {
                        // This error should only happen when shutdown has been initiated by the server.
                        // If there is no abort reason and we have this error then the connection is in an
                        // unexpected state. Abort connection and throw reason error.
                        if (_abortReason == null)
                        {
                            Abort(new ConnectionAbortedException("Unexpected error when accepting stream.", ex));
                        }

                        _abortReason!.Throw();
                    }
                }
            }

        }
        // other catch blocks follow

        // ...
    }

Kestrel (reading requests)

    private async Task DoReceive()
    {
        Debug.Assert(_stream != null);

        Exception? error = null;

        try
        {
            var input = Input;
            while (true)
            {
                var buffer = input.GetMemory(MinAllocBufferSize);
                var bytesReceived = await _stream.ReadAsync(buffer);

                // ...
                if (result.IsCompleted || result.IsCanceled)
                {
                    // Pipe consumer is shut down, do we stop writing
                    break;
                }
            }
        }
        // this used to be two duplicate catch blocks for Stream/Connection aborts
        catch (QuicException ex) when (ex.ApplicationProtocolErrorCode != null)
        {
            // Abort from peer.
            _error = ex.ApplicationProtocolErrorCode.Value;
            QuicLog.StreamAbortedRead(_log, this, ex.ApplicationProtocolErrorCode.Value);

            // This could be ignored if _shutdownReason is already set.
            error = new ConnectionResetException(ex.Message, ex);

            _clientAbort = true;
        }
        catch (QuicException ex) when (ex.QuicError == QuicError.OperationAborted)
        {
            // AbortRead has been called for the stream.
            error = new ConnectionAbortedException(ex.Message, ex);
        }
        catch (Exception ex)
        {
            // This is unexpected.
            error = ex;
            QuicLog.StreamError(_log, this, error);
        }

        // ...
    }

Http3RequestStream.SendAsync

Note that the usage may be modified based on the outcome of Exposing HTTP/2 and HTTP/3 protocol error details from SocketsHttpHandler proposal.

        public async Task<HttpResponseMessage> SendAsync(CancellationToken cancellationToken)
        {
            // ...
            try
            {
                // ... write request into QuicStream and read response back

                return response;
            }
            catch (QuicException ex) when (ex.ApplicationProtocolErrorCode != null)
            {
                // aborted by the app layer
                Debug.Assert(ex.QuicError == QuicError.ConnectionAborted || ex.QuicError == QuicError.StreamAborted);

                // HTTP3 uses same error code space for connection and stream errors
                switch (ex.ApplicationProtocolErrorCode.Value)
                {
                    case Http3ErrorCode.VersionFallback:
                        // The server is requesting us fall back to an older HTTP version.
                        throw new HttpRequestException(SR.net_http_retry_on_older_version, ex, RequestRetryType.RetryOnLowerHttpVersion);

                    case Http3ErrorCode.RequestRejected:
                        // The server is rejecting the request without processing it, retry it on a different connection.
                        throw new HttpRequestException(SR.net_http_request_aborted, ex, RequestRetryType.RetryOnConnectionFailure);

                    default:
                        // Only observe the first exception we get.
                        Exception? abortException = ex.QuicError == QuicError.StreamAborted
                            ? _connection.AbortException;
                            : _connection.Abort(ex) // Our connection was reset. Start shutting down the connection.
                        
                        throw new HttpRequestException(SR.net_http_client_execution_error, abortException ?? ex);
                }
            }
            catch (OperationCanceledException ex) { /*...*/ }
            catch (Http3ConnectionException ex) { /*...*/ }
            catch (Exception ex)
            {
                _stream.AbortWrite((long)Http3ErrorCode.InternalError);
                if (ex is HttpRequestException)
                {
                    throw;
                }
                throw new HttpRequestException(SR.net_http_client_execution_error, ex);
            }
        }

Comparison with other APIs

Sockets

As already mentioned, the exception throwing is inspired by that of Socket class, which uses SocketException for all socket-related errors with SocketError giving more specific details (reason).

SslStream

SslStream by itself does not generate any low-level transport exceptions, it just propagates whichever exceptions are thrown by the inner stream (e.g. IOException with inner SocketException from NetworkStream). This is not possible for QUIC as it does not wrap any other abstraction.

SslStream by itself generates following:

  • AuthenticationException

    • thrown for all TLS handshake related errors
      • TLS alerts: server rejected the certificate, ALPN negotiation fails, ...
      • Local certificate validation fails (better exception messages than the alerts above)
    • Behavior adopted for QuicConnection for consistency
  • InvalidOperationException - for overlapping read/write operations

    • This behavior has been adopted for QuicStream for consistency.

Alternative designs

Subclasses with non-nullable Application level error code

// same as above
public class QuicException : IOException
{
    // ...

    public long? ApplicationProtocolErrorCode { get; } 
}

public class QuicStreamAbortedException : QuicException 
{
    public QuicStreamAbortedException(long errorCode) : base(QuicError.StreamAborted, errorCode, ...) {}
    
    // define non-nullable getter since the error code must be supplied for this type of error
    public new long ApplicationProtocolErrorCode => base.ApplicationProtocolErrorCode.Value;
}

public class QuicConnectionAbortedException : QuicException
{
    public QuicConnectionAbortedException(long errorCode) : base(QuicError.ConnectionAborted, errorCode, ...) {}

    public new long ApplicationProtocolErrorCode => base.ApplicationProtocolErrorCode.Value;
}

This removes the need to handle nullability warnings when accessing the ApplicationProtocolErrorCode when we know (based on the knowledge of the protocol) that it needs to be not-null.

ErrorCode in QuicException subclasses

public class QuicException : IOException
{
    public QuicException(QuicError error, string message, Exception innerException) {}

    // Error code for distinguishing the different types of failure.
    public QuicError QuicError { get; }
}

public class QuicStreamAbortedException : QuicException 
{
    public QuicStreamAbortedException(long errorCode) : base(QuicError.StreamAborted, ...) {}

    public long ApplicationProtocolErrorCode { get; }
}

public class QuicConnectionAbortedException : QuicException
{
    public QuicConnectionAbortedException(long errorCode) : base(QuicError.ConnectionAborted, ...) {}

    public long ApplicationProtocolErrorCode { get; }
}

Summary of expected exceptions from QUIC API

Below are the API classes annotated with expected exceptions (to be included in the documentation).

QuicListener

public class QuicListener : IAsyncDisposable
{
    // - Argument{Null}Exception - when validating options
    // - PlatformNotSupportedException - when MsQuic is not available
    public static QuicListener Create(QuicListenerOptions options);

    // - ObjectDisposedException
    public IPEndPoint ListenEndPoint { get; }

    // - ObjectDisposedException
    public async ValueTask<QuicConnection> AcceptConnectionAsync(CancellationToken cancellationToken = default);

    public void DisposeAsync();
}

QuicConnection

public sealed class QuicConnection : IAsyncDisposable
{
    // - PlatformNotSupportedException - when MsQuic is not available
    public static QuicConnection Create();

    /// <summary>Indicates whether the QuicConnection is connected.</summary>
    // - ObjectDisposedException
    public bool Connected { get; }

    /// <summary>Remote endpoint to which the connection try to get / is connected. Throws if Connected is false.</summary>
    // - ObjectDisposedException
    // - QuicException - NotConnected
    public IPEndPoint RemoteEndPoint { get; }

    /// <summary>Local endpoint to which the connection will be / is bound. Throws if Connected is false.</summary>
    // - ObjectDisposedException
    // - QuicException - NotConnected
    public IPEndPoint LocalEndPoint { get; }

    /// <summary>Peer's certificate, available only after the connection is fully connected (Connected is true) and the peer provided the certificate.</summary>
    // - ObjectDisposedException
    // - QuicException - NotConnected
    // ?? SslStream would throw InvalidOperationException ("Not authenticated") in this case
    public X509Certificate? RemoteCertificate { get; }

    /// <summary>Final, negotiated ALPN, available only after the connection is fully connected (Connected is true).</summary>
    // - ObjectDisposedException
    // - QuicException - NotConnected
    // ?? SslStream would throw InvalidOperationException ("Not authenticated") in this case
    public SslApplicationProtocol NegotiatedApplicationProtocol { get; }

    /// <summary>Connects to the remote endpoint.</summary>
    // - ObjectDisposedException
    // - Argument{Null}Exception - When passed options are not valid
    // - AuthenticationException - Failed to authenticate
    // - QuicException - IsConnected - Already connected or connection attempt failed (terminated by transport)
    public ValueTask ConnectAsync(QuicClientConnectionOptions options, CancellationToken cancellationToken = default);

    /// <summary>Create an outbound uni/bidirectional stream.</summary>
    // - ObjectDisposedException
    // - ArgumentOutOfRangeException - invalid direction
    // - QuicException - NotConnected
    // - QuicException - ConnectionAborted - When closed by peer (application).
    // - QuicException - When closed locally
    public ValueTask<QuicStream> OpenStreamAsync(QuicStreamDirection direction, CancellationToken cancellationToken = default);

    /// <summary>Accept an incoming stream.</summary>
    // - ObjectDisposedException
    // - QuicException - NotConnected
    // - QuicException - ConnectionIdle, ProtocolError - When closed by transport.
    // - QuicException - ConnectionAborted - When closed by peer (application).
    // - QuicException - OperationAborted - When closed locally
    public ValueTask<QuicStream> AcceptStreamAsync(CancellationToken cancellationToken = default);

    /// <summary>Close the connection and terminate any active streams.</summary>
    // - ObjectDisposedException
    // - ArgumentOutOfRangeException - errorCode out of variable-length encoding range ([0, 2^62-1])
    // - QuicException - NotConnected
    // Note: duplicate calls, or when already closed by peer/transport are ignored and don't produce exception.
    public ValueTask CloseAsync(long errorCode, CancellationToken cancellationToken = default);
}

QuicStream

public class QuicStream : Stream
{
    // - ObjectDisposedException
    // - InvalidOperationException - Another concurrent read is pending
    // - Argument(Null)Exception - invalid parameters (null buffer etc)
    // - NotSupportedException - Stream does not support reading.
    // - QuicException - ConnectionIdle, ProtocolError - When closed by transport.
    // - QuicException - StreamAborted, ConnectionAborted - When closed by peer (application).
    // - QuicException - OperationAborted - When closed locally
    public ValueTask<int> ReadAsync(...); // all overloads

    // - ObjectDisposedException
    // - InvalidOperationException - Another concurrent write is pending
    // - Argument(Null)Exception - invalid parameters (null buffer etc)
    // - NotSupportedException - Stream does not support writing.
    // - QuicException - ConnectionIdle, ProtocolError - When closed by transport.
    // - QuicException - StreamAborted, ConnectionAborted - When closed by peer (application).
    // - QuicException - OperationAborted - When closed locally
    public ValueTask WriteAsync(...); // all overloads

    // - ObjectDisposedException
    public long StreamId { get; } // https://www.rfc-editor.org/rfc/rfc9000.html#name-stream-types-and-identifier

    // - ObjectDisposedException
    public QuicStreamDirection StreamDirection { get; }  // https://github.com/dotnet/runtime/issues/55816

    // - ObjectDisposedException
    // when awaited, throws:
    // - QuicException - ConnectionIdle, ProtocolError - When closed by transport.
    // - QuicException - StreamAborted, ConnectionAborted - When closed by peer (application).
    // - QuicException - OperationAborted - When closed locally
    public Task ReadsCompleted { get; } // gets set when peer sends STREAM frame with FIN bit (=EOF, =ReadAsync returning 0) or when peer aborts the sending side by sending RESET_STREAM frame. Inspired by channel.

    // - ObjectDisposedException
    // when awaited, throws:
    // - QuicException - ConnectionIdle, ProtocolError - When closed by transport.
    // - QuicException - StreamAborted, ConnectionAborted - When closed by peer (application).
    // - QuicException - OperationAborted - When closed locally
    public Task WritesCompleted { get; } // gets set when our side sends STREAM frame with FIN bit (=EOF) or when peer aborts the receiving side by sending STOP_SENDING frame. Inspired by channel.

    // - ObjectDisposedException
    // - NotSupportedException - Stream does not support reading/writing.
    // - ArgumentOutOfRangeException - invalid combination of flags in abortDirection, error code out of range [0, 2^62-1]
    // Note: duplicate calls allowed
    public void Abort(QuicAbortDirection abortDirection, long errorCode); // abortively ends either sending ore receiving or both sides of the stream, i.e.: RESET_STREAM frame or STOP_SENDING frame

    // - ObjectDisposedException
    // - NotSupportedException - Stream does not support writing.
    // Note: duplicate calls allowed
    public void CompleteWrites(); // https://github.com/dotnet/runtime/issues/43290, gracefully ends the sending side, equivalent to WriteAsync with endStream set to true
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment