Based on #55619, for QUIC API design, see
OperationCanceledException
- when the cancellation token for a particular call fires, and it should always contain the relevant cancellation tokenObjectDisposedException
- when given object was disposedQuicOperationAbortedException
- when the local operation was aborted due to the stream/connection being aborted locally.QuicConnectionAbortedException
- peer closed the connection with application-level error codeQuicStreamAbortedException
- peer aborted the read/write direction of the stream
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.
}
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
// ...
}
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);
}
// ...
}
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);
}
}
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
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
- thrown for all TLS handshake related errors
-
InvalidOperationException
- for overlapping read/write operations- This behavior has been adopted for
QuicStream
for consistency.
- This behavior has been adopted for
// 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.
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; }
}
Below are the API classes annotated with expected exceptions (to be included in the documentation).
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();
}
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);
}
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
}