Browse Source

Add RPCError and Status (#1656)

RPCError and Status

Motivation:

One mistake we made in v1 was having the status also be an error. This
leads to interesting situations where a status with code 'ok' can be
treated as an error.

Here, we split them into two objects.

Modifications:

- Add a new GRPCCore target
- Add RPCError and Status types and tests

Result:

RPCError and status are separate types.
George Barnett 2 years ago
parent
commit
d576a74069

+ 21 - 0
Package.swift

@@ -119,6 +119,8 @@ extension Target.Dependency {
     name: "SwiftProtobufPluginLibrary",
     package: "swift-protobuf"
   )
+
+  static let grpcCore: Self = .target(name: "GRPCCore")
 }
 
 // MARK: - Targets
@@ -146,6 +148,12 @@ extension Target {
     path: "Sources/GRPC"
   )
 
+  static let grpcCore: Target = .target(
+    name: "GRPCCore",
+    dependencies: [],
+    path: "Sources/GRPCCore"
+  )
+
   static let cgrpcZlib: Target = .target(
     name: cgrpcZlibTargetName,
     path: "Sources/CGRPCZlib",
@@ -200,6 +208,13 @@ extension Target {
     ]
   )
 
+  static let grpcCoreTests: Target = .testTarget(
+    name: "GRPCCoreTests",
+    dependencies: [
+      .grpcCore,
+    ]
+  )
+
   static let interopTestModels: Target = .target(
     name: "GRPCInteroperabilityTestModels",
     dependencies: [
@@ -476,6 +491,12 @@ let package = Package(
     .routeGuideClient,
     .routeGuideServer,
     .packetCapture,
+
+    // v2
+    .grpcCore,
+
+    // v2 tests
+    .grpcCoreTests,
   ]
 )
 

+ 18 - 0
Sources/GRPCCore/Metadata.swift

@@ -0,0 +1,18 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// FIXME: placeholder.
+public typealias Metadata = [String: String]

+ 244 - 0
Sources/GRPCCore/RPCError.swift

@@ -0,0 +1,244 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// An error representing the outcome of an RPC.
+///
+/// See also ``Status``.
+public struct RPCError: @unchecked Sendable, Hashable, Error {
+  // @unchecked because it relies on heap allocated storage and 'isKnownUniquelyReferenced'
+
+  private var storage: Storage
+  private mutating func ensureStorageIsUnique() {
+    if !isKnownUniquelyReferenced(&self.storage) {
+      self.storage = self.storage.copy()
+    }
+  }
+
+  /// A code representing the high-level domain of the error.
+  public var code: Code {
+    get { self.storage.code }
+    set {
+      self.ensureStorageIsUnique()
+      self.storage.code = newValue
+    }
+  }
+
+  /// A message providing additional context about the error.
+  public var message: String {
+    get { self.storage.message }
+    set {
+      self.ensureStorageIsUnique()
+      self.storage.message = newValue
+    }
+  }
+
+  /// 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 metadata: Metadata {
+    get { self.storage.metadata }
+    set {
+      self.ensureStorageIsUnique()
+      self.storage.metadata = newValue
+    }
+  }
+
+  /// Create a new RPC error.
+  ///
+  /// - Parameters:
+  ///   - code: The status code.
+  ///   - message: A message providing additional context about the code.
+  ///   - metadata: Any metadata to attach to the error.
+  public init(code: Code, message: String, metadata: Metadata = [:]) {
+    self.storage = Storage(code: code, message: message, metadata: metadata)
+  }
+
+  /// Create a new RPC error from the provided ``Status``.
+  ///
+  /// Returns `nil` if the provided ``Status`` has code ``Status/Code-swift.struct/ok``.
+  ///
+  /// - Parameter status: The status to convert.
+  public init?(status: Status) {
+    guard let code = Code(statusCode: status.code) else { return nil }
+    self.init(code: code, message: status.message, metadata: [:])
+  }
+}
+
+extension RPCError: CustomStringConvertible {
+  public var description: String {
+    "\(self.code): \"\(self.message)\""
+  }
+}
+
+extension RPCError {
+  private final class Storage: Hashable {
+    var code: RPCError.Code
+    var message: String
+    var metadata: Metadata
+
+    init(code: RPCError.Code, message: String, metadata: Metadata) {
+      self.code = code
+      self.message = message
+      self.metadata = metadata
+    }
+
+    func copy() -> Self {
+      Self(code: self.code, message: self.message, metadata: self.metadata)
+    }
+
+    func hash(into hasher: inout Hasher) {
+      hasher.combine(self.code)
+      hasher.combine(self.message)
+      hasher.combine(self.metadata)
+    }
+
+    static func == (lhs: RPCError.Storage, rhs: RPCError.Storage) -> Bool {
+      return lhs.code == rhs.code && lhs.message == rhs.message && lhs.metadata == rhs.metadata
+    }
+  }
+}
+
+extension RPCError {
+  public struct Code: Hashable, Sendable, CustomStringConvertible {
+    /// The numeric value of the error code.
+    public var rawValue: Int { Int(self.wrapped.rawValue) }
+
+    private var wrapped: Status.Code.Wrapped
+    private init(_ wrapped: Status.Code.Wrapped) {
+      self.wrapped = wrapped
+    }
+
+    internal init?(statusCode: Status.Code) {
+      if statusCode == .ok {
+        return nil
+      } else {
+        self.wrapped = statusCode.wrapped
+      }
+    }
+
+    public var description: String {
+      String(describing: self.wrapped)
+    }
+  }
+}
+
+extension RPCError.Code {
+  /// The operation was cancelled (typically by the caller).
+  public static let cancelled = Self(.cancelled)
+
+  /// Unknown error. An example of where this error may be returned is if a
+  /// Status value received from another address space belongs to an error-space
+  /// that is not known in this address space. Also errors raised by APIs that
+  /// do not return enough error information may be converted to this error.
+  public static let unknown = Self(.unknown)
+
+  /// Client specified an invalid argument. Note that this differs from
+  /// ``failedPrecondition``. ``invalidArgument`` indicates arguments that are
+  /// problematic regardless of the state of the system (e.g., a malformed file
+  /// name).
+  public static let invalidArgument = Self(.invalidArgument)
+
+  /// Deadline expired before operation could complete. For operations that
+  /// change the state of the system, this error may be returned even if the
+  /// operation has completed successfully. For example, a successful response
+  /// from a server could have been delayed long enough for the deadline to
+  /// expire.
+  public static let deadlineExceeded = Self(.deadlineExceeded)
+
+  /// Some requested entity (e.g., file or directory) was not found.
+  public static let notFound = Self(.notFound)
+
+  /// Some entity that we attempted to create (e.g., file or directory) already
+  /// exists.
+  public static let alreadyExists = Self(.alreadyExists)
+
+  /// The caller does not have permission to execute the specified operation.
+  /// ``permissionDenied`` must not be used for rejections caused by exhausting
+  /// some resource (use ``resourceExhausted`` instead for those errors).
+  /// ``permissionDenied`` must not be used if the caller can not be identified
+  /// (use ``unauthenticated`` instead for those errors).
+  public static let permissionDenied = Self(.permissionDenied)
+
+  /// Some resource has been exhausted, perhaps a per-user quota, or perhaps the
+  /// entire file system is out of space.
+  public static let resourceExhausted = Self(.resourceExhausted)
+
+  /// Operation was rejected because the system is not in a state required for
+  /// the operation's execution. For example, directory to be deleted may be
+  /// non-empty, an rmdir operation is applied to a non-directory, etc.
+  ///
+  /// A litmus test that may help a service implementor in deciding
+  /// between ``failedPrecondition``, ``aborted``, and ``unavailable``:
+  /// - Use ``unavailable`` if the client can retry just the failing call.
+  /// - Use ``aborted`` if the client should retry at a higher-level
+  ///   (e.g., restarting a read-modify-write sequence).
+  /// - Use ``failedPrecondition`` if the client should not retry until
+  ///   the system state has been explicitly fixed. E.g., if an "rmdir"
+  ///   fails because the directory is non-empty, ``failedPrecondition``
+  ///   should be returned since the client should not retry unless
+  ///   they have first fixed up the directory by deleting files from it.
+  /// - Use ``failedPrecondition`` if the client performs conditional
+  ///   REST Get/Update/Delete on a resource and the resource on the
+  ///   server does not match the condition. E.g., conflicting
+  ///   read-modify-write on the same resource.
+  public static let failedPrecondition = Self(.failedPrecondition)
+
+  /// The operation was aborted, typically due to a concurrency issue like
+  /// sequencer check failures, transaction aborts, etc.
+  ///
+  /// See litmus test above for deciding between ``failedPrecondition``, ``aborted``,
+  /// and ``unavailable``.
+  public static let aborted = Self(.aborted)
+
+  /// Operation was attempted past the valid range. E.g., seeking or reading
+  /// past end of file.
+  ///
+  /// Unlike ``invalidArgument``, this error indicates a problem that may be fixed
+  /// if the system state changes. For example, a 32-bit file system will
+  /// generate ``invalidArgument`` if asked to read at an offset that is not in the
+  /// range [0,2^32-1], but it will generate ``outOfRange`` if asked to read from
+  /// an offset past the current file size.
+  ///
+  /// There is a fair bit of overlap between ``failedPrecondition`` and
+  /// ``outOfRange``. We recommend using ``outOfRange`` (the more specific error)
+  /// when it applies so that callers who are iterating through a space can
+  /// easily look for an ``outOfRange`` error to detect when they are done.
+  public static let outOfRange = Self(.outOfRange)
+
+  /// Operation is not implemented or not supported/enabled in this service.
+  public static let unimplemented = Self(.unimplemented)
+
+  /// Internal errors. Means some invariants expected by underlying System has
+  /// been broken. If you see one of these errors, Something is very broken.
+  public static let internalError = Self(.internalError)
+
+  /// The service is currently unavailable. This is a most likely a transient
+  /// condition and may be corrected by retrying with a backoff.
+  ///
+  /// See litmus test above for deciding between ``failedPrecondition``, ``aborted``,
+  /// and ``unavailable``.
+  public static let unavailable = Self(.unavailable)
+
+  /// Unrecoverable data loss or corruption.
+  public static let dataLoss = Self(.dataLoss)
+
+  /// The request does not have valid authentication credentials for the
+  /// operation.
+  public static let unauthenticated = Self(.unauthenticated)
+}

