Skip to content

Commit

Permalink
Add RPCErrorConvertible (#2143)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
glbrntt authored Dec 12, 2024
1 parent 7ed6f7d commit 13150bd
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 1 deletion.
10 changes: 9 additions & 1 deletion Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
57 changes: 57 additions & 0 deletions Sources/GRPCCore/RPCError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 45 additions & 0 deletions Tests/GRPCCoreTests/RPCErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

0 comments on commit 13150bd

Please sign in to comment.