Browse Source

Add RPCErrorConvertible (#2143)

Motivation:

If an error is thrown from a server RPC then the status sent to the
client will always have the unknown error code unless an `RPCError` is
thrown.

Moreover, there are various extensions to gRPC which rely on additional
information being stuffed into the metadata. This is difficult and a bit
error prone for users to do directly.

We should provide a mechanism whereby errors can be converted to an
`RPCError` such that the appropriate code, message and metadata are sent
to the client.

Modifications:

- Add the `RPCErrorConvertible` protocol. Conforming types provide
appropriate properties to populate an `RPCError`.
- Add handling for this in the server executor such that convertible
errors are converted into an `RPCError`.

Result:

Easier for users to propagate an appropriate status
George Barnett 1 year ago
parent
commit
13150bde88

+ 9 - 1
Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift

@@ -188,7 +188,15 @@ struct ServerRPCExecutor {
         try await handler(request, context)
       }
     }.castError(to: RPCError.self) { error in
-      RPCError(code: .unknown, message: "Service method threw an unknown error.", cause: error)
+      if let convertible = error as? (any RPCErrorConvertible) {
+        return RPCError(convertible)
+      } else {
+        return RPCError(
+          code: .unknown,
+          message: "Service method threw an unknown error.",
+          cause: error
+        )
+      }
     }.flatMap { response in
       response.accepted
     }

+ 57 - 0
Sources/GRPCCore/RPCError.swift

@@ -277,3 +277,60 @@ extension RPCError.Code {
   /// operation.
   public static let unauthenticated = Self(code: .unauthenticated)
 }
+
+/// A value that can be converted to an ``RPCError``.
+///
+/// You can conform types to this protocol to have more control over the status codes and
+/// error information provided to clients when a service throws an error.
+public protocol RPCErrorConvertible {
+  /// The error code to terminate the RPC with.
+  var rpcErrorCode: RPCError.Code { get }
+
+  /// A message providing additional context about the error.
+  var rpcErrorMessage: String { get }
+
+  /// Metadata associated with the error.
+  ///
+  /// Any metadata included in the error thrown from a service will be sent back to the client and
+  /// conversely any ``RPCError`` received by the client may include metadata sent by a service.
+  ///
+  /// Note that clients and servers may synthesise errors which may not include metadata.
+  var rpcErrorMetadata: Metadata { get }
+
+  /// The original error which led to this error being thrown.
+  var rpcErrorCause: (any Error)? { get }
+}
+
+extension RPCErrorConvertible {
+  /// Metadata associated with the error.
+  ///
+  /// Any metadata included in the error thrown from a service will be sent back to the client and
+  /// conversely any ``RPCError`` received by the client may include metadata sent by a service.
+  ///
+  /// Note that clients and servers may synthesise errors which may not include metadata.
+  public var rpcErrorMetadata: Metadata {
+    [:]
+  }
+
+  /// The original error which led to this error being thrown.
+  public var rpcErrorCause: (any Error)? {
+    nil
+  }
+}
+
+extension RPCErrorConvertible where Self: Error {
+  /// The original error which led to this error being thrown.
+  public var rpcErrorCause: (any Error)? {
+    self
+  }
+}
+
+extension RPCError {
+  /// Create a new error by converting the given value.
+  public init(_ convertible: some RPCErrorConvertible) {
+    self.code = convertible.rpcErrorCode
+    self.message = convertible.rpcErrorMessage
+    self.metadata = convertible.rpcErrorMetadata
+    self.cause = convertible.rpcErrorCause
+  }
+}

+ 23 - 0
Tests/GRPCCoreTests/Call/Server/Internal/ServerRPCExecutorTests.swift

@@ -346,4 +346,27 @@ final class ServerRPCExecutorTests: XCTestCase {
       XCTAssertEqual(parts, [.status(Status(code: .unavailable, message: "Unavailable"), [:])])
     }
   }
+
+  func testErrorConversion() async throws {
+    struct CustomError: RPCErrorConvertible, Error {
+      var rpcErrorCode: RPCError.Code { .alreadyExists }
+      var rpcErrorMessage: String { "foobar" }
+      var rpcErrorMetadata: Metadata { ["error": "yes"] }
+    }
+
+    let harness = ServerRPCExecutorTestHarness()
+    try await harness.execute(handler: .throwing(CustomError())) { inbound in
+      try await inbound.write(.metadata(["foo": "bar"]))
+      try await inbound.write(.message([0]))
+      await inbound.finish()
+    } consumer: { outbound in
+      let parts = try await outbound.collect()
+      XCTAssertEqual(
+        parts,
+        [
+          .status(Status(code: .alreadyExists, message: "foobar"), ["error": "yes"])
+        ]
+      )
+    }
+  }
 }

+ 45 - 0
Tests/GRPCCoreTests/RPCErrorTests.swift

@@ -189,4 +189,49 @@ struct RPCErrorTests {
     #expect(wrappedError1.message == "Error 1.")
     #expect(wrappedError1.cause == nil)
   }
+
+  @Test("Convert type to RPCError")
+  func convertTypeUsingRPCErrorConvertible() {
+    struct Cause: Error {}
+    struct ConvertibleError: RPCErrorConvertible {
+      var rpcErrorCode: RPCError.Code { .unknown }
+      var rpcErrorMessage: String { "uhoh" }
+      var rpcErrorMetadata: Metadata { ["k": "v"] }
+      var rpcErrorCause: (any Error)? { Cause() }
+    }
+
+    let error = RPCError(ConvertibleError())
+    #expect(error.code == .unknown)
+    #expect(error.message == "uhoh")
+    #expect(error.metadata == ["k": "v"])
+    #expect(error.cause is Cause)
+  }
+
+  @Test("Convert type to RPCError with defaults")
+  func convertTypeUsingRPCErrorConvertibleDefaults() {
+    struct ConvertibleType: RPCErrorConvertible {
+      var rpcErrorCode: RPCError.Code { .unknown }
+      var rpcErrorMessage: String { "uhoh" }
+    }
+
+    let error = RPCError(ConvertibleType())
+    #expect(error.code == .unknown)
+    #expect(error.message == "uhoh")
+    #expect(error.metadata == [:])
+    #expect(error.cause == nil)
+  }
+
+  @Test("Convert error to RPCError with defaults")
+  func convertErrorUsingRPCErrorConvertibleDefaults() {
+    struct ConvertibleType: RPCErrorConvertible, Error {
+      var rpcErrorCode: RPCError.Code { .unknown }
+      var rpcErrorMessage: String { "uhoh" }
+    }
+
+    let error = RPCError(ConvertibleType())
+    #expect(error.code == .unknown)
+    #expect(error.message == "uhoh")
+    #expect(error.metadata == [:])
+    #expect(error.cause is ConvertibleType)
+  }
 }