diff --git a/Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift b/Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift index aa2163424..50ff0b3bd 100644 --- a/Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift +++ b/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 } diff --git a/Sources/GRPCCore/RPCError.swift b/Sources/GRPCCore/RPCError.swift index 810298e3a..80157034c 100644 --- a/Sources/GRPCCore/RPCError.swift +++ b/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 + } +} diff --git a/Tests/GRPCCoreTests/Call/Server/Internal/ServerRPCExecutorTests.swift b/Tests/GRPCCoreTests/Call/Server/Internal/ServerRPCExecutorTests.swift index fe8d301aa..b807d02cd 100644 --- a/Tests/GRPCCoreTests/Call/Server/Internal/ServerRPCExecutorTests.swift +++ b/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"]) + ] + ) + } + } } diff --git a/Tests/GRPCCoreTests/RPCErrorTests.swift b/Tests/GRPCCoreTests/RPCErrorTests.swift index 7f87e697a..b4eba43d0 100644 --- a/Tests/GRPCCoreTests/RPCErrorTests.swift +++ b/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) + } }