Skip to content

Commit d31f1f0

Browse files
authored
feat: Automatic clock skew adjustment (#968)
1 parent fa85cd4 commit d31f1f0

File tree

28 files changed

+314
-75
lines changed

28 files changed

+314
-75
lines changed

Package.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,7 @@ let package = Package(
128128
),
129129
.target(
130130
name: "SmithyRetries",
131-
dependencies: [
132-
"SmithyRetriesAPI",
133-
.product(name: "AwsCommonRuntimeKit", package: "aws-crt-swift"),
134-
]
131+
dependencies: ["SmithyRetriesAPI"]
135132
),
136133
.target(
137134
name: "SmithyReadWrite",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import struct Foundation.TimeInterval
9+
import protocol Smithy.RequestMessage
10+
import protocol Smithy.ResponseMessage
11+
12+
/// A closure that is called to determine what, if any, correction should be made to the system's clock when signing requests.
13+
///
14+
/// Returns: a `TimeInterval` that represents the correction ("clock skew") that should be applied to the system clock,
15+
/// or `nil` if no correction should be applied.
16+
/// - Parameters:
17+
/// - request: The request that was sent to the server. (Typically this is a `HTTPRequest`)
18+
/// - response: The response that was returned from the server. (Typically this is a `HTTPResponse`)
19+
/// - error: The error that was returned by the server; typically this is a `ServiceError` with an error code that
20+
/// indicates clock skew is or might be the cause of the failed request.
21+
/// - previous: The previously measured clock skew value, or `nil` if none was recorded.
22+
/// - Returns: The calculated clock skew `TimeInterval`, or `nil` if no clock skew adjustment is to be applied.
23+
public typealias ClockSkewProvider<Request: RequestMessage, Response: ResponseMessage> =
24+
@Sendable (_ request: Request, _ response: Response, _ error: Error, _ previous: TimeInterval?) -> TimeInterval?
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import struct Foundation.TimeInterval
9+
10+
/// Serves as a concurrency-safe repository for recorded clock skew values, keyed by hostname.
11+
///
12+
/// Storing clock skew values in a shared repository allows future operations to include the clock skew
13+
/// correction on their initial attempt. It also allows multiple clients to share clock skew values.
14+
actor ClockSkewStore {
15+
static let shared = ClockSkewStore()
16+
17+
/// Stores clock skew values, keyed by hostname.
18+
private var clockSkewStorage = [String: TimeInterval]()
19+
20+
// Disable creation of new instances of this type.
21+
private init() {}
22+
23+
/// Retrieves the clock skew value for the passed host.
24+
/// - Parameter host: The host name for which to retrieve clock skew
25+
/// - Returns: The clock skew for the indicated host or `nil` if none is set.
26+
func clockSkew(host: String) async -> TimeInterval? {
27+
clockSkewStorage[host]
28+
}
29+
30+
/// Calls the passed block to modify the clock skew value for the passed host.
31+
///
32+
/// Returns a `Bool` indicating whether the clock skew value changed.
33+
/// - Parameters:
34+
/// - host: The host for which clock skew is to be updated.
35+
/// - block: A block that accepts the previous clock skew value, and returns the updated value.
36+
/// - Returns: `true` if the clock skew value was changed, `false` otherwise.
37+
func setClockSkew(host: String, block: @Sendable (TimeInterval?) -> TimeInterval?) async -> Bool {
38+
let previousValue = clockSkewStorage[host]
39+
let newValue = block(previousValue)
40+
clockSkewStorage[host] = newValue
41+
return newValue != previousValue
42+
}
43+
44+
/// Clears all saved clock skew values. For use during testing.
45+
func clear() async {
46+
clockSkewStorage = [:]
47+
}
48+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import struct Foundation.Date
9+
import struct Foundation.TimeInterval
10+
import protocol Smithy.RequestMessage
11+
import protocol Smithy.ResponseMessage
12+
13+
public enum DefaultClockSkewProvider {
14+
15+
public static func provider<Request: RequestMessage, Response: ResponseMessage>(
16+
) -> ClockSkewProvider<Request, Response> {
17+
return clockSkew(request:response:error:previous:)
18+
}
19+
20+
@Sendable
21+
private static func clockSkew<Request: RequestMessage, Response: ResponseMessage>(
22+
request: Request,
23+
response: Response,
24+
error: Error,
25+
previous: TimeInterval?
26+
) -> TimeInterval? {
27+
// The default clock skew provider does not determine clock skew.
28+
return nil
29+
}
30+
}

Sources/ClientRuntime/Networking/Http/Middlewares/SignerMiddleware.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,14 @@ extension SignerMiddleware: ApplySigner {
5050
)
5151
}
5252

53-
// Check if CRT should be provided a pre-computed Sha256 SignedBodyValue
5453
var updatedSigningProperties = signingProperties
54+
55+
// Look up & apply any applicable clock skew for this request
56+
if let clockSkew = await ClockSkewStore.shared.clockSkew(host: request.host) {
57+
updatedSigningProperties.set(key: AttributeKey(name: "ClockSkew"), value: clockSkew)
58+
}
59+
60+
// Check if CRT should be provided a pre-computed Sha256 SignedBodyValue
5561
let sha256: String? = attributes.get(key: AttributeKey(name: "X-Amz-Content-Sha256"))
5662
if let bodyValue = sha256 {
5763
updatedSigningProperties.set(key: AttributeKey(name: "SignedBodyValue"), value: bodyValue)

Sources/ClientRuntime/Orchestrator/Orchestrator.swift

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import struct Foundation.Date
9+
import struct Foundation.TimeInterval
910
import class Smithy.Context
1011
import enum Smithy.ClientError
1112
import protocol Smithy.RequestMessage
@@ -76,6 +77,7 @@ public struct Orchestrator<
7677
private let deserialize: (ResponseType, Context) async throws -> OutputType
7778
private let retryStrategy: (any RetryStrategy)?
7879
private let retryErrorInfoProvider: (Error) -> RetryErrorInfo?
80+
private let clockSkewProvider: ClockSkewProvider<RequestType, ResponseType>
7981
private let telemetry: OrchestratorTelemetry
8082
private let selectAuthScheme: SelectAuthScheme
8183
private let applyEndpoint: any ApplyEndpoint<RequestType>
@@ -96,6 +98,12 @@ public struct Orchestrator<
9698
self.retryErrorInfoProvider = { _ in nil }
9799
}
98100

101+
if let clockSkewProvider = builder.clockSkewProvider {
102+
self.clockSkewProvider = clockSkewProvider
103+
} else {
104+
self.clockSkewProvider = { (_, _, _, _) in nil }
105+
}
106+
99107
if let selectAuthScheme = builder.selectAuthScheme {
100108
self.selectAuthScheme = selectAuthScheme
101109
} else {
@@ -262,9 +270,29 @@ public struct Orchestrator<
262270
do {
263271
_ = try context.getOutput()
264272
await strategy.recordSuccess(token: token)
265-
} catch let error {
266-
// If we can't get errorInfo, we definitely can't retry
267-
guard let errorInfo = retryErrorInfoProvider(error) else { return }
273+
} catch {
274+
let clockSkewStore = ClockSkewStore.shared
275+
var clockSkewErrorInfo: RetryErrorInfo?
276+
277+
// Clock skew can't be calculated when there is no request/response, so safe-unwrap them
278+
if let request = context.getRequest(), let response = context.getResponse() {
279+
// Assign clock skew to local var to prevent capturing self in block below
280+
let clockSkewProvider = self.clockSkewProvider
281+
// Check for clock skew, and if found, store in the shared map of hosts to clock skews
282+
let clockSkewDidChange = await clockSkewStore.setClockSkew(host: request.host) { @Sendable previous in
283+
clockSkewProvider(request, response, error, previous)
284+
}
285+
// Retry only if the new clock skew is different than previous.
286+
// If clock skew was unchanged on this errored request, then clock skew is likely not the
287+
// cause of the error
288+
if clockSkewDidChange {
289+
clockSkewErrorInfo = .clockSkewErrorInfo
290+
}
291+
}
292+
293+
// If clock skew was found or has substantially changed, then retry on that
294+
// Else get errorInfo on the error
295+
guard let errorInfo = clockSkewErrorInfo ?? retryErrorInfoProvider(error) else { return }
268296

269297
// If the body is a nonseekable stream, we also can't retry
270298
do {
@@ -459,3 +487,11 @@ public struct Orchestrator<
459487
}
460488
}
461489
}
490+
491+
private extension RetryErrorInfo {
492+
493+
/// `RetryErrorInfo` value used to signal that a retry should be performed due to clock skew.
494+
static var clockSkewErrorInfo: RetryErrorInfo {
495+
RetryErrorInfo(errorType: .clientError, retryAfterHint: nil, isTimeout: false)
496+
}
497+
}

Sources/ClientRuntime/Orchestrator/OrchestratorBuilder.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
// SPDX-License-Identifier: Apache-2.0
66
//
77

8+
import struct Foundation.Date
9+
import struct Foundation.TimeInterval
810
import class Smithy.Context
911
import class Smithy.ContextBuilder
1012
import protocol Smithy.RequestMessage
@@ -33,6 +35,7 @@ public class OrchestratorBuilder<
3335
internal var deserialize: ((ResponseType, Context) async throws -> OutputType)?
3436
internal var retryStrategy: (any RetryStrategy)?
3537
internal var retryErrorInfoProvider: ((Error) -> RetryErrorInfo?)?
38+
internal var clockSkewProvider: (ClockSkewProvider<RequestType, ResponseType>)?
3639
internal var telemetry: OrchestratorTelemetry?
3740
internal var selectAuthScheme: SelectAuthScheme?
3841
internal var applyEndpoint: (any ApplyEndpoint<RequestType>)?
@@ -105,6 +108,14 @@ public class OrchestratorBuilder<
105108
return self
106109
}
107110

111+
/// - Parameter clockSkewProvider: Function that turns operation errors into a clock skew value
112+
/// - Returns: Builder
113+
@discardableResult
114+
public func clockSkewProvider(_ clockSkewProvider: @escaping ClockSkewProvider<RequestType, ResponseType>) -> Self {
115+
self.clockSkewProvider = clockSkewProvider
116+
return self
117+
}
118+
108119
/// - Parameter telemetry: container for telemetry
109120
/// - Returns: Builder
110121
@discardableResult

Sources/Smithy/RequestMessage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//
77

88
/// Message that is sent from client to service.
9-
public protocol RequestMessage {
9+
public protocol RequestMessage: Sendable {
1010

1111
/// The type of the builder that can build this request message.
1212
associatedtype RequestBuilderType: RequestMessageBuilder<Self>

Sources/Smithy/ResponseMessage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//
77

88
/// Message that is sent from service to client.
9-
public protocol ResponseMessage {
9+
public protocol ResponseMessage: Sendable {
1010

1111
/// The body of the response.
1212
var body: ByteStream { get }

Sources/SmithyHTTPAPI/HTTPRequest.swift

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import protocol Smithy.RequestMessage
1313
import protocol Smithy.RequestMessageBuilder
1414
import enum Smithy.ByteStream
1515
import enum Smithy.ClientError
16+
import struct Foundation.Date
1617
import struct Foundation.CharacterSet
1718
import struct Foundation.URLQueryItem
1819
import struct Foundation.URLComponents
@@ -35,24 +36,27 @@ public final class HTTPRequest: RequestMessage, @unchecked Sendable {
3536
public var path: String { destination.path }
3637
public var queryItems: [URIQueryItem]? { destination.queryItems }
3738
public var trailingHeaders: Headers = Headers()
38-
public var endpoint: Endpoint {
39-
return Endpoint(uri: self.destination, headers: self.headers)
40-
}
39+
public var endpoint: Endpoint { .init(uri: destination, headers: headers) }
40+
public internal(set) var signedAt: Date?
4141

4242
public convenience init(method: HTTPMethodType,
4343
endpoint: Endpoint,
4444
body: ByteStream = ByteStream.noStream) {
4545
self.init(method: method, uri: endpoint.uri, headers: endpoint.headers, body: body)
4646
}
4747

48-
public init(method: HTTPMethodType,
49-
uri: URI,
50-
headers: Headers,
51-
body: ByteStream = ByteStream.noStream) {
48+
public init(
49+
method: HTTPMethodType,
50+
uri: URI,
51+
headers: Headers,
52+
body: ByteStream = ByteStream.noStream,
53+
signedAt: Date? = nil
54+
) {
5255
self.method = method
5356
self.destination = uri
5457
self.headers = headers
5558
self.body = body
59+
self.signedAt = signedAt
5660
}
5761

5862
public func toBuilder() -> HTTPRequestBuilder {
@@ -66,6 +70,7 @@ public final class HTTPRequest: RequestMessage, @unchecked Sendable {
6670
.withPort(self.destination.port)
6771
.withProtocol(self.destination.scheme)
6872
.withQueryItems(self.destination.queryItems)
73+
.withSignedAt(signedAt)
6974
return builder
7075
}
7176

@@ -156,6 +161,7 @@ public final class HTTPRequestBuilder: RequestMessageBuilder {
156161
public private(set) var port: UInt16?
157162
public private(set) var protocolType: URIScheme = .https
158163
public private(set) var trailingHeaders: Headers = Headers()
164+
public private(set) var signedAt: Date?
159165

160166
public var currentQueryItems: [URIQueryItem]? {
161167
return queryItems
@@ -254,6 +260,12 @@ public final class HTTPRequestBuilder: RequestMessageBuilder {
254260
return self
255261
}
256262

263+
@discardableResult
264+
public func withSignedAt(_ value: Date?) -> HTTPRequestBuilder {
265+
self.signedAt = value
266+
return self
267+
}
268+
257269
public func build() -> HTTPRequest {
258270
let uri = URIBuilder()
259271
.withScheme(protocolType)
@@ -262,7 +274,7 @@ public final class HTTPRequestBuilder: RequestMessageBuilder {
262274
.withPort(port)
263275
.withQueryItems(queryItems)
264276
.build()
265-
return HTTPRequest(method: methodType, uri: uri, headers: headers, body: body)
277+
return HTTPRequest(method: methodType, uri: uri, headers: headers, body: body, signedAt: signedAt)
266278
}
267279
}
268280

0 commit comments

Comments
 (0)