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

Add RPCErrorConvertible #2143

Merged
merged 2 commits into from
Dec 12, 2024
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
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)
}
}
Loading