Skip to content

Commit 7be2bd8

Browse files
sichanyooSichan Yoo
andauthored
feat: Add retry information headers (#692)
* Organize test files and directories to mirror code files and directory structure. * Add withSocket utility method & AttributeKeys related to TTL (socketTimeout & estimatedSkew). * Make socketTimeout to default to 60 seconds. * Add codegen that saves socketTimeout from HttpClientConfiguration into middleware context. * Add utility functions; one that calculates estimated skew from date string & one that calculates TTL by adding estimated skew and socket timeout to current time according to local machine clock. * Make DeserializeMiddleware save estimated skew calculated from returned HTTP response's Date header value. * Make RetryMiddleware add retry information headers as defined in SEP. * Fix dateFormatter in getTTL utility method to take raw date and convert to string without any adjustments. * Add tests for the 2 utility methods getTTL & getEstimatedSkew. Augment existing RetryIntegrationTests to check retry information headers in inputs. * Update codegen test to include socketTimeout addition. * Add dummy values needed for context used by retry middleware tests. * Change a couple XCTAssert to XCTAssertEqual for better log message. * Make socketTimeout non-optional given default value is being set now. * Log .info level message then proceed with default values instead of throwing an error. * Fix socket timeout related errors. * Fix syntax error. --------- Co-authored-by: Sichan Yoo <[email protected]>
1 parent 1fb3803 commit 7be2bd8

31 files changed

+171
-11
lines changed

Sources/ClientRuntime/Config/DefaultSDKRuntimeConfiguration.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ public extension DefaultSDKRuntimeConfiguration {
9696
return URLSessionHTTPClient(httpClientConfiguration: httpClientConfiguration)
9797
#else
9898
let connectTimeoutMs = httpClientConfiguration.connectTimeout.map { UInt32($0 * 1000) }
99-
let socketTimeout = httpClientConfiguration.connectTimeout.map { UInt32($0) }
99+
let socketTimeout = UInt32(httpClientConfiguration.socketTimeout)
100100
let config = CRTClientEngineConfig(connectTimeoutMs: connectTimeoutMs, socketTimeout: socketTimeout)
101101
return CRTClientEngine(config: config)
102102
#endif

Sources/ClientRuntime/Middleware/RetryMiddleware.swift

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

8+
import class Foundation.DateFormatter
9+
import struct Foundation.Locale
10+
import struct Foundation.TimeInterval
11+
import struct Foundation.TimeZone
12+
import struct Foundation.UUID
13+
814
public struct RetryMiddleware<Strategy: RetryStrategy,
915
ErrorInfoProvider: RetryErrorInfoProvider,
1016
OperationStackOutput>: Middleware {
@@ -16,23 +22,36 @@ public struct RetryMiddleware<Strategy: RetryStrategy,
1622
public var id: String { "Retry" }
1723
public var strategy: Strategy
1824

25+
// The UUID string used to uniquely identify an API call and all of its subsequent retries.
26+
private let invocationID = UUID().uuidString.lowercased()
27+
// Max number of retries configured for retry strategy.
28+
private var maxRetries: Int
29+
1930
public init(options: RetryStrategyOptions) {
2031
self.strategy = Strategy(options: options)
32+
self.maxRetries = options.maxRetriesBase
2133
}
2234

2335
public func handle<H>(context: Context, input: SdkHttpRequestBuilder, next: H) async throws ->
2436
OperationOutput<OperationStackOutput>
2537
where H: Handler, MInput == H.Input, MOutput == H.Output, Context == H.Context {
2638

39+
input.headers.add(name: "amz-sdk-invocation-id", value: invocationID)
40+
2741
let partitionID = try getPartitionID(context: context, input: input)
2842
let token = try await strategy.acquireInitialRetryToken(tokenScope: partitionID)
29-
return try await sendRequest(token: token, context: context, input: input, next: next)
43+
input.headers.add(name: "amz-sdk-request", value: "attempt=1; max=\(maxRetries)")
44+
return try await sendRequest(attemptNumber: 1, token: token, context: context, input: input, next: next)
3045
}
3146

32-
private func sendRequest<H>(token: Strategy.Token, context: Context, input: MInput, next: H) async throws ->
47+
private func sendRequest<H>(
48+
attemptNumber: Int,
49+
token: Strategy.Token,
50+
context: Context,
51+
input: MInput, next: H
52+
) async throws ->
3353
OperationOutput<OperationStackOutput>
3454
where H: Handler, MInput == H.Input, MOutput == H.Output, Context == H.Context {
35-
3655
do {
3756
let serviceResponse = try await next.handle(context: context, input: input)
3857
await strategy.recordSuccess(token: token)
@@ -45,7 +64,28 @@ public struct RetryMiddleware<Strategy: RetryStrategy,
4564
// TODO: log token error here
4665
throw operationError
4766
}
48-
return try await sendRequest(token: token, context: context, input: input, next: next)
67+
var estimatedSkew = context.attributes.get(key: AttributeKeys.estimatedSkew)
68+
if estimatedSkew == nil {
69+
estimatedSkew = 0
70+
context.getLogger()!.info("Estimated skew not found; defaulting to zero.")
71+
}
72+
var socketTimeout = context.attributes.get(key: AttributeKeys.socketTimeout)
73+
if socketTimeout == nil {
74+
socketTimeout = 60.0
75+
context.getLogger()!.info("Socket timeout value not found; defaulting to 60 seconds.")
76+
}
77+
let ttlDateUTCString = getTTL(now: Date(), estimatedSkew: estimatedSkew!, socketTimeout: socketTimeout!)
78+
input.headers.update(
79+
name: "amz-sdk-request",
80+
value: "ttl=\(ttlDateUTCString); attempt=\(attemptNumber + 1); max=\(maxRetries)"
81+
)
82+
return try await sendRequest(
83+
attemptNumber: attemptNumber + 1,
84+
token: token,
85+
context: context,
86+
input: input,
87+
next: next
88+
)
4989
}
5090
}
5191

@@ -66,3 +106,24 @@ public struct RetryMiddleware<Strategy: RetryStrategy,
66106
}
67107
}
68108
}
109+
110+
// Calculates & returns TTL datetime in strftime format `YYYYmmddTHHMMSSZ`.
111+
func getTTL(now: Date, estimatedSkew: TimeInterval, socketTimeout: TimeInterval) -> String {
112+
let dateFormatter = DateFormatter()
113+
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
114+
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
115+
dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
116+
let ttlDate = now.addingTimeInterval(estimatedSkew + socketTimeout)
117+
return dateFormatter.string(from: ttlDate)
118+
}
119+
120+
// Calculates & returns estimated skew.
121+
func getEstimatedSkew(now: Date, responseDateString: String) -> TimeInterval {
122+
let dateFormatter = DateFormatter()
123+
dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z"
124+
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
125+
dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
126+
let responseDate: Date = dateFormatter.date(from: responseDateString) ?? now
127+
// (Estimated skew) = (Date header from HTTP response) - (client's current time)).
128+
return responseDate.timeIntervalSince(now)
129+
}

