-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Description
Updated by @CarnaViire
Background and motivation
ClientWebSocket
currently doesn't provide any details about upgrade response. However, the information about response headers and status code might be important in both failure and success scenarios.
In case of failure, the status code can help to distinguish between retriable and non-retriable errors (server doesn't support web sockets at all vs just a small transient error). Headers might also contain additional information on how to handle the situation.
The headers are also useful even in case of a success web socket connect, e.g. they can contain a token tied to a session, or some info related to subprotocol version, or that the server can go down soon, etc.
There are three asks on GH for this ATM with a total of 21 distinct upvotes — #28331, #62474 and #25918 (this issue).
API Proposal
There are two alternatives, that are usable regardless of success/failure scenario, and also both opt-in, in order not to regress the existing usages in size and perf.
Option 1. ConnectAsync overload with a result object to fill in
// NEW
class WebSocketConnectResult
{
public int? HttpStatusCode { get; set; }
public IReadOnlyDictionary<string, IEnumerable<string>>? HttpResponseHeaders { get; set; }
}
// EXISTING
class ClientWebSocket
{
// EXISTING
public Task ConnectAsync(Uri uri, CancellationToken cancellationToken);
// NEW
public Task ConnectAsync(Uri uri, WebSocketConnectResult result, CancellationToken cancellationToken);
}
Usage:
ClientWebSocket ws = new();
WebSocketConnectResult result = new();
try
{
await ws.ConnectAsync(uri, result, default);
// success scenario
ProcessSuccess(result.HttpResponseHeaders);
}
catch (WebSocketException)
{
// failure scenario
if (connectResult.HttpStatusCode != null)
{
ProcessFailure(result.HttpStatusCode, result.HttpResponseHeaders);
}
}
Pros:
- Provides data that is essentially a connect result, from an overload of
ConnectAsync
method - User can reuse the result objects
- Result object is independent from the
ClientWebSocket
object and its ownership/lifetime is "naturally" in hands of the user
Cons:
- Need to manually create additional object (no
out var
syntax here) - User needs to ensure thread-safety, especially if reusing result objects
Option 2. WebSocket property with opt-in setting
// EXISTING
class ClientWebSocketOptions
{
// NEW
public bool CollectHttpResponseDetails { get; set; } = false;
}
// EXISTING
class ClientWebSocket
{
// EXISTING
public string? SubProtocol { get; }
// NEW
public int? HttpStatusCode { get; }
public IReadOnlyDictionary<string, IEnumerable<string>>? HttpResponseHeaders { get; set; } // setter to clean up when not needed anymore
}
Usage:
ClientWebSocket ws = new();
ws.Options.CollectHttpResponseDetails = true;
try
{
await ws.ConnectAsync(uri, default);
// success scenario
ProcessSuccess(ws.HttpResponseHeaders);
ws.HttpResponseHeaders = null; // clean up (if needed)
}
catch (WebSocketException)
{
// failure scenario
if (ws.HttpStatusCode != null)
{
ProcessFailure(ws.HttpStatusCode, ws.HttpResponseHeaders);
}
}
Pros:
SubProtocol
, which also can be treated as "connect result", is already a part ofClientWebSocket
object- No additional objects
Cons:
- Expanding
ClientWebSocket
object leads to increased memory footprint - Even though it is possible to clean up by setting headers to null, user needs to be aware of that approach.
Other alternatives considered, but rejected:
- Returning result object from the method itself (
Task<WebSocketConnectResult>
) would either only work in success scenario or will require unconventional handling of every possible exception by storing it into the result object - Having different approaches for success and failure scenarios doubles the API changes needed and is harder to use, plus adding a new derived exception is bad for discoverability.
- Using full
HttpResponseMessage
is dangerous, it is easy to misuse (and end up breaking the protocol/aborting connection by disposing/etc) - In general, we want to distance from
System.Net
types in favor of plain types likeint
andIDictionary
to enable wider usage.
Original post by @amrmahdi
Related to #19405
ClientWebSocket on .NET Core does not provide the upgrade request errors in the exception details as it does on the .NET Framework.
Repro code
var client = new ClientWebSocket();
client.ConnectAsync(new Uri("wss://speech.platform.bing.com/speech/recognition/interactive/cognitiveservices/v1"), CancellationToken.None).GetAwaiter().GetResult();
Behavior on .NET 462
Unhandled Exception: System.Net.WebSockets.WebSocketException: Unable to connect to the remote server ---> System.Net.WebException: The remote server returned an error: (403) Forbidden.
at System.Net.HttpWebRequest.EndGetResponse(IAsyncResult asyncResult)
at System.Threading.Tasks.TaskFactory`1.FromAsyncCoreLogic(IAsyncResult iar, Func`2 endFunction, Action`1 endAction, Task`1 promise, Boolean requiresSynchronization)
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Net.WebSockets.ClientWebSocket.<ConnectAsyncCore>d__21.MoveNext()
--- End of inner exception stack trace ---
at System.Net.WebSockets.ClientWebSocket.<ConnectAsyncCore>d__21.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
at ConsoleApp1.Program.Main(String[] args)
Behavior on .NET Core 2.1 preview 2
Unhandled Exception: System.Net.WebSockets.WebSocketException: Unable to connect to the remote server
at System.Net.WebSockets.WebSocketHandle.ConnectAsyncCore(Uri uri, CancellationToken cancellationToken, ClientWebSocketOptions options)
at System.Net.WebSockets.ClientWebSocket.ConnectAsyncCore(Uri uri, CancellationToken cancellationToken)
at ConsoleApp1.Program.Main(String[] args)
As you can see on .NET 462, the inner exception is a WebException
with the error details.
Proposed Fix
Create an inner exception of type WebException
in a similar fashion to
https://github.com/dotnet/corefx/blob/6acd74dda7bc4f585d2c4006da4a8b2deb0261ad/src/System.Net.Requests/src/System/Net/HttpWebRequest.cs#L1211
and throw if the response is not 200.
The original WebException in .NET framework was thrown fromHttpWebRequest.GetResponseAsync()
, so I think the exception needs to be bubbled up in a similar way.