diff --git a/Package.swift b/Package.swift index 754a3ccce..a781bbcb8 100644 --- a/Package.swift +++ b/Package.swift @@ -128,10 +128,7 @@ let package = Package( ), .target( name: "SmithyRetries", - dependencies: [ - "SmithyRetriesAPI", - .product(name: "AwsCommonRuntimeKit", package: "aws-crt-swift"), - ] + dependencies: ["SmithyRetriesAPI"] ), .target( name: "SmithyReadWrite", diff --git a/Sources/ClientRuntime/ClockSkew/ClockSkewProvider.swift b/Sources/ClientRuntime/ClockSkew/ClockSkewProvider.swift new file mode 100644 index 000000000..c53aec3a7 --- /dev/null +++ b/Sources/ClientRuntime/ClockSkew/ClockSkewProvider.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import struct Foundation.TimeInterval +import protocol Smithy.RequestMessage +import protocol Smithy.ResponseMessage + +/// A closure that is called to determine what, if any, correction should be made to the system's clock when signing requests. +/// +/// Returns: a `TimeInterval` that represents the correction ("clock skew") that should be applied to the system clock, +/// or `nil` if no correction should be applied. +/// - Parameters: +/// - request: The request that was sent to the server. (Typically this is a `HTTPRequest`) +/// - response: The response that was returned from the server. (Typically this is a `HTTPResponse`) +/// - error: The error that was returned by the server; typically this is a `ServiceError` with an error code that +/// indicates clock skew is or might be the cause of the failed request. +/// - previous: The previously measured clock skew value, or `nil` if none was recorded. +/// - Returns: The calculated clock skew `TimeInterval`, or `nil` if no clock skew adjustment is to be applied. +public typealias ClockSkewProvider = + @Sendable (_ request: Request, _ response: Response, _ error: Error, _ previous: TimeInterval?) -> TimeInterval? diff --git a/Sources/ClientRuntime/ClockSkew/ClockSkewStore.swift b/Sources/ClientRuntime/ClockSkew/ClockSkewStore.swift new file mode 100644 index 000000000..56cdbf6ca --- /dev/null +++ b/Sources/ClientRuntime/ClockSkew/ClockSkewStore.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import struct Foundation.TimeInterval + +/// Serves as a concurrency-safe repository for recorded clock skew values, keyed by hostname. +/// +/// Storing clock skew values in a shared repository allows future operations to include the clock skew +/// correction on their initial attempt. It also allows multiple clients to share clock skew values. +actor ClockSkewStore { + static let shared = ClockSkewStore() + + /// Stores clock skew values, keyed by hostname. + private var clockSkewStorage = [String: TimeInterval]() + + // Disable creation of new instances of this type. + private init() {} + + /// Retrieves the clock skew value for the passed host. + /// - Parameter host: The host name for which to retrieve clock skew + /// - Returns: The clock skew for the indicated host or `nil` if none is set. + func clockSkew(host: String) async -> TimeInterval? { + clockSkewStorage[host] + } + + /// Calls the passed block to modify the clock skew value for the passed host. + /// + /// Returns a `Bool` indicating whether the clock skew value changed. + /// - Parameters: + /// - host: The host for which clock skew is to be updated. + /// - block: A block that accepts the previous clock skew value, and returns the updated value. + /// - Returns: `true` if the clock skew value was changed, `false` otherwise. + func setClockSkew(host: String, block: @Sendable (TimeInterval?) -> TimeInterval?) async -> Bool { + let previousValue = clockSkewStorage[host] + let newValue = block(previousValue) + clockSkewStorage[host] = newValue + return newValue != previousValue + } + + /// Clears all saved clock skew values. For use during testing. + func clear() async { + clockSkewStorage = [:] + } +} diff --git a/Sources/ClientRuntime/ClockSkew/DefaultClockSkewProvider.swift b/Sources/ClientRuntime/ClockSkew/DefaultClockSkewProvider.swift new file mode 100644 index 000000000..96bafe5c7 --- /dev/null +++ b/Sources/ClientRuntime/ClockSkew/DefaultClockSkewProvider.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import struct Foundation.Date +import struct Foundation.TimeInterval +import protocol Smithy.RequestMessage +import protocol Smithy.ResponseMessage + +public enum DefaultClockSkewProvider { + + public static func provider( + ) -> ClockSkewProvider { + return clockSkew(request:response:error:previous:) + } + + @Sendable + private static func clockSkew( + request: Request, + response: Response, + error: Error, + previous: TimeInterval? + ) -> TimeInterval? { + // The default clock skew provider does not determine clock skew. + return nil + } +} diff --git a/Sources/ClientRuntime/Networking/Http/Middlewares/SignerMiddleware.swift b/Sources/ClientRuntime/Networking/Http/Middlewares/SignerMiddleware.swift index df7c42b77..1e7432eaa 100644 --- a/Sources/ClientRuntime/Networking/Http/Middlewares/SignerMiddleware.swift +++ b/Sources/ClientRuntime/Networking/Http/Middlewares/SignerMiddleware.swift @@ -50,8 +50,14 @@ extension SignerMiddleware: ApplySigner { ) } - // Check if CRT should be provided a pre-computed Sha256 SignedBodyValue var updatedSigningProperties = signingProperties + + // Look up & apply any applicable clock skew for this request + if let clockSkew = await ClockSkewStore.shared.clockSkew(host: request.host) { + updatedSigningProperties.set(key: AttributeKey(name: "ClockSkew"), value: clockSkew) + } + + // Check if CRT should be provided a pre-computed Sha256 SignedBodyValue let sha256: String? = attributes.get(key: AttributeKey(name: "X-Amz-Content-Sha256")) if let bodyValue = sha256 { updatedSigningProperties.set(key: AttributeKey(name: "SignedBodyValue"), value: bodyValue) diff --git a/Sources/ClientRuntime/Orchestrator/Orchestrator.swift b/Sources/ClientRuntime/Orchestrator/Orchestrator.swift index d2c7ad2ab..313c56f36 100644 --- a/Sources/ClientRuntime/Orchestrator/Orchestrator.swift +++ b/Sources/ClientRuntime/Orchestrator/Orchestrator.swift @@ -6,6 +6,7 @@ // import struct Foundation.Date +import struct Foundation.TimeInterval import class Smithy.Context import enum Smithy.ClientError import protocol Smithy.RequestMessage @@ -76,6 +77,7 @@ public struct Orchestrator< private let deserialize: (ResponseType, Context) async throws -> OutputType private let retryStrategy: (any RetryStrategy)? private let retryErrorInfoProvider: (Error) -> RetryErrorInfo? + private let clockSkewProvider: ClockSkewProvider private let telemetry: OrchestratorTelemetry private let selectAuthScheme: SelectAuthScheme private let applyEndpoint: any ApplyEndpoint @@ -96,6 +98,12 @@ public struct Orchestrator< self.retryErrorInfoProvider = { _ in nil } } + if let clockSkewProvider = builder.clockSkewProvider { + self.clockSkewProvider = clockSkewProvider + } else { + self.clockSkewProvider = { (_, _, _, _) in nil } + } + if let selectAuthScheme = builder.selectAuthScheme { self.selectAuthScheme = selectAuthScheme } else { @@ -262,9 +270,29 @@ public struct Orchestrator< do { _ = try context.getOutput() await strategy.recordSuccess(token: token) - } catch let error { - // If we can't get errorInfo, we definitely can't retry - guard let errorInfo = retryErrorInfoProvider(error) else { return } + } catch { + let clockSkewStore = ClockSkewStore.shared + var clockSkewErrorInfo: RetryErrorInfo? + + // Clock skew can't be calculated when there is no request/response, so safe-unwrap them + if let request = context.getRequest(), let response = context.getResponse() { + // Assign clock skew to local var to prevent capturing self in block below + let clockSkewProvider = self.clockSkewProvider + // Check for clock skew, and if found, store in the shared map of hosts to clock skews + let clockSkewDidChange = await clockSkewStore.setClockSkew(host: request.host) { @Sendable previous in + clockSkewProvider(request, response, error, previous) + } + // Retry only if the new clock skew is different than previous. + // If clock skew was unchanged on this errored request, then clock skew is likely not the + // cause of the error + if clockSkewDidChange { + clockSkewErrorInfo = .clockSkewErrorInfo + } + } + + // If clock skew was found or has substantially changed, then retry on that + // Else get errorInfo on the error + guard let errorInfo = clockSkewErrorInfo ?? retryErrorInfoProvider(error) else { return } // If the body is a nonseekable stream, we also can't retry do { @@ -459,3 +487,11 @@ public struct Orchestrator< } } } + +private extension RetryErrorInfo { + + /// `RetryErrorInfo` value used to signal that a retry should be performed due to clock skew. + static var clockSkewErrorInfo: RetryErrorInfo { + RetryErrorInfo(errorType: .clientError, retryAfterHint: nil, isTimeout: false) + } +} diff --git a/Sources/ClientRuntime/Orchestrator/OrchestratorBuilder.swift b/Sources/ClientRuntime/Orchestrator/OrchestratorBuilder.swift index c5bbc0bf0..e9280794c 100644 --- a/Sources/ClientRuntime/Orchestrator/OrchestratorBuilder.swift +++ b/Sources/ClientRuntime/Orchestrator/OrchestratorBuilder.swift @@ -5,6 +5,8 @@ // SPDX-License-Identifier: Apache-2.0 // +import struct Foundation.Date +import struct Foundation.TimeInterval import class Smithy.Context import class Smithy.ContextBuilder import protocol Smithy.RequestMessage @@ -33,6 +35,7 @@ public class OrchestratorBuilder< internal var deserialize: ((ResponseType, Context) async throws -> OutputType)? internal var retryStrategy: (any RetryStrategy)? internal var retryErrorInfoProvider: ((Error) -> RetryErrorInfo?)? + internal var clockSkewProvider: (ClockSkewProvider)? internal var telemetry: OrchestratorTelemetry? internal var selectAuthScheme: SelectAuthScheme? internal var applyEndpoint: (any ApplyEndpoint)? @@ -105,6 +108,14 @@ public class OrchestratorBuilder< return self } + /// - Parameter clockSkewProvider: Function that turns operation errors into a clock skew value + /// - Returns: Builder + @discardableResult + public func clockSkewProvider(_ clockSkewProvider: @escaping ClockSkewProvider) -> Self { + self.clockSkewProvider = clockSkewProvider + return self + } + /// - Parameter telemetry: container for telemetry /// - Returns: Builder @discardableResult diff --git a/Sources/Smithy/RequestMessage.swift b/Sources/Smithy/RequestMessage.swift index 2000a1d7d..4ce885969 100644 --- a/Sources/Smithy/RequestMessage.swift +++ b/Sources/Smithy/RequestMessage.swift @@ -6,7 +6,7 @@ // /// Message that is sent from client to service. -public protocol RequestMessage { +public protocol RequestMessage: Sendable { /// The type of the builder that can build this request message. associatedtype RequestBuilderType: RequestMessageBuilder diff --git a/Sources/Smithy/ResponseMessage.swift b/Sources/Smithy/ResponseMessage.swift index 0ae0b317a..18adc7515 100644 --- a/Sources/Smithy/ResponseMessage.swift +++ b/Sources/Smithy/ResponseMessage.swift @@ -6,7 +6,7 @@ // /// Message that is sent from service to client. -public protocol ResponseMessage { +public protocol ResponseMessage: Sendable { /// The body of the response. var body: ByteStream { get } diff --git a/Sources/SmithyHTTPAPI/HTTPRequest.swift b/Sources/SmithyHTTPAPI/HTTPRequest.swift index 224fea91f..5e6cde596 100644 --- a/Sources/SmithyHTTPAPI/HTTPRequest.swift +++ b/Sources/SmithyHTTPAPI/HTTPRequest.swift @@ -13,6 +13,7 @@ import protocol Smithy.RequestMessage import protocol Smithy.RequestMessageBuilder import enum Smithy.ByteStream import enum Smithy.ClientError +import struct Foundation.Date import struct Foundation.CharacterSet import struct Foundation.URLQueryItem import struct Foundation.URLComponents @@ -35,9 +36,8 @@ public final class HTTPRequest: RequestMessage, @unchecked Sendable { public var path: String { destination.path } public var queryItems: [URIQueryItem]? { destination.queryItems } public var trailingHeaders: Headers = Headers() - public var endpoint: Endpoint { - return Endpoint(uri: self.destination, headers: self.headers) - } + public var endpoint: Endpoint { .init(uri: destination, headers: headers) } + public internal(set) var signedAt: Date? public convenience init(method: HTTPMethodType, endpoint: Endpoint, @@ -45,14 +45,18 @@ public final class HTTPRequest: RequestMessage, @unchecked Sendable { self.init(method: method, uri: endpoint.uri, headers: endpoint.headers, body: body) } - public init(method: HTTPMethodType, - uri: URI, - headers: Headers, - body: ByteStream = ByteStream.noStream) { + public init( + method: HTTPMethodType, + uri: URI, + headers: Headers, + body: ByteStream = ByteStream.noStream, + signedAt: Date? = nil + ) { self.method = method self.destination = uri self.headers = headers self.body = body + self.signedAt = signedAt } public func toBuilder() -> HTTPRequestBuilder { @@ -66,6 +70,7 @@ public final class HTTPRequest: RequestMessage, @unchecked Sendable { .withPort(self.destination.port) .withProtocol(self.destination.scheme) .withQueryItems(self.destination.queryItems) + .withSignedAt(signedAt) return builder } @@ -156,6 +161,7 @@ public final class HTTPRequestBuilder: RequestMessageBuilder { public private(set) var port: UInt16? public private(set) var protocolType: URIScheme = .https public private(set) var trailingHeaders: Headers = Headers() + public private(set) var signedAt: Date? public var currentQueryItems: [URIQueryItem]? { return queryItems @@ -254,6 +260,12 @@ public final class HTTPRequestBuilder: RequestMessageBuilder { return self } + @discardableResult + public func withSignedAt(_ value: Date?) -> HTTPRequestBuilder { + self.signedAt = value + return self + } + public func build() -> HTTPRequest { let uri = URIBuilder() .withScheme(protocolType) @@ -262,7 +274,7 @@ public final class HTTPRequestBuilder: RequestMessageBuilder { .withPort(port) .withQueryItems(queryItems) .build() - return HTTPRequest(method: methodType, uri: uri, headers: headers, body: body) + return HTTPRequest(method: methodType, uri: uri, headers: headers, body: body, signedAt: signedAt) } } diff --git a/Sources/SmithyHTTPAuth/SigV4Signer.swift b/Sources/SmithyHTTPAuth/SigV4Signer.swift index 38fa755ee..9c04fbb9f 100644 --- a/Sources/SmithyHTTPAuth/SigV4Signer.swift +++ b/Sources/SmithyHTTPAuth/SigV4Signer.swift @@ -5,6 +5,9 @@ // SPDX-License-Identifier: Apache-2.0 // +import struct Foundation.Date +import struct Foundation.TimeInterval +import struct Foundation.URL import class AwsCommonRuntimeKit.HTTPRequestBase import class AwsCommonRuntimeKit.Signer import class SmithyHTTPAPI.HTTPRequest @@ -25,9 +28,6 @@ import struct Smithy.Attributes import struct Smithy.SwiftLogger import struct SmithyIdentity.AWSCredentialIdentity import struct SmithyHTTPAuthAPI.SigningFlags -import struct Foundation.Date -import struct Foundation.TimeInterval -import struct Foundation.URL import SmithyHTTPClient public class SigV4Signer: SmithyHTTPAuthAPI.Signer, @unchecked Sendable { @@ -52,7 +52,13 @@ public class SigV4Signer: SmithyHTTPAuthAPI.Signer, @unchecked Sendable { ) } - let signingConfig = try constructSigningConfig(identity: identity, signingProperties: signingProperties) + let signedAt = Date() + + let signingConfig = try constructSigningConfig( + identity: identity, + signingProperties: signingProperties, + signedAt: signedAt + ) let unsignedRequest = requestBuilder.build() let crtUnsignedRequest: HTTPRequestBase = isBidirectionalStreamingEnabled ? @@ -66,7 +72,11 @@ public class SigV4Signer: SmithyHTTPAuthAPI.Signer, @unchecked Sendable { config: crtSigningConfig ) - let sdkSignedRequest = requestBuilder.update(from: crtSignedRequest, originalRequest: unsignedRequest) + let sdkSignedRequest = requestBuilder.update( + from: crtSignedRequest, + originalRequest: unsignedRequest, + signedAt: signedAt + ) // Return signed request return sdkSignedRequest @@ -74,7 +84,8 @@ public class SigV4Signer: SmithyHTTPAuthAPI.Signer, @unchecked Sendable { private func constructSigningConfig( identity: AWSCredentialIdentity, - signingProperties: Smithy.Attributes + signingProperties: Smithy.Attributes, + signedAt: Date ) throws -> AWSSigningConfig { guard let unsignedBody = signingProperties.get(key: SigningPropertyKeys.unsignedBody) else { throw Smithy.ClientError.authError( @@ -97,7 +108,8 @@ public class SigV4Signer: SmithyHTTPAuthAPI.Signer, @unchecked Sendable { ) } - let expiration: TimeInterval = signingProperties.get(key: SigningPropertyKeys.expiration) ?? 0 + let clockSkew = signingProperties.get(key: SigningPropertyKeys.clockSkew) ?? 0.0 + let expiration = signingProperties.get(key: SigningPropertyKeys.expiration) ?? 0.0 let signedBodyHeader: AWSSignedBodyHeader = signingProperties.get(key: SigningPropertyKeys.signedBodyHeader) ?? .none @@ -127,7 +139,7 @@ public class SigV4Signer: SmithyHTTPAuthAPI.Signer, @unchecked Sendable { signedBodyHeader: signedBodyHeader, signedBodyValue: signedBodyValue, flags: flags, - date: Date(), + date: signedAt.addingTimeInterval(clockSkew), service: signingName, region: signingRegion, signatureType: signatureType, diff --git a/Sources/SmithyHTTPAuthAPI/Context/SigningPropertyKeys.swift b/Sources/SmithyHTTPAuthAPI/Context/SigningPropertyKeys.swift index 4f4e521df..923ef7658 100644 --- a/Sources/SmithyHTTPAuthAPI/Context/SigningPropertyKeys.swift +++ b/Sources/SmithyHTTPAuthAPI/Context/SigningPropertyKeys.swift @@ -14,6 +14,7 @@ public enum SigningPropertyKeys { // Keys used to store/retrieve AWSSigningConfig fields in/from signingProperties passed to AWSSigV4Signer public static let bidirectionalStreaming = AttributeKey(name: "BidirectionalStreaming") public static let checksum = AttributeKey(name: "checksum") + public static let clockSkew = AttributeKey(name: "ClockSkew") public static let expiration = AttributeKey(name: "Expiration") public static let isChunkedEligibleStream = AttributeKey(name: "isChunkedEligibleStream") public static let omitSessionToken = AttributeKey(name: "OmitSessionToken") diff --git a/Sources/SmithyHTTPClient/SdkHttpRequestBuilder+HTTPRequestBase.swift b/Sources/SmithyHTTPClient/SdkHttpRequestBuilder+HTTPRequestBase.swift index 75cf6fba1..b943b9caa 100644 --- a/Sources/SmithyHTTPClient/SdkHttpRequestBuilder+HTTPRequestBase.swift +++ b/Sources/SmithyHTTPClient/SdkHttpRequestBuilder+HTTPRequestBase.swift @@ -5,11 +5,12 @@ // SPDX-License-Identifier: Apache-2.0 // +import struct Foundation.Date +import struct Foundation.URLComponents import struct Smithy.URIQueryItem import class SmithyHTTPAPI.HTTPRequest import class SmithyHTTPAPI.HTTPRequestBuilder import struct SmithyHTTPAPI.Headers -import struct Foundation.URLComponents import AwsCommonRuntimeKit extension HTTPRequestBuilder { @@ -19,10 +20,15 @@ extension HTTPRequestBuilder { /// - crtRequest: the CRT request, this can be either a `HTTPRequest` or a `HTTP2Request` /// - originalRequest: the SDK request that is used to hold the original values /// - Returns: the builder - public func update(from crtRequest: HTTPRequestBase, originalRequest: HTTPRequest) -> HTTPRequestBuilder { + public func update( + from crtRequest: HTTPRequestBase, + originalRequest: HTTPRequest, + signedAt: Date + ) -> HTTPRequestBuilder { headers = convertSignedHeadersToHeaders(crtRequest: crtRequest) withMethod(originalRequest.method) withHost(originalRequest.host) + withSignedAt(signedAt) if let crtRequest = crtRequest as? AwsCommonRuntimeKit.HTTPRequest, let components = URLComponents(string: crtRequest.path) { withPath(components.percentEncodedPath) diff --git a/Sources/SmithyRetries/ExponentialBackOffJitterType.swift b/Sources/SmithyRetries/ExponentialBackOffJitterType.swift index af204b63b..a564b2c4e 100644 --- a/Sources/SmithyRetries/ExponentialBackOffJitterType.swift +++ b/Sources/SmithyRetries/ExponentialBackOffJitterType.swift @@ -4,7 +4,6 @@ // // SPDX-License-Identifier: Apache-2.0 // -import AwsCommonRuntimeKit public enum ExponentialBackOffJitterType: Sendable { case `default` @@ -12,15 +11,3 @@ public enum ExponentialBackOffJitterType: Sendable { case full case decorrelated } - -extension ExponentialBackOffJitterType { - - func toCRTType() -> AwsCommonRuntimeKit.ExponentialBackoffJitterMode { - switch self { - case .default: return .default - case .none: return .none - case .full: return .full - case .decorrelated: return .decorrelated - } - } -} diff --git a/Sources/SmithyRetries/RetryErrorType+CRT.swift b/Sources/SmithyRetries/RetryErrorType+CRT.swift deleted file mode 100644 index 8b523fc3f..000000000 --- a/Sources/SmithyRetries/RetryErrorType+CRT.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Copyright Amazon.com Inc. or its affiliates. -// All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -import enum SmithyRetriesAPI.RetryErrorType -import AwsCommonRuntimeKit - -public extension RetryErrorType { - - func toCRTType() -> AwsCommonRuntimeKit.RetryError { - switch self { - case .transient: return .transient - case .throttling: return .throttling - case .serverError: return .serverError - case .clientError: return .clientError - } - } -} diff --git a/Sources/SmithyRetriesAPI/RetryBackoffStrategy.swift b/Sources/SmithyRetriesAPI/RetryBackoffStrategy.swift index 04b2a09f2..83dc89ba2 100644 --- a/Sources/SmithyRetriesAPI/RetryBackoffStrategy.swift +++ b/Sources/SmithyRetriesAPI/RetryBackoffStrategy.swift @@ -5,7 +5,7 @@ // SPDX-License-Identifier: Apache-2.0 // -import Foundation +import struct Foundation.TimeInterval public protocol RetryBackoffStrategy: Sendable { /// Returns a Duration that a caller performing retries should use for delaying between retries. In a green-threads context, this would be diff --git a/Sources/SmithyRetriesAPI/RetryErrorInfoProvider.swift b/Sources/SmithyRetriesAPI/RetryErrorInfoProvider.swift index e0e7b63b7..302ed3feb 100644 --- a/Sources/SmithyRetriesAPI/RetryErrorInfoProvider.swift +++ b/Sources/SmithyRetriesAPI/RetryErrorInfoProvider.swift @@ -5,8 +5,6 @@ // SPDX-License-Identifier: Apache-2.0 // -import Foundation - /// A type that can triage errors for determining how & if to retry the error. /// /// Based on the error's content & properties, the retry error info provider will determine the general retry type of this error diff --git a/Sources/SmithyRetriesAPI/RetryStrategy.swift b/Sources/SmithyRetriesAPI/RetryStrategy.swift index e8393cfdc..99fee639b 100644 --- a/Sources/SmithyRetriesAPI/RetryStrategy.swift +++ b/Sources/SmithyRetriesAPI/RetryStrategy.swift @@ -5,8 +5,6 @@ // SPDX-License-Identifier: Apache-2.0 // -import Foundation - public protocol RetryStrategy { associatedtype Token: RetryToken diff --git a/Tests/ClientRuntimeTests/NetworkingTests/Http/SdkRequestBuilderTests.swift b/Tests/ClientRuntimeTests/NetworkingTests/Http/SdkRequestBuilderTests.swift index 088a81f88..c190b4175 100644 --- a/Tests/ClientRuntimeTests/NetworkingTests/Http/SdkRequestBuilderTests.swift +++ b/Tests/ClientRuntimeTests/NetworkingTests/Http/SdkRequestBuilderTests.swift @@ -18,7 +18,7 @@ class SdkRequestBuilderTests: XCTestCase { let crtRequest = try HTTPRequest() crtRequest.path = pathToMatch - let updatedRequest = HTTPRequestBuilder().update(from: crtRequest, originalRequest: originalRequest).build() + let updatedRequest = HTTPRequestBuilder().update(from: crtRequest, originalRequest: originalRequest, signedAt: Date()).build() let updatedPath = [updatedRequest.destination.path, updatedRequest.destination.queryString].compactMap { $0 }.joined(separator: "?") XCTAssertEqual(pathToMatch, updatedPath) XCTAssertEqual(url, updatedRequest.destination.url?.absoluteString) diff --git a/Tests/ClientRuntimeTests/OrchestratorTests/OrchestratorTests.swift b/Tests/ClientRuntimeTests/OrchestratorTests/OrchestratorTests.swift index ea0ed9c5a..8cb8135df 100644 --- a/Tests/ClientRuntimeTests/OrchestratorTests/OrchestratorTests.swift +++ b/Tests/ClientRuntimeTests/OrchestratorTests/OrchestratorTests.swift @@ -41,6 +41,11 @@ class OrchestratorTests: XCTestCase { } } + struct TestClockSkewError: ServiceError, Error { + var typeName: String? { "TestClockSkewError" } + var message: String? { "" } + } + struct TestError: Error, Equatable, LocalizedError { let value: String @@ -1325,6 +1330,42 @@ class OrchestratorTests: XCTestCase { func computeNextBackoffDelay(attempt: Int) -> TimeInterval { 0.0 } } + func test_clockSkew_retriesWithClockSkewApplied() async throws { + await ClockSkewStore.shared.clear() + let host = "clockskew.test" + let input = TestInput(foo: "bar") + let trace = Trace() + var attempt = 0 + let executeRequest = TraceExecuteRequest(succeedAfter: 2, trace: trace) + let orchestrator = traceOrchestrator(trace: trace) + .retryStrategy(DefaultRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ImmediateBackoffStrategy()))) + .retryErrorInfoProvider({ ($0 is TestError) ? RetryErrorInfo(errorType: .clientError, retryAfterHint: nil, isTimeout: false) : nil }) + .clockSkewProvider({ _, _, error, previous in error is TestClockSkewError ? 300.0 : previous }) + .serialize({ (input: TestInput, builder: HTTPRequestBuilder, context) in + builder.withHost(host).withBody(.noStream).withSignedAt(Date()) + }) + .deserialize({ response, context in + attempt += 1 + switch attempt { + case 1: + throw TestClockSkewError() + case 2: + throw TestError(value: "") + default: + return TestOutput(bar: "") + } + }) + .executeRequest(executeRequest) + let result = await asyncResult { + return try await orchestrator.build().execute(input: input) + } + XCTAssertNoThrow(try result.get()) + XCTAssertEqual(executeRequest.requestCount, 3) + let recordedClockSkew = await ClockSkewStore.shared.clockSkew(host: host) + XCTAssertEqual(recordedClockSkew, 300.0) + await ClockSkewStore.shared.clear() + } + func test_retry_retriesDataBody() async throws { let input = TestInput(foo: "bar") let trace = Trace() diff --git a/Tests/SmithyHTTPClientTests/HttpRequestTests.swift b/Tests/SmithyHTTPClientTests/HttpRequestTests.swift index ccb169f08..b36ac1064 100644 --- a/Tests/SmithyHTTPClientTests/HttpRequestTests.swift +++ b/Tests/SmithyHTTPClientTests/HttpRequestTests.swift @@ -126,7 +126,7 @@ class HttpRequestTests: NetworkingTestUtils { let httpRequest = try builder.build().toHttpRequest() httpRequest.path = "/hello?foo=bar&quz=bar&signedthing=signed" - let updatedRequest = builder.update(from: httpRequest, originalRequest: builder.build()) + let updatedRequest = builder.update(from: httpRequest, originalRequest: builder.build(), signedAt: Date()) XCTAssertEqual(updatedRequest.path, "/hello") XCTAssertEqual(updatedRequest.queryItems.count, 3) @@ -150,4 +150,10 @@ class HttpRequestTests: NetworkingTestUtils { let endpoint = Endpoint(host: "", path: "") XCTAssertNil(endpoint.url, "An invalid endpoint should result in a nil URL.") } + + func test_requestBuilder_SignedAt() { + let now = Date() + let request = HTTPRequestBuilder().withSignedAt(now).build() + XCTAssertEqual(request.signedAt, now) + } } diff --git a/Tests/SmithyRetriesTests/RetryIntegrationTests.swift b/Tests/SmithyRetriesTests/RetryIntegrationTests.swift index 0e818b693..35a6a72c3 100644 --- a/Tests/SmithyRetriesTests/RetryIntegrationTests.swift +++ b/Tests/SmithyRetriesTests/RetryIntegrationTests.swift @@ -54,7 +54,13 @@ final class RetryIntegrationTests: XCTestCase { .attributes(context) .retryErrorInfoProvider(DefaultRetryErrorInfoProvider.errorInfo(for:)) .retryStrategy(subject) - .deserialize({ _, _ in TestOutputResponse() }) + .deserialize({ response, _ in + if response.statusCode == .ok { + return TestOutputResponse() + } else { + throw TestHTTPError(statusCode: response.statusCode) + } + }) .executeRequest(next) // Set the quota on the test output handler so it can verify state during tests @@ -221,9 +227,10 @@ private class TestOutputHandler: ExecuteRequest { // Return either a successful response or a HTTP error, depending on the directions in the test step. switch testStep.response { case .success: - return HTTPResponse() + return HTTPResponse(statusCode: .ok) case .httpError(let statusCode): - throw TestHTTPError(statusCode: statusCode) + let httpStatusCode = HTTPStatusCode(rawValue: statusCode)! + return HTTPResponse(statusCode: httpStatusCode) } } @@ -301,9 +308,8 @@ private class TestOutputHandler: ExecuteRequest { private struct TestHTTPError: HTTPError, Error { var httpResponse: HTTPResponse - init(statusCode: Int) { - guard let statusCodeValue = HTTPStatusCode(rawValue: statusCode) else { fatalError("Unrecognized HTTP code") } - self.httpResponse = HTTPResponse(statusCode: statusCodeValue) + init(statusCode: HTTPStatusCode) { + self.httpResponse = HTTPResponse(statusCode: statusCode) } } diff --git a/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/HTTPBindingProtocolGenerator.kt b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/HTTPBindingProtocolGenerator.kt index d9a1b81bd..3b277f731 100644 --- a/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/HTTPBindingProtocolGenerator.kt +++ b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/HTTPBindingProtocolGenerator.kt @@ -41,6 +41,7 @@ import software.amazon.smithy.swift.codegen.events.MessageMarshallableGenerator import software.amazon.smithy.swift.codegen.events.MessageUnmarshallableGenerator import software.amazon.smithy.swift.codegen.integration.httpResponse.HTTPResponseGenerator import software.amazon.smithy.swift.codegen.integration.middlewares.AuthSchemeMiddleware +import software.amazon.smithy.swift.codegen.integration.middlewares.ClockSkewMiddleware import software.amazon.smithy.swift.codegen.integration.middlewares.ContentLengthMiddleware import software.amazon.smithy.swift.codegen.integration.middlewares.ContentMD5Middleware import software.amazon.smithy.swift.codegen.integration.middlewares.ContentTypeMiddleware @@ -437,6 +438,7 @@ abstract class HTTPBindingProtocolGenerator( ) operationMiddleware.appendMiddleware(operation, DeserializeMiddleware(ctx.model, ctx.symbolProvider)) operationMiddleware.appendMiddleware(operation, LoggingMiddleware(ctx.model, ctx.symbolProvider)) + operationMiddleware.appendMiddleware(operation, ClockSkewMiddleware(ctx.model, ctx.symbolProvider, clockSkewProviderSymbol)) operationMiddleware.appendMiddleware(operation, RetryMiddleware(ctx.model, ctx.symbolProvider, retryErrorInfoProviderSymbol)) operationMiddleware.appendMiddleware(operation, SignerMiddleware(ctx.model, ctx.symbolProvider)) addProtocolSpecificMiddleware(ctx, operation) diff --git a/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/ProtocolGenerator.kt b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/ProtocolGenerator.kt index 3b20c72e3..fa558d524 100644 --- a/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/ProtocolGenerator.kt +++ b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/ProtocolGenerator.kt @@ -57,6 +57,8 @@ interface ProtocolGenerator { val DefaultServiceErrorProtocolSymbol: Symbol = ClientRuntimeTypes.Core.ServiceError + val DefaultClockSkewProviderSymbol: Symbol = ClientRuntimeTypes.Core.DefaultClockSkewProvider + val DefaultRetryErrorInfoProviderSymbol: Symbol = ClientRuntimeTypes.Core.DefaultRetryErrorInfoProvider } @@ -85,6 +87,9 @@ interface ProtocolGenerator { */ var serviceErrorProtocolSymbol: Symbol + val clockSkewProviderSymbol: Symbol + get() = DefaultClockSkewProviderSymbol + /** * Symbol that should be used when the deserialized service error type cannot be determined * It defaults to the UnknownServiceError available in smithy-swift's client-runtime. diff --git a/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/middlewares/ClockSkewMiddleware.kt b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/middlewares/ClockSkewMiddleware.kt new file mode 100644 index 000000000..21919cb3b --- /dev/null +++ b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/integration/middlewares/ClockSkewMiddleware.kt @@ -0,0 +1,31 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +package software.amazon.smithy.swift.codegen.integration.middlewares + +import software.amazon.smithy.codegen.core.Symbol +import software.amazon.smithy.codegen.core.SymbolProvider +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.swift.codegen.SwiftWriter +import software.amazon.smithy.swift.codegen.integration.ProtocolGenerator +import software.amazon.smithy.swift.codegen.middleware.MiddlewareRenderable + +class ClockSkewMiddleware( + val model: Model, + val symbolProvider: SymbolProvider, + val clockSkewProviderSymbol: Symbol, +) : MiddlewareRenderable { + override val name = "ClockSkewMiddleware" + + override fun render( + ctx: ProtocolGenerator.GenerationContext, + writer: SwiftWriter, + op: OperationShape, + operationStackName: String, + ) { + writer.write("builder.clockSkewProvider(\$N.provider())", clockSkewProviderSymbol) + } +} diff --git a/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/swiftmodules/ClientRuntimeTypes.kt b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/swiftmodules/ClientRuntimeTypes.kt index 928486d99..8248201cd 100644 --- a/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/swiftmodules/ClientRuntimeTypes.kt +++ b/smithy-swift-codegen/src/main/kotlin/software/amazon/smithy/swift/codegen/swiftmodules/ClientRuntimeTypes.kt @@ -60,6 +60,7 @@ object ClientRuntimeTypes { val SDKLogLevel = runtimeSymbol("SDKLogLevel", SwiftDeclaration.ENUM) val ClientLogMode = runtimeSymbol("ClientLogMode", SwiftDeclaration.ENUM) val IdempotencyTokenGenerator = runtimeSymbol("IdempotencyTokenGenerator", SwiftDeclaration.PROTOCOL) + val DefaultClockSkewProvider = runtimeSymbol("DefaultClockSkewProvider", SwiftDeclaration.ENUM) val DefaultRetryErrorInfoProvider = runtimeSymbol("DefaultRetryErrorInfoProvider", SwiftDeclaration.ENUM) val PaginateToken = runtimeSymbol("PaginateToken", SwiftDeclaration.PROTOCOL) val PaginatorSequence = runtimeSymbol("PaginatorSequence", SwiftDeclaration.STRUCT) diff --git a/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/HttpProtocolClientGeneratorTests.kt b/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/HttpProtocolClientGeneratorTests.kt index 780349575..800f64fd3 100644 --- a/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/HttpProtocolClientGeneratorTests.kt +++ b/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/HttpProtocolClientGeneratorTests.kt @@ -211,6 +211,7 @@ extension RestJsonProtocolClient { builder.interceptors.add(ClientRuntime.ContentLengthMiddleware()) builder.deserialize(ClientRuntime.DeserializeMiddleware(AllocateWidgetOutput.httpOutput(from:), AllocateWidgetOutputError.httpError(from:))) builder.interceptors.add(ClientRuntime.LoggerMiddleware(clientLogMode: config.clientLogMode)) + builder.clockSkewProvider(ClientRuntime.DefaultClockSkewProvider.provider()) builder.retryStrategy(SmithyRetries.DefaultRetryStrategy(options: config.retryStrategyOptions)) builder.retryErrorInfoProvider(ClientRuntime.DefaultRetryErrorInfoProvider.errorInfo(for:)) builder.applySigner(ClientRuntime.SignerMiddleware()) diff --git a/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/requestandresponse/EventStreamTests.kt b/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/requestandresponse/EventStreamTests.kt index 6a36fa62c..907c87776 100644 --- a/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/requestandresponse/EventStreamTests.kt +++ b/smithy-swift-codegen/src/test/kotlin/software/amazon/smithy/swift/codegen/requestandresponse/EventStreamTests.kt @@ -233,6 +233,7 @@ extension EventStreamTestClientTypes.TestStream { builder.interceptors.add(ClientRuntime.ContentLengthMiddleware()) builder.deserialize(ClientRuntime.DeserializeMiddleware(TestStreamOpOutput.httpOutput(from:), TestStreamOpOutputError.httpError(from:))) builder.interceptors.add(ClientRuntime.LoggerMiddleware(clientLogMode: config.clientLogMode)) + builder.clockSkewProvider(ClientRuntime.DefaultClockSkewProvider.provider()) builder.retryStrategy(SmithyRetries.DefaultRetryStrategy(options: config.retryStrategyOptions)) builder.retryErrorInfoProvider(ClientRuntime.DefaultRetryErrorInfoProvider.errorInfo(for:)) builder.applySigner(ClientRuntime.SignerMiddleware())