Skip to content

Commit 367738e

Browse files
richardwang1124Matt Muller
andauthored
Waiters (#292)
Waiters --------- Co-authored-by: Matt Muller <mamuller@amazon.com>
1 parent a55c06c commit 367738e

22 files changed

Lines changed: 1688 additions & 3 deletions

File tree

gems/smithy-client/lib/smithy-client.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
require_relative 'smithy-client/retry'
2929
require_relative 'smithy-client/service_error'
3030
require_relative 'smithy-client/util'
31+
require_relative 'smithy-client/waiters/poller'
32+
require_relative 'smithy-client/waiters/waiter'
3133
require_relative 'smithy-client/input'
3234
require_relative 'smithy-client/output'
3335
require_relative 'smithy-client/base'

gems/smithy-client/lib/smithy-client/base.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ def context_for(operation_name, params)
7979
)
8080
end
8181

82+
def waiter(waiter_name, options = {})
83+
waiter_class = waiters[waiter_name]
84+
raise Waiters::NoSuchWaiterError.new(waiter_name, waiters.keys) unless waiter_class
85+
86+
waiter_class.new(options.merge(client: self))
87+
end
88+
8289
class << self
8390
def new(options = {})
8491
plugins = build_plugins
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
module Waiters
6+
# Abstract poller class which polls a single API operation and inspects
7+
# the output and/or error for states matching one of its acceptors.
8+
class Poller
9+
def initialize(options = {})
10+
@operation_name = options[:operation_name]
11+
@acceptors = options[:acceptors]
12+
end
13+
14+
def call(client, params)
15+
@input = params
16+
# TODO: make build_input public and update this line
17+
input = client.send(:build_input, @operation_name, params)
18+
input.handlers.remove(Plugins::RaiseResponseErrors::Handler)
19+
output = input.send_request
20+
status = evaluate_acceptors(output)
21+
[output, status.to_sym]
22+
end
23+
24+
private
25+
26+
def evaluate_acceptors(output)
27+
@acceptors.each do |acceptor|
28+
return acceptor['state'] if acceptor_matches?(acceptor['matcher'], output)
29+
end
30+
output.error.nil? ? 'retry' : 'error'
31+
end
32+
33+
def acceptor_matches?(matcher, output)
34+
matcher_type = matcher.keys.first
35+
send("matches_#{matcher_type}?", matcher[matcher_type], output)
36+
end
37+
38+
def matches_output?(path_matcher, output)
39+
return false if output.data.nil?
40+
41+
actual = JMESPath.search(path_matcher['path'], output.data)
42+
equal?(actual, path_matcher['expected'], path_matcher['comparator'])
43+
end
44+
45+
# rubocop:disable Naming/MethodName
46+
def matches_inputOutput?(path_matcher, output)
47+
return false unless !output.data.nil? && @input
48+
49+
data = {
50+
input: @input,
51+
output: output.data
52+
}
53+
actual = JMESPath.search(path_matcher['path'], data)
54+
equal?(actual, path_matcher['expected'], path_matcher['comparator'])
55+
end
56+
57+
def matches_success?(path_matcher, output)
58+
path_matcher == true ? !output.data.nil? : !output.error.nil?
59+
end
60+
61+
def matches_errorType?(path_matcher, output)
62+
return false if output.error.nil?
63+
64+
output.error.class.to_s.end_with?("Errors::#{path_matcher}")
65+
end
66+
67+
def equal?(actual, expected, comparator)
68+
send("#{comparator}?", actual, expected)
69+
end
70+
71+
def stringEquals?(actual, expected)
72+
actual == expected
73+
end
74+
75+
def booleanEquals?(actual, expected)
76+
actual.to_s == expected
77+
end
78+
79+
def allStringEquals?(actual, expected)
80+
return false if actual.nil? || actual.empty?
81+
82+
actual.all? { |value| value == expected }
83+
end
84+
85+
def anyStringEquals?(actual, expected)
86+
return false if actual.nil? || actual.empty?
87+
88+
actual.any? { |value| value == expected }
89+
end
90+
# rubocop:enable Naming/MethodName
91+
end
92+
end
93+
end
94+
end
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
module Waiters
6+
# Raised when a waiter detects a condition where the waiter can never
7+
# succeed.
8+
class WaiterFailed < StandardError; end
9+
10+
# Raised when a waiter enters a failure state.
11+
class FailureStateError < WaiterFailed
12+
def initialize(error)
13+
msg = "stopped waiting, encountered a failure state: #{error}"
14+
super(msg)
15+
end
16+
end
17+
18+
# Raised when the total wait time of a waiter exceeds the maximum
19+
# wait time.
20+
class MaxWaitTimeExceededError < WaiterFailed
21+
def initialize(max_wait_time)
22+
msg = "stopped waiting after maximum wait time of #{max_wait_time} seconds was exceeded"
23+
super(msg)
24+
end
25+
end
26+
27+
# Raised when a waiter encounters an unexpected error.
28+
class UnexpectedError < WaiterFailed
29+
def initialize(error)
30+
msg = "stopped waiting due to an unexpected error: #{error}"
31+
super(msg)
32+
end
33+
end
34+
35+
# Raised when attempting to get a waiter by name and the waiter has not
36+
# been defined.
37+
class NoSuchWaiterError < ArgumentError
38+
def initialize(waiter_name, valid_waiters)
39+
msg = "no such waiter: #{waiter_name}; valid waiter names are: #{valid_waiters}"
40+
super(msg)
41+
end
42+
end
43+
44+
# Abstract waiter class which waits for a resource to reach a desired
45+
# state.
46+
class Waiter
47+
def initialize(options = {})
48+
@max_wait_time = max_wait_time(options[:max_wait_time])
49+
@remaining_time = @max_wait_time
50+
@max_delay = max_delay(options[:max_delay])
51+
@min_delay = min_delay(options[:min_delay])
52+
@poller = options[:poller]
53+
end
54+
55+
def wait(client, params)
56+
poll(client, params)
57+
end
58+
59+
private
60+
61+
def max_wait_time(time)
62+
unless time.is_a?(Integer)
63+
raise ArgumentError, "expected `:max_wait_time` to be an Integer, got: #{time.class}"
64+
end
65+
66+
time
67+
end
68+
69+
def max_delay(delay)
70+
raise ArgumentError, '`:max_delay` must be greater than 0' if delay < 1
71+
72+
delay
73+
end
74+
75+
def min_delay(delay)
76+
if delay < 1 || delay > @max_delay
77+
raise ArgumentError, '`:min_delay` must be greater than 0 and less than or equal to `:max_delay`'
78+
end
79+
80+
delay
81+
end
82+
83+
def poll(client, params)
84+
attempts = 0
85+
loop do
86+
output, status = @poller.call(client, params)
87+
attempts += 1
88+
89+
case status
90+
when :success then return
91+
when :failure then raise FailureStateError, output.error
92+
when :error then raise UnexpectedError, output.error
93+
when :retry
94+
raise MaxWaitTimeExceededError, @max_wait_time if @remaining_time.zero?
95+
96+
delay = delay(attempts)
97+
@remaining_time -= delay
98+
sleep(delay)
99+
end
100+
end
101+
end
102+
103+
def delay(attempts)
104+
attempt_ceiling = (Math.log(@max_delay / @min_delay) / Math.log(2)) + 1
105+
delay = attempts > attempt_ceiling ? @max_delay : @min_delay * (2**(attempts - 1))
106+
delay = rand(@min_delay..delay)
107+
delay = @remaining_time if @remaining_time - delay <= @min_delay
108+
delay
109+
end
110+
end
111+
end
112+
end
113+
end

0 commit comments

Comments
 (0)