Skip to content

Commit

Permalink
feat: Smithy Waiters (#463)
Browse files Browse the repository at this point in the history
* feat: Add waiter client runtime components (#450)
* feat: Waiter extension & methods (#478)
* feat: Add WaiterTypedError type, conform operation errors to it (#491)
* feat: Code-generate Acceptors for waiters (#483)
* fix: Use correct var name in AND expression (#493)
* fix: Correct Swift compile errors for comparison expressions (#494)
  • Loading branch information
jbelkins committed Dec 19, 2022
1 parent 581320d commit dbf417f
Show file tree
Hide file tree
Showing 41 changed files with 3,002 additions and 3 deletions.
2 changes: 2 additions & 0 deletions Packages/.swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ disabled_rules:
- file_length
- syntactic_sugar
- unused_capture_list
- nesting
- large_tuple
- type_body_length

opt_in_rules:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

/// General Service Error structure used when exact error could not be deduced from the `HttpResponse`
public struct UnknownHttpServiceError: HttpServiceError, Swift.Equatable {
public var _errorType: String?

public var _isThrottling: Bool = false

public var _statusCode: HttpStatusCode?
Expand All @@ -19,9 +21,23 @@ public struct UnknownHttpServiceError: HttpServiceError, Swift.Equatable {
}

extension UnknownHttpServiceError {
public init(httpResponse: HttpResponse, message: String? = nil) {

/// Creates an `UnknownHttpServiceError` from a HTTP response.
/// - Parameters:
/// - httpResponse: The `HttpResponse` for this error.
/// - message: The message associated with this error. Defaults to `nil`.
/// - errorType: The error type associated with this error. Defaults to `nil`.
public init(httpResponse: HttpResponse, message: String? = nil, errorType: String? = nil) {
self._statusCode = httpResponse.statusCode
self._headers = httpResponse.headers
self._message = message
self._errorType = errorType
}
}

extension UnknownHttpServiceError: WaiterTypedError {

/// The Smithy identifier, without namespace, for the type of this error, or `nil` if the
/// error has no known type.
public var waiterErrorType: String? { _errorType }
}
16 changes: 16 additions & 0 deletions Packages/ClientRuntime/Sources/Networking/SdkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@ public enum SdkError<E>: Error {
case unknown(Error?)

}

extension SdkError: WaiterTypedError {

/// The Smithy identifier, without namespace, for the type of this error, or `nil` if the
/// error has no known type.
public var waiterErrorType: String? {
switch self {
case .service(let error, _):
return (error as? WaiterTypedError)?.waiterErrorType
case .client(let error, _):
return (error as? WaiterTypedError)?.waiterErrorType
case .unknown(let error):
return (error as? WaiterTypedError)?.waiterErrorType
}
}
}
92 changes: 92 additions & 0 deletions Packages/ClientRuntime/Sources/Waiters/Acceptor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

extension WaiterConfiguration {
/// `Acceptor` is a Swift-native equivalent of Smithy acceptors:
/// https://awslabs.github.io/smithy/2.0/additional-specs/waiters.html#acceptor-structure
/// An `Acceptor` defines a condition (its `matcher`) that will cause the wait operation to transition to
/// a given state (its `state`).
public struct Acceptor {

public typealias Matcher = (Input, Result<Output, Error>) -> Bool

/// The possible states of a Smithy waiter during the waiting process.
public enum State {
/// The waiter has succeeded if this state is reached, and should conclude waiting.
case success
/// The waiter should repeat the operation after a delay if this state is reached.
case retry
/// The waiter has failed if this state is reached, and should conclude waiting.
case failure
}

/// Used as the root value of an `inputOutput` acceptor, which has the `input` and `output` fields
/// as its two top level properties.
///
/// Even though `input` and `output` are both guaranteed to be present when this type is created,
/// these properties are optional because `InputOutput` is handled like any other Smithy model object,
/// and smithy-swift currently does not support `@required` properties on Smithy models.
///
/// In the future, if smithy-swift is updated to support `@required` properties, these may be made
/// non-optional and the corresponding Smithy model's members for `input` and `output` should be
/// marked with `@required` as well.
public struct InputOutput {
public let input: Input?
public let output: Output?

public init(input: Input, output: Output) {
self.input = input
self.output = output
}
}

/// The state that the `Waiter` enters when this `Acceptor` matches the operation response.
public let state: State

/// A closure that determines if this `Acceptor` matches the operation response.
public let matcher: Matcher

/// Creates a new `Acceptor` that will cause the waiter to enter `state` when `Matcher` is true.
public init(
state: State,
matcher: @escaping Matcher
) {
self.state = state
self.matcher = matcher
}

/// Determines if the `Acceptor` matches for the supplied parameters, and returns a
/// `Acceptor.Match` value which can be used to conclude the wait or initiate retry.
func evaluate(
input: Input,
result: Result<Output, Error>
) -> Match? {
guard matcher(input, result) else { return nil }
switch (state, result) {
case (.retry, _):
return .retry
case (.success, let result):
return .success(result)
case (.failure, let result):
return .failure(result)
}
}

/// `Acceptor.Match` encapsulates the action required by an `Acceptor` that matches the
/// operation's response.
public enum Match {
/// An `Acceptor` with `success` state matched an operation, and the associated value
/// is that operation's result.
case success(Result<Output, Error>)
/// An `Acceptor` with `retry` state matched an operation.
case retry
/// An `Acceptor` with `failure` state matched an operation, and the associated value
/// is that operation's result.
case failure(Result<Output, Error>)
}
}
}
104 changes: 104 additions & 0 deletions Packages/ClientRuntime/Sources/Waiters/JMESUtils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation

/// Utility functions for performing comparisons between values in JMESPath expressions.
///
/// `Bool` may be compared for equality & inequality.
///
/// `String` and a `RawRepresentable where RawValue == String` may be interchangeable compared for equality and inequality.
///
/// `Int` and `Double` may be interchangeably compared for equality, inequality, and order.
///
/// When one of the values in an order comparison is `nil`, the result is `false`.
public enum JMESUtils {

// Function for comparing Bool to Bool.

public static func compare(_ lhs: Bool?, _ comparator: (Bool?, Bool?) -> Bool, _ rhs: Bool?) -> Bool {
return comparator(lhs, rhs)
}

// Functions for comparing Double to Double.

public static func compare(_ lhs: Double?, _ comparator: (Double?, Double?) -> Bool, _ rhs: Double?) -> Bool {
comparator(lhs, rhs)
}

public static func compare(_ lhs: Double?, _ comparator: (Double, Double) -> Bool, _ rhs: Double?) -> Bool {
guard let lhs = lhs, let rhs = rhs else { return false }
return comparator(lhs, rhs)
}

// Functions for comparing Int to Int. Double comparators are used since Int has
// extra overloads on `==` that prevent it from resolving correctly, and Ints compared
// to Doubles are already compared as Doubles anyway.

public static func compare(_ lhs: Int?, _ comparator: (Double?, Double?) -> Bool, _ rhs: Int?) -> Bool {
comparator(lhs.map { Double($0) }, rhs.map { Double($0) })
}

public static func compare(_ lhs: Int?, _ comparator: (Double, Double) -> Bool, _ rhs: Int?) -> Bool {
guard let lhs = lhs, let rhs = rhs else { return false }
return comparator(Double(lhs), Double(rhs))
}

// Function for comparing String to String.

public static func compare(_ lhs: String?, _ comparator: (String?, String?) -> Bool, _ rhs: String?) -> Bool {
comparator(lhs, rhs)
}

// Function for comparing two types that are each raw representable by String.

public static func compare<L: RawRepresentable, R: RawRepresentable>(
_ lhs: L?,
_ comparator: (String?, String?) -> Bool,
_ rhs: R?
) -> Bool where L.RawValue == String, R.RawValue == String {
comparator(lhs?.rawValue, rhs?.rawValue)
}

// Extensions for comparing Int and / or Double.

public static func compare(_ lhs: Int?, _ comparator: (Double?, Double?) -> Bool, _ rhs: Double?) -> Bool {
comparator(lhs.map { Double($0) }, rhs)
}

public static func compare(_ lhs: Double?, _ comparator: (Double?, Double?) -> Bool, _ rhs: Int?) -> Bool {
comparator(lhs, rhs.map { Double($0) })
}

public static func compare(_ lhs: Int?, _ comparator: (Double, Double) -> Bool, _ rhs: Double?) -> Bool {
guard let lhs = lhs, let rhs = rhs else { return false }
return comparator(Double(lhs), rhs)
}

public static func compare(_ lhs: Double?, _ comparator: (Double, Double) -> Bool, _ rhs: Int?) -> Bool {
guard let lhs = lhs, let rhs = rhs else { return false }
return comparator(lhs, Double(rhs))
}

// Extensions for comparing String with types having raw value of String.

public static func compare<T: RawRepresentable>(
_ lhs: T?,
_ comparator: (String?, String?) -> Bool,
_ rhs: String?
) -> Bool where T.RawValue == String {
comparator(lhs?.rawValue, rhs)
}

public static func compare<T: RawRepresentable>(
_ lhs: String?,
_ comparator: (String?, String?) -> Bool,
_ rhs: T?
) -> Bool where T.RawValue == String {
comparator(lhs, rhs?.rawValue)
}
}
97 changes: 97 additions & 0 deletions Packages/ClientRuntime/Sources/Waiters/Waiter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation

/// An object used to wait on an operation until a desired state is reached, or until it is determined that
/// the desired state will never be reached.
/// Intended to be a generic type for use when waiting on any Smithy `operation`.
/// May be reused for multiple waits, including concurrent operations.
public class Waiter<Input, Output> {

/// The configuration this waiter was created with.
public let config: WaiterConfiguration<Input, Output>

/// The operation that this waiter will call one or more times while waiting on the success condition.
public let operation: (Input) async throws -> Output

let retryer: WaiterRetryer<Input, Output>

/// Block that creates a `WaiterScheduler` with the supplied params.
/// May be replaced with a different block for testing.
var schedulerFactory = { (minDelay: TimeInterval, maxDelay: TimeInterval, maxWaitTime: TimeInterval)
-> WaiterScheduler in
return WaiterScheduler(minDelay: minDelay, maxDelay: maxDelay, maxWaitTime: maxWaitTime)
}

/// Creates a `waiter` object with the supplied config and operation.
/// - Parameters:
/// - config: An instance of `WaiterConfiguration` that defines the default behavior of this waiter.
/// - operation: A closure that is called one or more times to perform the waiting operation;
/// takes an `Input` as its sole param & returns an `Output` asynchronously.
/// The `operation` closure throws an error if the operation cannot be performed or the
/// operation completes with an error.
public convenience init(
config: WaiterConfiguration<Input, Output>,
operation: @escaping (Input) async throws -> Output
) {
self.init(config: config, operation: operation, retryer: WaiterRetryer<Input, Output>())
}

/// The designated initializer for this class. See public / convenience initializer for more details.
///
/// Allows for creation with a custom retryer.
/// - Parameters:
/// - config: An instance of `WaiterConfiguration` that defines the default behavior of this waiter.
/// - operation: A closure that is called one or more times to perform the waiting operation;
/// takes an `Input` as its sole param & returns an `Output` asynchronously.
/// The `operation` closure throws an error if the operation cannot be performed or the
/// operation completes with an error.
/// - retryer: The `WaiterRetryer` to be used when polling the operation.
init(
config: WaiterConfiguration<Input, Output>,
operation: @escaping (Input) async throws -> Output,
retryer: WaiterRetryer<Input, Output>
) {
self.config = config
self.operation = operation
self.retryer = retryer
}

/// Initiates waiting, retrying the operation if necessary until the wait succeeds, fails, or times out.
/// Returns a `WaiterOutcome` asynchronously on waiter success, throws an error asynchronously on
/// waiter failure or timeout.
/// - Parameters:
/// - options: `WaiterOptions` to be used to configure this wait.
/// - input: The `Input` object to be used as a parameter when performing the operation.
/// - Returns: A `WaiterOutcome` with the result of the final, successful performance of the operation.
/// - Throws: `WaiterFailureError` if the waiter fails due to matching an `Acceptor` with state `failure`
/// or there is an error not handled by any `Acceptor.`
///
/// `WaiterTimeoutError` if the waiter times out.
@discardableResult
public func waitUntil(
options: WaiterOptions,
input: Input
) async throws -> WaiterOutcome<Output> {
let minDelay = options.minDelay ?? config.minDelay
let maxDelay = options.maxDelay ?? config.maxDelay
let maxWaitTime = options.maxWaitTime
let scheduler = schedulerFactory(minDelay, maxDelay, maxWaitTime)

while !scheduler.isExpired {
if let result = try await retryer.waitThenRequest(scheduler: scheduler,
input: input,
config: config,
operation: operation) {
return result
}
}
// Waiting has expired, throw an error back to the caller
throw WaiterTimeoutError(attempts: scheduler.attempts)
}
}
Loading

0 comments on commit dbf417f

Please sign in to comment.