Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[API Proposal]: [QUIC] QuicException #70669

Closed
rzikm opened this issue Jun 13, 2022 · 4 comments · Fixed by #71432
Closed

[API Proposal]: [QUIC] QuicException #70669

rzikm opened this issue Jun 13, 2022 · 4 comments · Fixed by #71432
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-System.Net.Quic blocking Marks issues that we want to fast track in order to unblock other important work
Projects
Milestone

Comments

@rzikm
Copy link
Member

rzikm commented Jun 13, 2022

This proposal is for exceptions thrown from the new System.Net.Quic APIs covered in following proposals.

Proposed design

In general, we reuse the same types of exceptions used for the same concept in other areas:

  • AuthenticationException for handshake-related errors like in SslStream
  • InvalidOperationException for overlapping async read/write operations
  • the usual types like ObjectDisposedException, OperationCanceledException, ...

For the errors which are specific to QUIC, we add a new exception type: QuicException inspired by Sockets SocketException with SocketError.

// derive from IOException to for more consistency (Stream API lists IOException as expected from WriteAsync etc.)
public sealed class QuicException : IOException
{
    public QuicException(QuicError error, string message, long? errorCode, Exception innerException)
    {
        // ...
    }

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

    // The ConnectionAborted and StreamAborted errors are accompanied by application-level error code
    // specified by the peer. In case of HTTP3, this will be the HTTP error code. For all other errors, this property
    // will be null.
    //  - not null only when when QuicError is ConnectionAborted or StreamAborted
    public long? ApplicationProtocolErrorCode { get; }
}

// Platform-independent error/status codes used to indicate reason of failure. This has been modelled after what
// error codes can MsQuic return to us.
public enum QuicError
{
    // used as a catch all for errors for which we don't have a more specific code to surface to the user.
    // the raw MsQuic status can go into the HResult property of the exception to improve diagnosing unexpected errors
    // or into an inner exception
    InternalError, 

    // accompanied by Application protocol error code (see comment on the property above)
    ConnectionAborted,
    StreamAborted,

    // Possible errors when attempting to establish a new connection
    AddressInUse,
    InvalidAddress,
    ConnectionTimeout,
    HostUnreachable,
    ConnectionRefused,
    VersionNegotiationError,

    // connection was terminated due to inactivity
    ConnectionIdle,

    // fatal QUIC-level protocol error
    ProtocolError,

    // the operation was aborted due to locally aborting stream/connection
    // side note: this error is synthesized by the .NET layer, MsQuic itself never returns this.
    OperationAborted, 

    //
    // 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 #70684.

        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
}
@ghost ghost added the untriaged New issue has not been triaged by the area owner label Jun 13, 2022
@ghost
Copy link

ghost commented Jun 13, 2022

Tagging subscribers to this area: @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Issue Details

TODO

Author: rzikm
Assignees: -
Labels:

area-System.Net.Quic

Milestone: -

@rzikm rzikm removed the untriaged New issue has not been triaged by the area owner label Jun 13, 2022
@rzikm rzikm changed the title [API Proposal] System.Net.Quic Exceptions [API Proposal]: [QUIC] System.Net.Quic Exceptions Jun 13, 2022
@rzikm rzikm changed the title [API Proposal]: [QUIC] System.Net.Quic Exceptions [API Proposal]: [QUIC] QuicException Jun 13, 2022
@rzikm rzikm added this to To Do (Low Priority) in HTTP/3 via automation Jun 13, 2022
@rzikm rzikm moved this from To Do (Low Priority) to To Do (High Priority) in HTTP/3 Jun 13, 2022
@rzikm rzikm added the api-ready-for-review API is ready for review, it is NOT ready for implementation label Jun 13, 2022
@rzikm rzikm added this to the 7.0.0 milestone Jun 13, 2022
@rzikm rzikm self-assigned this Jun 14, 2022
@karelz karelz added the blocking Marks issues that we want to fast track in order to unblock other important work label Jun 27, 2022
@terrajobst
Copy link
Member

terrajobst commented Jun 28, 2022

Video

  • QuicError
    • It seems we're mapping MsQuic codes to QuicError. We should ensure that our API surface is general enough, ideally using terminology/concepts from the QUIC spec to ensure the managed API could be backed by another QUIC implementation.
    • We should a success code
  • QuicException
    • The parameter errorCode should align with the property ApplicationProtocolErrorCode so either name both the long form or both the short form.
namespace System.Net.Quic;

public sealed class QuicException : IOException
{
    public QuicException(QuicError error, string message, long? applicationErrorCode, Exception? innerException);
    public QuicError QuicError { get; }
    public long? ApplicationErrorCode { get; }
}

public enum QuicError
{
    Success,
    InternalError, 
    ConnectionAborted,
    StreamAborted,
    AddressInUse,
    InvalidAddress,
    ConnectionTimeout,
    HostUnreachable,
    ConnectionRefused,
    VersionNegotiationError,
    ConnectionIdle,
    ProtocolError,
    OperationAborted, 
}

@terrajobst terrajobst added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for review, it is NOT ready for implementation labels Jun 28, 2022
@karelz
Copy link
Member

karelz commented Jun 28, 2022

Note: Another change in the final approved API against the original proposal is nullable innerException.

@danmoseley
Copy link
Member

QuickError It seems we're mapping MsQuic codes to QuickError

should this be QuicError?

@ghost ghost added the in-pr There is an active PR which will close this issue when it is merged label Jun 29, 2022
rzikm added a commit to rzikm/dotnet-runtime that referenced this issue Jul 12, 2022
HTTP/3 automation moved this from To Do (High Priority) to Done Jul 12, 2022
@ghost ghost removed the in-pr There is an active PR which will close this issue when it is merged label Jul 12, 2022
@ghost ghost locked as resolved and limited conversation to collaborators Aug 12, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-System.Net.Quic blocking Marks issues that we want to fast track in order to unblock other important work
Projects
No open projects
HTTP/3
  
Done
Development

Successfully merging a pull request may close this issue.

4 participants