Skip to content

Commit 959cbd3

Browse files
authored
Add a handful of utilities (grpc#1690)
Motivation: The client rpc executor makes use of a bunch of utilities. Since it will be a reasonably large PR, in order to make it slightly less large, I'd like to get some of the utilities reviewed separately. Since most are too small to be worth reviewing individually this change includes a few unrelated utilities. Modifications: - Add `UnsafeTransfer` - Adds an optional `cause` error to `RPCError` - Add extensions to `Metadata` for setting/parsing a few gRPC specific metadata fields - Add extensions to `Result` for working with `async` closures and casting errors to a known type - Add a type-erased closable writer similar to the type-erased writer Result: A few handy helpers are in place and the rpc executor PR will be a little smaller.
1 parent cdb9fc6 commit 959cbd3

File tree

7 files changed

+393
-7
lines changed

7 files changed

+393
-7
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@usableFromInline
18+
struct UnsafeTransfer<Wrapped> {
19+
@usableFromInline
20+
var wrappedValue: Wrapped
21+
22+
@inlinable
23+
init(_ wrappedValue: Wrapped) {
24+
self.wrappedValue = wrappedValue
25+
}
26+
}
27+
28+
extension UnsafeTransfer: @unchecked Sendable {}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
18+
extension Metadata {
19+
@inlinable
20+
var previousRPCAttempts: Int? {
21+
get {
22+
self.firstString(forKey: .previousRPCAttempts).flatMap { Int($0) }
23+
}
24+
set {
25+
if let newValue = newValue {
26+
self.replaceOrAddString(String(describing: newValue), forKey: .previousRPCAttempts)
27+
} else {
28+
self.removeAllValues(forKey: .previousRPCAttempts)
29+
}
30+
}
31+
}
32+
33+
@inlinable
34+
var retryPushback: RetryPushback? {
35+
return self.firstString(forKey: .retryPushbackMs).map {
36+
RetryPushback(milliseconds: $0)
37+
}
38+
}
39+
}
40+
41+
extension Metadata {
42+
@usableFromInline
43+
enum GRPCKey: String, Sendable, Hashable {
44+
case retryPushbackMs = "grpc-retry-pushback-ms"
45+
case previousRPCAttempts = "grpc-previous-rpc-attempts"
46+
}
47+
48+
@inlinable
49+
func firstString(forKey key: GRPCKey) -> String? {
50+
self[stringValues: key.rawValue].first(where: { _ in true })
51+
}
52+
53+
@inlinable
54+
mutating func replaceOrAddString(_ value: String, forKey key: GRPCKey) {
55+
self.replaceOrAddString(value, forKey: key.rawValue)
56+
}
57+
58+
@inlinable
59+
mutating func removeAllValues(forKey key: GRPCKey) {
60+
self.removeAllValues(forKey: key.rawValue)
61+
}
62+
}
63+
64+
extension Metadata {
65+
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
66+
@usableFromInline
67+
enum RetryPushback: Hashable, Sendable {
68+
case retryAfter(Duration)
69+
case stopRetrying
70+
71+
@inlinable
72+
init(milliseconds value: String) {
73+
if let milliseconds = Int64(value), milliseconds >= 0 {
74+
let (seconds, remainingMilliseconds) = milliseconds.quotientAndRemainder(dividingBy: 1000)
75+
// 1e18 attoseconds per second
76+
// 1e15 attoseconds per millisecond.
77+
let attoseconds = Int64(remainingMilliseconds) * 1_000_000_000_000_000
78+
self = .retryAfter(Duration(secondsComponent: seconds, attosecondsComponent: attoseconds))
79+
} else {
80+
// Negative or not parseable means stop trying.
81+
// Source: https://github.com/grpc/proposal/blob/master/A6-client-retries.md
82+
self = .stopRetrying
83+
}
84+
}
85+
}
86+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
extension Result where Failure == any Error {
18+
/// Like `Result(catching:)`, but `async`.
19+
///
20+
/// - Parameter body: An `async` closure to catch the result of.
21+
@inlinable
22+
init(catching body: () async throws -> Success) async {
23+
do {
24+
self = .success(try await body())
25+
} catch {
26+
self = .failure(error)
27+
}
28+
}
29+
30+
/// Attempts to map the error to the given error type.
31+
///
32+
/// If the cast fails then the provided closure is used to create an error of the given type.
33+
///
34+
/// - Parameters:
35+
/// - errorType: The type of error to cast to.
36+
/// - buildError: A closure which constructs the desired error if the cast fails.
37+
@inlinable
38+
func castError<NewError: Error>(
39+
to errorType: NewError.Type = NewError.self,
40+
or buildError: (any Error) -> NewError
41+
) -> Result<Success, NewError> {
42+
return self.mapError { error in
43+
return (error as? NewError) ?? buildError(error)
44+
}
45+
}
46+
}

Sources/GRPCCore/RPCError.swift

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,24 +59,36 @@ public struct RPCError: @unchecked Sendable, Hashable, Error {
5959
}
6060
}
6161

62+
/// The original error which led to this error being thrown.
63+
public var cause: Error? {
64+
get { self.storage.cause }
65+
set {
66+
self.ensureStorageIsUnique()
67+
self.storage.cause = newValue
68+
}
69+
}
70+
6271
/// Create a new RPC error.
6372
///
6473
/// - Parameters:
6574
/// - code: The status code.
6675
/// - message: A message providing additional context about the code.
6776
/// - metadata: Any metadata to attach to the error.
68-
public init(code: Code, message: String, metadata: Metadata = [:]) {
69-
self.storage = Storage(code: code, message: message, metadata: metadata)
77+
/// - cause: An underlying error which led to this error being thrown.
78+
public init(code: Code, message: String, metadata: Metadata = [:], cause: Error? = nil) {
79+
self.storage = Storage(code: code, message: message, metadata: metadata, cause: cause)
7080
}
7181

7282
/// Create a new RPC error from the provided ``Status``.
7383
///
7484
/// Returns `nil` if the provided ``Status`` has code ``Status/Code-swift.struct/ok``.
7585
///
76-
/// - Parameter status: The status to convert.
77-
public init?(status: Status) {
86+
/// - Parameters:
87+
/// - status: The status to convert.
88+
/// - metadata: Any metadata to attach to the error.
89+
public init?(status: Status, metadata: Metadata = [:]) {
7890
guard let code = Code(status.code) else { return nil }
79-
self.init(code: code, message: status.message, metadata: [:])
91+
self.init(code: code, message: status.message, metadata: metadata)
8092
}
8193
}
8294

@@ -91,15 +103,17 @@ extension RPCError {
91103
var code: RPCError.Code
92104
var message: String
93105
var metadata: Metadata
106+
var cause: Error?
94107

95-
init(code: RPCError.Code, message: String, metadata: Metadata) {
108+
init(code: RPCError.Code, message: String, metadata: Metadata, cause: Error?) {
96109
self.code = code
97110
self.message = message
98111
self.metadata = metadata
112+
self.cause = cause
99113
}
100114

101115
func copy() -> Self {
102-
Self(code: self.code, message: self.message, metadata: self.metadata)
116+
Self(code: self.code, message: self.message, metadata: self.metadata, cause: self.cause)
103117
}
104118

105119
func hash(into hasher: inout Hasher) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
18+
extension RPCWriter {
19+
@usableFromInline
20+
struct Closable: ClosableRPCWriterProtocol {
21+
@usableFromInline
22+
let writer: any ClosableRPCWriterProtocol<Element>
23+
24+
/// Creates an ``RPCWriter`` by wrapping the `other` writer.
25+
///
26+
/// - Parameter other: The writer to wrap.
27+
@inlinable
28+
init(wrapping other: some ClosableRPCWriterProtocol<Element>) {
29+
self.writer = other
30+
}
31+
32+
/// Writes a sequence of elements.
33+
///
34+
/// This function suspends until the elements have been accepted. Implements can use this
35+
/// to exert backpressure on callers.
36+
///
37+
/// - Parameter elements: The elements to write.
38+
@inlinable
39+
func write(contentsOf elements: some Sequence<Element>) async throws {
40+
try await self.writer.write(contentsOf: elements)
41+
}
42+
43+
/// Indicate to the writer that no more writes are to be accepted.
44+
///
45+
/// All writes after ``finish()`` has been called should result in an error
46+
/// being thrown.
47+
@inlinable
48+
func finish() {
49+
self.writer.finish()
50+
}
51+
52+
/// Indicate to the writer that no more writes are to be accepted because an error occurred.
53+
///
54+
/// All writes after ``finish(throwing:)`` has been called should result in an error
55+
/// being thrown.
56+
@inlinable
57+
func finish(throwing error: Error) {
58+
self.writer.finish(throwing: error)
59+
}
60+
}
61+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import XCTest
18+
19+
@testable import GRPCCore
20+
21+
final class MetadataGRPCTests: XCTestCase {
22+
func testPreviousRPCAttemptsValidValues() {
23+
let testData = [("0", 0), ("1", 1), ("-1", -1)]
24+
for (value, expected) in testData {
25+
let metadata: Metadata = ["grpc-previous-rpc-attempts": "\(value)"]
26+
XCTAssertEqual(metadata.previousRPCAttempts, expected)
27+
}
28+
}
29+
30+
func testPreviousRPCAttemptsInvalidValues() {
31+
let values = ["foo", "42.0"]
32+
for value in values {
33+
let metadata: Metadata = ["grpc-previous-rpc-attempts": "\(value)"]
34+
XCTAssertNil(metadata.previousRPCAttempts)
35+
}
36+
}
37+
38+
func testSetPreviousRPCAttemptsToValue() {
39+
var metadata: Metadata = [:]
40+
41+
metadata.previousRPCAttempts = 42
42+
XCTAssertEqual(metadata, ["grpc-previous-rpc-attempts": "42"])
43+
44+
metadata.previousRPCAttempts = nil
45+
XCTAssertEqual(metadata, [:])
46+
47+
for i in 0 ..< 5 {
48+
metadata.addString("\(i)", forKey: "grpc-previous-rpc-attempts")
49+
}
50+
XCTAssertEqual(metadata.count, 5)
51+
52+
// Should remove old values.
53+
metadata.previousRPCAttempts = 42
54+
XCTAssertEqual(metadata, ["grpc-previous-rpc-attempts": "42"])
55+
}
56+
57+
func testRetryPushbackValidDelay() {
58+
let testData: [(String, Duration)] = [
59+
("0", .zero),
60+
("1", Duration(secondsComponent: 0, attosecondsComponent: 1_000_000_000_000_000)),
61+
("999", Duration(secondsComponent: 0, attosecondsComponent: 999_000_000_000_000_000)),
62+
("1000", Duration(secondsComponent: 1, attosecondsComponent: 0)),
63+
("1001", Duration(secondsComponent: 1, attosecondsComponent: 1_000_000_000_000_000)),
64+
("1999", Duration(secondsComponent: 1, attosecondsComponent: 999_000_000_000_000_000)),
65+
]
66+
67+
for (value, expectedDuration) in testData {
68+
let metadata: Metadata = ["grpc-retry-pushback-ms": "\(value)"]
69+
XCTAssertEqual(metadata.retryPushback, .retryAfter(expectedDuration))
70+
}
71+
}
72+
73+
func testRetryPushbackInvalidDelay() {
74+
let testData: [String] = ["-1", "-inf", "not-a-number", "42.0"]
75+
76+
for value in testData {
77+
let metadata: Metadata = ["grpc-retry-pushback-ms": "\(value)"]
78+
XCTAssertEqual(metadata.retryPushback, .stopRetrying)
79+
}
80+
}
81+
82+
func testRetryPushbackNoValuePresent() {
83+
let metadata: Metadata = [:]
84+
XCTAssertNil(metadata.retryPushback)
85+
}
86+
}

0 commit comments

Comments
 (0)