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

Document (and test) that obtaining error data is much simpler now #394

Merged
merged 1 commit into from
Dec 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions doc/exceptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Throwing and handling exceptions

The JSON-RPC protocol allows for server methods to return errors to the client instead of a result, except when the client invoked the method as a notification.

As a cross-platform protocol, JSON-RPC doesn't define error messages with enough precision to represent .NET exceptions, callstacks, etc. with full fidelity.
An exception thrown from a .NET-based JSON-RPC server may contain data that will not be included in the JSON-RPC error message that is sent back to the client.
The data that is sent back may only be human readable rather than machine parsable.

The [structure JSON-RPC defines for errors](https://www.jsonrpc.org/specification#response_object) includes an error code and a message.

Some error codes are reserved for the protocol itself or for the library that implements it,
but most of the 32-bit integer range of the error code is available for the application to define.
This error code is the best way for an RPC server to communicate a particular kind of error that the RPC client may use for controlling execution flow. For example the server may use an error code to indicate a conflict and another code to indicate a permission denied error. The client may check this error code and branch execution based on its value.

The error *message* should be a localized, human readable message that explains the problem, possibly to the programmer of the RPC client or perhaps to the end user of the application.

JSON-RPC also allows for an `error.data` property which may be a primitive value, array or object that provides more data regarding the error.
The schema for this property is up to the application, but StreamJsonRpc defaults to serializing
a `CommonErrorData` object to this property which retains much of the useful information from an
`Exception` object.

## Server-side concerns

In StreamJsonRpc, your RPC server can return errors to the client by throwing an exception from your RPC method. StreamJsonRpc will automatically serialize data from the exception as an error response and transmit to the client when allowed. If the RPC method was invoked using a JSON-RPC notification, the client is not expecting any response and the exception thrown from the server will be swallowed.

If some or all exceptions thrown from RPC methods should be considered fatal and terminate the JSON-RPC connection, you can configure this behavior. See the [Fatal exceptions section of our resiliency doc](resiliency.md#Fatal-exceptions).

By default any exception thrown from an RPC method is assigned `JsonRpcErrorCode.InvocationError` (-32000) for the JSON-RPC `error.code` property. The `Exception.Message` property is used as the JSON-RPC `error.message` property.

An RPC server may take total control of `error.message` value simply by throwing any exception type with the message to use.
The RPC server may also take control of the `error.code` and `error.data` properties by throwing `LocalRpcException`, which has properties for each of these JSON-RPC error message properties.
When a JSON-RPC server may throw multiple exceptions that clients should react differently to, it's highly recommended that the RPC server throw `LocalRpcException` and set its `ErrorCode` property so the client can check and branch based on it.

Another option to more closely control the error messages sent from an RPC server is to derive from the `JsonRpc` class and override its `CreateErrorDetails` method.
This allows the server to inspect the original JSON-RPC request as well as the exception thrown from the RPC server method and determine whatever `error.code`, `error.message` and `error.data` the JSON-RPC client requires.

## Client-side concerns

An invocation of an RPC method may throw several exceptions back at the client.
A full list of these exceptions are documented on each Invoke or Notify method API on the `JsonRpc` class.
All these exceptions may also be thrown from methods on dynamically generated proxies.
Relevant to the discussion of handling exceptions thrown from the RPC server are these exceptions which the client should be prepared to handle:

1. `OperationCanceledException` - Thrown when the client cancels the request before it is transmitted or when the server acknowledged a cancellation notification and terminated the RPC method at the server.
1. `RemoteInvocationException` - Thrown when the server responds to a request with an error message other than one that acknowledges cancellation.

The `RemoteInvocationException` contains properties to inspect the `error.code`, `error.message` and `error.data` properties from the original JSON-RPC error message.

Because `error.data` may have any schema, extracting this property from `RemoteInvocationException` may require a bit more effort. The exception's `DeserializedErrorData` property is typically the best one to use and will typically be an instance of `CommonErrorData` which you can try casting to. `CommonErrorData` contains most of the details useful from a .NET Exception.

When working with a JSON-RPC server that responds with `error.data` that does *not* conform to the `CommonErrorData` type defined by StreamJsonRpc, you may derive from the `JsonRpc` class and override the `GetErrorDetailsDataType` method to inspect the JSON-RPC error message directly and return the type of data object that should be deserialized for easier consumption from your exception handlers.

The `RemoteInvocationException.InnerException` property does *not* contain a deserialized exception tree from what was thrown on the server. The server may not even be based on .NET, but even if it were, .NET exceptions are not serialized such that they can be deserialized over a JSON-RPC connection. Thus this property will typically be `null` and should not generally be relied on.
2 changes: 1 addition & 1 deletion doc/recvrequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ RPC server methods may:

**Important notes**:

1. When an RPC-invoked server method throws an exception, StreamJsonRpc will handle the exception and (when applicable) send an error response to the client with a description of the failure.
1. When an RPC-invoked server method throws an exception, StreamJsonRpc will handle the exception and (when applicable) send an error response to the client with a description of the failure. [Learn more about this and how to customize error handling behavior](exceptions.md).
1. RPC servers may be invoked multiple times concurrently to keep up with incoming client requests.

[Learn more about writing resilient servers](resiliency.md).
Expand Down
7 changes: 7 additions & 0 deletions doc/sendrequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,10 @@ Terminating the connection is done by calling the proxy's `IDisposable.Dispose()
```

[Learn more about dynamically generated proxies](dynamicproxy.md).

## Exception handling

RPC methods may throw exceptions.
The RPC client should be prepared to handle these exceptions.

[Learn more about throwing and handling exceptions](exceptions.md).
13 changes: 4 additions & 9 deletions src/StreamJsonRpc.Tests/JsonRpcJsonHeadersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,14 @@ public async Task ExceptionControllingErrorData()
}

[Fact]
public async Task CanPassExceptionFromServer()
public async Task CanPassExceptionFromServer_ErrorData()
{
#pragma warning disable SA1139 // Use literal suffix notation instead of casting
const int COR_E_UNAUTHORIZEDACCESS = unchecked((int)0x80070005);
#pragma warning restore SA1139 // Use literal suffix notation instead of casting
RemoteInvocationException exception = await Assert.ThrowsAnyAsync<RemoteInvocationException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsUnauthorizedAccessException)));
Assert.Equal((int)JsonRpcErrorCode.InvocationError, exception.ErrorCode);

var errorData = ((JToken?)exception.ErrorData)!.ToObject<CommonErrorData>();
Assert.NotNull(errorData.StackTrace);
Assert.StrictEqual(COR_E_UNAUTHORIZEDACCESS, errorData.HResult);

errorData = Assert.IsType<CommonErrorData>(exception.DeserializedErrorData);
var errorDataJToken = (JToken?)exception.ErrorData;
Assert.NotNull(errorDataJToken);
var errorData = errorDataJToken!.ToObject<CommonErrorData>();
Assert.NotNull(errorData.StackTrace);
Assert.StrictEqual(COR_E_UNAUTHORIZEDACCESS, errorData.HResult);
}
Expand Down
10 changes: 4 additions & 6 deletions src/StreamJsonRpc.Tests/JsonRpcMessagePackLengthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,13 @@ public async Task ExceptionControllingErrorData()
}

[Fact]
public async Task CanPassExceptionFromServer()
public async Task CanPassExceptionFromServer_ErrorData()
{
#pragma warning disable SA1139 // Use literal suffix notation instead of casting
const int COR_E_UNAUTHORIZEDACCESS = unchecked((int)0x80070005);
#pragma warning restore SA1139 // Use literal suffix notation instead of casting
RemoteInvocationException exception = await Assert.ThrowsAnyAsync<RemoteInvocationException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsUnauthorizedAccessException)));
Assert.Equal((int)JsonRpcErrorCode.InvocationError, exception.ErrorCode);
var errorData = (CommonErrorData?)exception.ErrorData;
Assert.NotNull(errorData!.StackTrace);

var errorData = Assert.IsType<CommonErrorData>(exception.ErrorData);
Assert.NotNull(errorData.StackTrace);
Assert.StrictEqual(COR_E_UNAUTHORIZEDACCESS, errorData.HResult);
}

Expand Down
15 changes: 15 additions & 0 deletions src/StreamJsonRpc.Tests/JsonRpcTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@

public abstract class JsonRpcTests : TestBase
{
#pragma warning disable SA1310 // Field names should not contain underscore
protected const int COR_E_UNAUTHORIZEDACCESS = unchecked((int)0x80070005);
#pragma warning restore SA1310 // Field names should not contain underscore

protected readonly Server server;
protected Nerdbank.FullDuplexStream serverStream;
protected JsonRpc serverRpc;
Expand Down Expand Up @@ -1637,6 +1641,17 @@ public async Task ReturnTypeThrowsOnDeserialization()
await Assert.ThrowsAnyAsync<Exception>(() => this.clientRpc.InvokeWithCancellationAsync<TypeThrowsWhenDeserialized>(nameof(Server.GetTypeThrowsWhenDeserialized), cancellationToken: this.TimeoutToken)).WithCancellation(this.TimeoutToken);
}

[Fact]
public async Task CanPassExceptionFromServer_DeserializedErrorData()
{
RemoteInvocationException exception = await Assert.ThrowsAnyAsync<RemoteInvocationException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsUnauthorizedAccessException)));
Assert.Equal((int)JsonRpcErrorCode.InvocationError, exception.ErrorCode);

var errorData = Assert.IsType<CommonErrorData>(exception.DeserializedErrorData);
Assert.NotNull(errorData.StackTrace);
Assert.StrictEqual(COR_E_UNAUTHORIZEDACCESS, errorData.HResult);
}

protected override void Dispose(bool disposing)
{
if (disposing)
Expand Down
3 changes: 2 additions & 1 deletion src/StreamJsonRpc/Exceptions/RemoteInvocationException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ protected RemoteInvocationException(
/// <remarks>
/// Depending on the <see cref="IJsonRpcMessageFormatter"/> used, the value of this property, if any,
/// may be a <see cref="JToken"/> or a deserialized object.
/// Deserializing this or casting this object to <see cref="CommonErrorData"/> <em>may</em> succeed, and be a means to extract useful error information.
/// If a deserialized object, the type of this object is determined by <see cref="JsonRpc.GetErrorDetailsDataType(JsonRpcError)"/>.
/// The default implementation of this method produces a <see cref="CommonErrorData"/> object.
/// </remarks>
public object? ErrorData { get; }

Expand Down