+ 264 - 0
Sources/GRPCCore/Status.swift

@@ -0,0 +1,264 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// A status object represents the outcome of an RPC.
+///
+/// Each ``Status`` is composed of a ``Status/code-swift.property`` and ``Status/message``. Each
+/// service implementation chooses the code and message returned to the client for each RPC
+/// it implements. However, client and server implementations may also generate status objects
+/// on their own if an error happens.
+///
+/// ``Status`` represents the raw outcome of an RPC whether it was successful or not; ``RPCError``
+/// is similar to ``Status`` but only represents error cases, in other words represents all status
+/// codes apart from ``Code-swift.struct/ok``.
+public struct Status: @unchecked Sendable, Hashable {
+  // @unchecked because it relies on heap allocated storage and 'isKnownUniquelyReferenced'
+
+  private var storage: Storage
+  private mutating func ensureStorageIsUnique() {
+    if !isKnownUniquelyReferenced(&self.storage) {
+      self.storage = self.storage.copy()
+    }
+  }
+
+  /// A code representing the high-level domain of the status.
+  public var code: Code {
+    get { self.storage.code }
+    set {
+      self.ensureStorageIsUnique()
+      self.storage.code = newValue
+    }
+  }
+
+  /// A message providing additional context about the status.
+  public var message: String {
+    get { self.storage.message }
+    set {
+      self.ensureStorageIsUnique()
+      self.storage.message = newValue
+    }
+  }
+
+  /// Create a new status.
+  ///
+  /// - Parameters:
+  ///   - code: The status code.
+  ///   - message: A message providing additional context about the code.
+  public init(code: Code, message: String) {
+    if code == .ok, message.isEmpty {
+      // Avoid a heap allocation for the common case.
+      self.storage = Storage.okWithNoMessage
+    } else {
+      self.storage = Storage(code: code, message: message)
+    }
+  }
+}
+
+extension Status: CustomStringConvertible {
+  public var description: String {
+    "\(self.code): \"\(self.message)\""
+  }
+}
+
+extension Status {
+  private final class Storage: Hashable {
+    static let okWithNoMessage = Storage(code: .ok, message: "")
+
+    var code: Status.Code
+    var message: String
+
+    init(code: Status.Code, message: String) {
+      self.code = code
+      self.message = message
+    }
+
+    func copy() -> Self {
+      Self(code: self.code, message: self.message)
+    }
+
+    func hash(into hasher: inout Hasher) {
+      hasher.combine(self.code)
+      hasher.combine(self.message)
+    }
+
+    static func == (lhs: Status.Storage, rhs: Status.Storage) -> Bool {
+      return lhs.code == rhs.code && lhs.message == rhs.message
+    }
+  }
+}
+
+extension Status {
+  /// Status codes for gRPC operations.
+  ///
+  /// The outcome of every RPC is indicated by a status code.
+  public struct Code: Hashable, CustomStringConvertible, Sendable {
+    // Source: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md
+    enum Wrapped: UInt8, Hashable, Sendable {
+      case ok = 0
+      case cancelled = 1
+      case unknown = 2
+      case invalidArgument = 3
+      case deadlineExceeded = 4
+      case notFound = 5
+      case alreadyExists = 6
+      case permissionDenied = 7
+      case resourceExhausted = 8
+      case failedPrecondition = 9
+      case aborted = 10
+      case outOfRange = 11
+      case unimplemented = 12
+      case internalError = 13
+      case unavailable = 14
+      case dataLoss = 15
+      case unauthenticated = 16
+    }
+
+    /// The underlying value.
+    let wrapped: Wrapped
+
+    /// The numeric value of the error code.
+    public var rawValue: Int { Int(self.wrapped.rawValue) }
+
+    /// Creates a status codes from its raw value.
+    ///
+    /// - Parameters:
+    ///   - rawValue: The numeric value to create the code from.
+    /// Returns `nil` if the `rawValue` isn't a valid error code.
+    public init?(rawValue: Int) {
+      if let value = UInt8(exactly: rawValue), let wrapped = Wrapped(rawValue: value) {
+        self.wrapped = wrapped
+      } else {
+        return nil
+      }
+    }
+
+    private init(_ wrapped: Wrapped) {
+      self.wrapped = wrapped
+    }
+
+    public var description: String {
+      String(describing: self.wrapped)
+    }
+  }
+}
+
+extension Status.Code {
+  /// The operation completed successfully.
+  public static let ok = Self(.ok)
+
+  /// The operation was cancelled (typically by the caller).
+  public static let cancelled = Self(.cancelled)
+
+  /// Unknown error. An example of where this error may be returned is if a
+  /// Status value received from another address space belongs to an error-space
+  /// that is not known in this address space. Also errors raised by APIs that
+  /// do not return enough error information may be converted to this error.
+  public static let unknown = Self(.unknown)
+
+  /// Client specified an invalid argument. Note that this differs from
+  /// ``failedPrecondition``. ``invalidArgument`` indicates arguments that are
+  /// problematic regardless of the state of the system (e.g., a malformed file
+  /// name).
+  public static let invalidArgument = Self(.invalidArgument)
+
+  /// Deadline expired before operation could complete. For operations that
+  /// change the state of the system, this error may be returned even if the
+  /// operation has completed successfully. For example, a successful response
+  /// from a server could have been delayed long enough for the deadline to
+  /// expire.
+  public static let deadlineExceeded = Self(.deadlineExceeded)
+
+  /// Some requested entity (e.g., file or directory) was not found.
+  public static let notFound = Self(.notFound)
+
+  /// Some entity that we attempted to create (e.g., file or directory) already
+  /// exists.
+  public static let alreadyExists = Self(.alreadyExists)
+
+  /// The caller does not have permission to execute the specified operation.
+  /// ``permissionDenied`` must not be used for rejections caused by exhausting
+  /// some resource (use ``resourceExhausted`` instead for those errors).
+  /// ``permissionDenied`` must not be used if the caller can not be identified
+  /// (use ``unauthenticated`` instead for those errors).
+  public static let permissionDenied = Self(.permissionDenied)
+
+  /// Some resource has been exhausted, perhaps a per-user quota, or perhaps the
+  /// entire file system is out of space.
+  public static let resourceExhausted = Self(.resourceExhausted)
+
+  /// Operation was rejected because the system is not in a state required for
+  /// the operation's execution. For example, directory to be deleted may be
+  /// non-empty, an rmdir operation is applied to a non-directory, etc.
+  ///
+  /// A litmus test that may help a service implementor in deciding
+  /// between ``failedPrecondition``, ``aborted``, and ``unavailable``:
+  /// - Use ``unavailable`` if the client can retry just the failing call.
+  /// - Use ``aborted`` if the client should retry at a higher-level
+  ///   (e.g., restarting a read-modify-write sequence).
+  /// - Use ``failedPrecondition`` if the client should not retry until
+  ///   the system state has been explicitly fixed. E.g., if an "rmdir"
+  ///   fails because the directory is non-empty, ``failedPrecondition``
+  ///   should be returned since the client should not retry unless
+  ///   they have first fixed up the directory by deleting files from it.
+  /// - Use ``failedPrecondition`` if the client performs conditional
+  ///   REST Get/Update/Delete on a resource and the resource on the
+  ///   server does not match the condition. E.g., conflicting
+  ///   read-modify-write on the same resource.
+  public static let failedPrecondition = Self(.failedPrecondition)
+
+  /// The operation was aborted, typically due to a concurrency issue like
+  /// sequencer check failures, transaction aborts, etc.
+  ///
+  /// See litmus test above for deciding between ``failedPrecondition``, ``aborted``,
+  /// and ``unavailable``.
+  public static let aborted = Self(.aborted)
+
+  /// Operation was attempted past the valid range. E.g., seeking or reading
+  /// past end of file.
+  ///
+  /// Unlike ``invalidArgument``, this error indicates a problem that may be fixed
+  /// if the system state changes. For example, a 32-bit file system will
+  /// generate ``invalidArgument`` if asked to read at an offset that is not in the
+  /// range [0,2^32-1], but it will generate ``outOfRange`` if asked to read from
+  /// an offset past the current file size.
+  ///
+  /// There is a fair bit of overlap between ``failedPrecondition`` and
+  /// ``outOfRange``. We recommend using ``outOfRange`` (the more specific error)
+  /// when it applies so that callers who are iterating through a space can
+  /// easily look for an ``outOfRange`` error to detect when they are done.
+  public static let outOfRange = Self(.outOfRange)
+
+  /// Operation is not implemented or not supported/enabled in this service.
+  public static let unimplemented = Self(.unimplemented)
+
+  /// Internal errors. Means some invariants expected by underlying System has
+  /// been broken. If you see one of these errors, Something is very broken.
+  public static let internalError = Self(.internalError)
+
+  /// The service is currently unavailable. This is a most likely a transient
+  /// condition and may be corrected by retrying with a backoff.
+  ///
+  /// See litmus test above for deciding between ``failedPrecondition``, ``aborted``,
+  /// and ``unavailable``.
+  public static let unavailable = Self(.unavailable)
+
+  /// Unrecoverable data loss or corruption.
+  public static let dataLoss = Self(.dataLoss)
+
+  /// The request does not have valid authentication credentials for the
+  /// operation.
+  public static let unauthenticated = Self(.unauthenticated)
+}