Sources/ClientRuntime/Networking/Http/HttpClientConfiguration.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ public class HttpClientConfiguration {
1818
/// Sets maximum time to wait between two data packets.
1919
/// Used to close stale connections that have no activity.
2020
///
21-
/// If no value is provided, the defaut client won't have a socket timeout.
22-
public var socketTimeout: TimeInterval?
21+
/// Defaults to 60 seconds if no value is provided.
22+
public var socketTimeout: TimeInterval
2323

2424
/// HTTP headers to be submitted with every HTTP request.
2525
///
@@ -45,7 +45,7 @@ public class HttpClientConfiguration {
4545
/// - protocolType: The HTTP scheme (`http` or `https`) to be used for API requests. Defaults to the operation's standard configuration.
4646
public init(
4747
connectTimeout: TimeInterval? = nil,
48-
socketTimeout: TimeInterval? = nil,
48+
socketTimeout: TimeInterval = 60.0,
4949
protocolType: ProtocolType = .https,
5050
defaultHeaders: Headers = Headers()
5151
) {

Sources/ClientRuntime/Networking/Http/HttpContext.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,12 @@ public class HttpContextBuilder {
308308
return self
309309
}
310310

311+
@discardableResult
312+
public func withSocketTimeout(value: TimeInterval?) -> HttpContextBuilder {
313+
self.attributes.set(key: AttributeKeys.socketTimeout, value: value)
314+
return self
315+
}
316+
311317
@discardableResult
312318
public func withUnsignedPayloadTrait(value: Bool) -> HttpContextBuilder {
313319
self.attributes.set(key: AttributeKeys.hasUnsignedPayloadTrait, value: value)
@@ -356,6 +362,10 @@ public enum AttributeKeys {
356362

357363
// Streams
358364
public static let isChunkedEligibleStream = AttributeKey<Bool>(name: "isChunkedEligibleStream")
365+
366+
// TTL calculation in retries.
367+
public static let estimatedSkew = AttributeKey<TimeInterval>(name: "EstimatedSkew")
368+
public static let socketTimeout = AttributeKey<TimeInterval>(name: "SocketTimeout")
359369
}
360370

361371
// The type of flow the mdidleware context is being constructed for

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ public struct DeserializeMiddleware<OperationStackOutput>: Middleware {
2727

2828
let response = try await next.handle(context: context, input: input) // call handler to get http response
2929

30+
if let responseDateString = response.httpResponse.headers.value(for: "Date") {
31+
let estimatedSkew = getEstimatedSkew(now: Date(), responseDateString: responseDateString)
32+
context.attributes.set(key: AttributeKeys.estimatedSkew, value: estimatedSkew)
33+
}
34+
3035
// check if the response body was effected by a previous middleware
3136
if let contextBody = context.response?.body {
3237
response.httpResponse.body = contextBody

Sources/ClientRuntime/Networking/Http/URLSession/URLSessionConfiguration+HTTPClientConfiguration.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ extension URLSessionConfiguration {
1313

1414
public static func from(httpClientConfiguration: HttpClientConfiguration) -> URLSessionConfiguration {
1515
let config = URLSessionConfiguration.default
16-
if let socketTimeout = httpClientConfiguration.socketTimeout {
17-
config.timeoutIntervalForRequest = socketTimeout
18-
}
16+
config.timeoutIntervalForRequest = httpClientConfiguration.socketTimeout
1917
return config
2018
}
2119
}

0 commit comments

Comments
 (0)