+ 101 - 0
Tests/GRPCCoreTests/RPCErrorTests.swift

@@ -0,0 +1,101 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import GRPCCore
+import XCTest
+
+final class RPCErrorTests: XCTestCase {
+  private static let statusCodeRawValue: [(RPCError.Code, Int)] = [
+    (.cancelled, 1),
+    (.unknown, 2),
+    (.invalidArgument, 3),
+    (.deadlineExceeded, 4),
+    (.notFound, 5),
+    (.alreadyExists, 6),
+    (.permissionDenied, 7),
+    (.resourceExhausted, 8),
+    (.failedPrecondition, 9),
+    (.aborted, 10),
+    (.outOfRange, 11),
+    (.unimplemented, 12),
+    (.internalError, 13),
+    (.unavailable, 14),
+    (.dataLoss, 15),
+    (.unauthenticated, 16),
+  ]
+
+  func testCustomStringConvertible() {
+    XCTAssertDescription(RPCError(code: .dataLoss, message: ""), #"dataLoss: """#)
+    XCTAssertDescription(RPCError(code: .unknown, message: "message"), #"unknown: "message""#)
+    XCTAssertDescription(RPCError(code: .aborted, message: "message"), #"aborted: "message""#)
+  }
+
+  func testErrorFromStatus() throws {
+    var status = Status(code: .ok, message: "")
+    // ok isn't an error
+    XCTAssertNil(RPCError(status: status))
+
+    status.code = .invalidArgument
+    var error = try XCTUnwrap(RPCError(status: status))
+    XCTAssertEqual(error.code, .invalidArgument)
+    XCTAssertEqual(error.message, "")
+    XCTAssertEqual(error.metadata, [:])
+
+    status.code = .cancelled
+    status.message = "an error message"
+    error = try XCTUnwrap(RPCError(status: status))
+    XCTAssertEqual(error.code, .cancelled)
+    XCTAssertEqual(error.message, "an error message")
+    XCTAssertEqual(error.metadata, [:])
+  }
+
+  func testEquatableConformance() {
+    XCTAssertEqual(
+      RPCError(code: .cancelled, message: ""),
+      RPCError(code: .cancelled, message: "")
+    )
+
+    XCTAssertEqual(
+      RPCError(code: .cancelled, message: "message"),
+      RPCError(code: .cancelled, message: "message")
+    )
+
+    XCTAssertEqual(
+      RPCError(code: .cancelled, message: "message", metadata: ["foo": "bar"]),
+      RPCError(code: .cancelled, message: "message", metadata: ["foo": "bar"])
+    )
+
+    XCTAssertNotEqual(
+      RPCError(code: .cancelled, message: ""),
+      RPCError(code: .cancelled, message: "message")
+    )
+
+    XCTAssertNotEqual(
+      RPCError(code: .cancelled, message: "message"),
+      RPCError(code: .unknown, message: "message")
+    )
+
+    XCTAssertNotEqual(
+      RPCError(code: .cancelled, message: "message", metadata: ["foo": "bar"]),
+      RPCError(code: .cancelled, message: "message", metadata: ["foo": "baz"])
+    )
+  }
+
+  func testStatusCodeRawValues() {
+    for (code, expected) in Self.statusCodeRawValue {
+      XCTAssertEqual(code.rawValue, expected, "\(code) had unexpected raw value")
+    }
+  }
+}

+ 98 - 0
Tests/GRPCCoreTests/StatusTests.swift

@@ -0,0 +1,98 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import GRPCCore
+import XCTest
+
+final class StatusTests: XCTestCase {
+  private static let statusCodeRawValue: [(Status.Code, Int)] = [
+    (.ok, 0),
+    (.cancelled, 1),
+    (.unknown, 2),
+    (.invalidArgument, 3),
+    (.deadlineExceeded, 4),
+    (.notFound, 5),
+    (.alreadyExists, 6),
+    (.permissionDenied, 7),
+    (.resourceExhausted, 8),
+    (.failedPrecondition, 9),
+    (.aborted, 10),
+    (.outOfRange, 11),
+    (.unimplemented, 12),
+    (.internalError, 13),
+    (.unavailable, 14),
+    (.dataLoss, 15),
+    (.unauthenticated, 16),
+  ]
+
+  func testCustomStringConvertible() {
+    XCTAssertDescription(Status(code: .ok, message: ""), #"ok: """#)
+    XCTAssertDescription(Status(code: .dataLoss, message: "message"), #"dataLoss: "message""#)
+    XCTAssertDescription(Status(code: .unknown, message: "message"), #"unknown: "message""#)
+    XCTAssertDescription(Status(code: .aborted, message: "message"), #"aborted: "message""#)
+  }
+
+  func testStatusCodeRawValues() {
+    for (code, expected) in Self.statusCodeRawValue {
+      XCTAssertEqual(code.rawValue, expected, "\(code) had unexpected raw value")
+    }
+  }
+
+  func testStatusCodeFromValidRawValue() {
+    for (expected, rawValue) in Self.statusCodeRawValue {
+      XCTAssertEqual(
+        Status.Code(rawValue: rawValue),
+        expected,
+        "\(rawValue) didn't convert to expected code \(expected)"
+      )
+    }
+  }
+
+  func testStatusCodeFromInvalidRawValue() {
+    // Internally represented as a `UInt8`; try all other values.
+    for rawValue in UInt8(17) ... UInt8.max {
+      XCTAssertNil(Status.Code(rawValue: Int(rawValue)))
+    }
+
+    // API accepts `Int` so try invalid `Int` values too.
+    XCTAssertNil(Status.Code(rawValue: -1))
+    XCTAssertNil(Status.Code(rawValue: 1000))
+    XCTAssertNil(Status.Code(rawValue: .max))
+  }
+
+  func testEquatableConformance() {
+    XCTAssertEqual(Status(code: .ok, message: ""), Status(code: .ok, message: ""))
+    XCTAssertEqual(Status(code: .ok, message: "message"), Status(code: .ok, message: "message"))
+
+    XCTAssertNotEqual(
+      Status(code: .ok, message: ""),
+      Status(code: .ok, message: "message")
+    )
+
+    XCTAssertNotEqual(
+      Status(code: .ok, message: "message"),
+      Status(code: .internalError, message: "message")
+    )
+
+    XCTAssertNotEqual(
+      Status(code: .ok, message: "message"),
+      Status(code: .ok, message: "different message")
+    )
+  }
+
+  func testFitsInExistentialContainer() {
+    XCTAssertLessThanOrEqual(MemoryLayout<Status>.size, 24)
+  }
+}

+ 25 - 0
Tests/GRPCCoreTests/Test Utilities/XCTest+Utilities.swift

@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import XCTest
+
+func XCTAssertDescription(
+  _ subject: some CustomStringConvertible,
+  _ expected: String,
+  file: StaticString = #filePath,
+  line: UInt = #line
+) {
+  XCTAssertEqual(String(describing: subject), expected, file: file, line: line)
+}