diff --git a/gems/smithy-client/lib/smithy-client.rb b/gems/smithy-client/lib/smithy-client.rb index ed70d9265..6c9f621e4 100644 --- a/gems/smithy-client/lib/smithy-client.rb +++ b/gems/smithy-client/lib/smithy-client.rb @@ -28,6 +28,8 @@ require_relative 'smithy-client/retry' require_relative 'smithy-client/service_error' require_relative 'smithy-client/util' +require_relative 'smithy-client/waiters/poller' +require_relative 'smithy-client/waiters/waiter' require_relative 'smithy-client/input' require_relative 'smithy-client/output' require_relative 'smithy-client/base' diff --git a/gems/smithy-client/lib/smithy-client/base.rb b/gems/smithy-client/lib/smithy-client/base.rb index 8f8e07d56..255409dac 100644 --- a/gems/smithy-client/lib/smithy-client/base.rb +++ b/gems/smithy-client/lib/smithy-client/base.rb @@ -79,6 +79,13 @@ def context_for(operation_name, params) ) end + def waiter(waiter_name, options = {}) + waiter_class = waiters[waiter_name] + raise Waiters::NoSuchWaiterError.new(waiter_name, waiters.keys) unless waiter_class + + waiter_class.new(options.merge(client: self)) + end + class << self def new(options = {}) plugins = build_plugins diff --git a/gems/smithy-client/lib/smithy-client/waiters/poller.rb b/gems/smithy-client/lib/smithy-client/waiters/poller.rb new file mode 100644 index 000000000..bc6050c95 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/waiters/poller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Smithy + module Client + module Waiters + # Abstract poller class which polls a single API operation and inspects + # the output and/or error for states matching one of its acceptors. + class Poller + def initialize(options = {}) + @operation_name = options[:operation_name] + @acceptors = options[:acceptors] + end + + def call(client, params) + @input = params + # TODO: make build_input public and update this line + input = client.send(:build_input, @operation_name, params) + input.handlers.remove(Plugins::RaiseResponseErrors::Handler) + output = input.send_request + status = evaluate_acceptors(output) + [output, status.to_sym] + end + + private + + def evaluate_acceptors(output) + @acceptors.each do |acceptor| + return acceptor['state'] if acceptor_matches?(acceptor['matcher'], output) + end + output.error.nil? ? 'retry' : 'error' + end + + def acceptor_matches?(matcher, output) + matcher_type = matcher.keys.first + send("matches_#{matcher_type}?", matcher[matcher_type], output) + end + + def matches_output?(path_matcher, output) + return false if output.data.nil? + + actual = JMESPath.search(path_matcher['path'], output.data) + equal?(actual, path_matcher['expected'], path_matcher['comparator']) + end + + # rubocop:disable Naming/MethodName + def matches_inputOutput?(path_matcher, output) + return false unless !output.data.nil? && @input + + data = { + input: @input, + output: output.data + } + actual = JMESPath.search(path_matcher['path'], data) + equal?(actual, path_matcher['expected'], path_matcher['comparator']) + end + + def matches_success?(path_matcher, output) + path_matcher == true ? !output.data.nil? : !output.error.nil? + end + + def matches_errorType?(path_matcher, output) + return false if output.error.nil? + + output.error.class.to_s.end_with?("Errors::#{path_matcher}") + end + + def equal?(actual, expected, comparator) + send("#{comparator}?", actual, expected) + end + + def stringEquals?(actual, expected) + actual == expected + end + + def booleanEquals?(actual, expected) + actual.to_s == expected + end + + def allStringEquals?(actual, expected) + return false if actual.nil? || actual.empty? + + actual.all? { |value| value == expected } + end + + def anyStringEquals?(actual, expected) + return false if actual.nil? || actual.empty? + + actual.any? { |value| value == expected } + end + # rubocop:enable Naming/MethodName + end + end + end +end diff --git a/gems/smithy-client/lib/smithy-client/waiters/waiter.rb b/gems/smithy-client/lib/smithy-client/waiters/waiter.rb new file mode 100644 index 000000000..9a1474119 --- /dev/null +++ b/gems/smithy-client/lib/smithy-client/waiters/waiter.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Smithy + module Client + module Waiters + # Raised when a waiter detects a condition where the waiter can never + # succeed. + class WaiterFailed < StandardError; end + + # Raised when a waiter enters a failure state. + class FailureStateError < WaiterFailed + def initialize(error) + msg = "stopped waiting, encountered a failure state: #{error}" + super(msg) + end + end + + # Raised when the total wait time of a waiter exceeds the maximum + # wait time. + class MaxWaitTimeExceededError < WaiterFailed + def initialize(max_wait_time) + msg = "stopped waiting after maximum wait time of #{max_wait_time} seconds was exceeded" + super(msg) + end + end + + # Raised when a waiter encounters an unexpected error. + class UnexpectedError < WaiterFailed + def initialize(error) + msg = "stopped waiting due to an unexpected error: #{error}" + super(msg) + end + end + + # Raised when attempting to get a waiter by name and the waiter has not + # been defined. + class NoSuchWaiterError < ArgumentError + def initialize(waiter_name, valid_waiters) + msg = "no such waiter: #{waiter_name}; valid waiter names are: #{valid_waiters}" + super(msg) + end + end + + # Abstract waiter class which waits for a resource to reach a desired + # state. + class Waiter + def initialize(options = {}) + @max_wait_time = max_wait_time(options[:max_wait_time]) + @remaining_time = @max_wait_time + @max_delay = max_delay(options[:max_delay]) + @min_delay = min_delay(options[:min_delay]) + @poller = options[:poller] + end + + def wait(client, params) + poll(client, params) + end + + private + + def max_wait_time(time) + unless time.is_a?(Integer) + raise ArgumentError, "expected `:max_wait_time` to be an Integer, got: #{time.class}" + end + + time + end + + def max_delay(delay) + raise ArgumentError, '`:max_delay` must be greater than 0' if delay < 1 + + delay + end + + def min_delay(delay) + if delay < 1 || delay > @max_delay + raise ArgumentError, '`:min_delay` must be greater than 0 and less than or equal to `:max_delay`' + end + + delay + end + + def poll(client, params) + attempts = 0 + loop do + output, status = @poller.call(client, params) + attempts += 1 + + case status + when :success then return + when :failure then raise FailureStateError, output.error + when :error then raise UnexpectedError, output.error + when :retry + raise MaxWaitTimeExceededError, @max_wait_time if @remaining_time.zero? + + delay = delay(attempts) + @remaining_time -= delay + sleep(delay) + end + end + end + + def delay(attempts) + attempt_ceiling = (Math.log(@max_delay / @min_delay) / Math.log(2)) + 1 + delay = attempts > attempt_ceiling ? @max_delay : @min_delay * (2**(attempts - 1)) + delay = rand(@min_delay..delay) + delay = @remaining_time if @remaining_time - delay <= @min_delay + delay + end + end + end + end +end diff --git a/gems/smithy-client/spec/smithy-client/waiters_spec.rb b/gems/smithy-client/spec/smithy-client/waiters_spec.rb new file mode 100644 index 000000000..2b2b7d200 --- /dev/null +++ b/gems/smithy-client/spec/smithy-client/waiters_spec.rb @@ -0,0 +1,651 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module Smithy + module Client + describe Waiters do + let(:shapes) do + { + 'smithy.ruby.tests#WaitService' => { + 'type' => 'service', + 'version' => '2022-11-30', + 'operations' => [{ 'target' => 'smithy.ruby.tests#GetWidget' }], + 'traits' => { 'smithy.protocols#rpcv2Cbor' => {} } + }, + 'smithy.ruby.tests#GetWidget' => { + 'type' => 'operation', + 'input' => { 'target' => 'smithy.ruby.tests#WidgetInput' }, + 'output' => { 'target' => 'smithy.ruby.tests#WidgetOutput' }, + 'errors' => [{ 'target' => 'smithy.ruby.tests#Error' }], + 'traits' => { 'smithy.waiters#waitable' => matchers } + }, + 'smithy.ruby.tests#WidgetInput' => { + 'type' => 'structure', + 'members' => { + 'stringProperty' => { 'target' => 'smithy.api#String' }, + 'stringArrayProperty' => { 'target' => 'smithy.ruby.tests#StringArray' }, + 'booleanProperty' => { 'target' => 'smithy.api#Boolean' }, + 'booleanArrayProperty' => { 'target' => 'smithy.ruby.tests#BooleanArray' } + } + }, + 'smithy.ruby.tests#WidgetOutput' => { + 'type' => 'structure', + 'members' => { + 'stringProperty' => { 'target' => 'smithy.api#String' }, + 'stringArrayProperty' => { 'target' => 'smithy.ruby.tests#StringArray' }, + 'booleanProperty' => { 'target' => 'smithy.api#Boolean' }, + 'booleanArrayProperty' => { 'target' => 'smithy.ruby.tests#BooleanArray' } + } + }, + 'smithy.ruby.tests#BooleanArray' => { + 'type' => 'list', + 'member' => { 'target' => 'smithy.api#Boolean' } + }, + 'smithy.ruby.tests#StringArray' => { + 'type' => 'list', + 'member' => { 'target' => 'smithy.api#String' } + }, + 'smithy.ruby.tests#Error' => { + 'type' => 'structure', + 'members' => { 'message' => { 'target' => 'smithy.api#String' } }, + 'traits' => { 'smithy.api#error' => 'client' } + } + } + end + + let(:sample_client) { ClientHelper.sample_client(shapes: shapes) } + + let(:client_class) do + client_class = sample_client.const_get(:Client) + client_class.clear_plugins + client_class.add_plugin(sample_client::Plugins::Endpoint) + client_class.add_plugin(Plugins::Protocol) + client_class.add_plugin(Plugins::RaiseResponseErrors) + client_class.add_plugin(Plugins::StubResponses) + client_class + end + + let(:client) { client_class.new(stub_responses: true) } + + before(:each) { allow_any_instance_of(Waiters::Waiter).to receive(:sleep) } + + def wait(waiter_name) + client.wait_until(waiter_name, { string_property: 'input' }, { max_wait_time: 60 }) + end + + describe Waiters::Waiter do + let(:matchers) do + { + 'OutputStringPropertyMatcher' => { + 'acceptors' => [ + { + 'state' => 'success', + 'matcher' => { + 'output' => { + 'path' => 'stringProperty', + 'expected' => 'success', + 'comparator' => 'stringEquals' + } + } + }, + { + 'state' => 'retry', + 'matcher' => { + 'output' => { + 'path' => 'stringProperty', + 'expected' => 'retry', + 'comparator' => 'stringEquals' + } + } + }, + { + 'state' => 'failure', + 'matcher' => { + 'output' => { + 'path' => 'stringProperty', + 'expected' => 'failure', + 'comparator' => 'stringEquals' + } + } + } + ] + } + } + end + + describe '#poll' do + it 'delays when status is retry' do + client.stub_responses( + :get_widget, + { string_property: 'retry' }, + { string_property: 'retry' }, + { string_property: 'success' } + ) + expect_any_instance_of(Waiters::Waiter).to receive(:sleep).exactly(2).times + wait(:output_string_property_matcher) + end + + it 'raises a failure state error when status is failure' do + client.stub_responses(:get_widget, { string_property: 'failure' }) + expect { wait(:output_string_property_matcher) } + .to raise_error(Waiters::FailureStateError) + end + + it 'raises an unexpected error when status is error' do + client.stub_responses(:get_widget, StandardError.new) + expect { wait(:output_string_property_matcher) } + .to raise_error(Waiters::UnexpectedError) + end + + it 'raises a max wait time exceeded error when there is no more remaining time' do + client.stub_responses( + :get_widget, + { string_property: 'retry' }, + { string_property: 'retry' } + ) + expect { wait(:output_string_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + end + + describe '#delay' do + it 'generates a random delay between min_delay and max_delay' do + min_delay = 5 + max_delay = 60 + options = { + max_wait_time: 60, + min_delay: min_delay, + max_delay: max_delay + } + client.stub_responses( + :get_widget, + { string_property: 'retry' }, + { string_property: 'retry' }, + { string_property: 'retry' }, + { string_property: 'success' } + ) + 1.upto(3) do |attempt| + expect_any_instance_of(Waiters::Waiter).to receive(:delay).with(attempt).and_wrap_original do |m, *args| + delay = m.call(*args) + expect(delay.between?(min_delay, max_delay)).to be true + delay + end + end + client.wait_until(:output_string_property_matcher, {}, options) + end + + it 'sets the delay to remaining time for the last attempt' do + remaining_time = 40 + options = { + max_wait_time: remaining_time, + min_delay: 25, + max_delay: 30 + } + client.stub_responses( + :get_widget, + { string_property: 'retry' }, + { string_property: 'success' } + ) + expect_any_instance_of(Waiters::Waiter).to receive(:delay).and_wrap_original do |m, *args| + delay = m.call(*args) + expect(delay).to eq(remaining_time) + delay + end + client.wait_until(:output_string_property_matcher, {}, options) + end + end + + context 'errors' do + it 'raises an error when max wait time is exceeded' do + client.stub_responses(:get_widget, {}) + expect do + client.wait_until(:output_string_property_matcher, {}, max_wait_time: 0) + end.to raise_error(Waiters::MaxWaitTimeExceededError) + end + + it 'raises an error when max_wait_time is not provided' do + expect do + client.wait_until(:output_string_property_matcher, {}) + end.to raise_error(ArgumentError, 'expected `:max_wait_time` to be an Integer, got: NilClass') + end + + it 'raises an error when max_delay is less than 1' do + options = { + max_wait_time: 5, + max_delay: 0 + } + expect do + client.wait_until(:output_string_property_matcher, {}, options) + end.to raise_error(ArgumentError, '`:max_delay` must be greater than 0') + end + + it 'raises an error when min_delay is less than 1' do + options = { + max_wait_time: 5, + min_delay: 0 + } + expect do + client.wait_until(:output_string_property_matcher, {}, options) + end.to raise_error(ArgumentError, + '`:min_delay` must be greater than 0 and less than or equal to `:max_delay`') + end + + it 'raises an error when max_delay is less than min_delay' do + options = { + max_wait_time: 5, + min_delay: 4, + max_delay: 2 + } + expect do + client.wait_until(:output_string_property_matcher, {}, options) + end.to raise_error(ArgumentError, + '`:min_delay` must be greater than 0 and less than or equal to `:max_delay`') + end + end + end + + describe Waiters::Poller do + context 'success matcher' do + let(:matchers) do + { + 'SuccessTrueMatcher' => { + 'acceptors' => [{ 'state' => 'success', 'matcher' => { 'success' => true } }] + }, + 'SuccessFalseMatcher' => { + 'acceptors' => [{ 'state' => 'success', 'matcher' => { 'success' => false } }] + } + } + end + + it 'succeeds when success is set to true and successful response is received' do + client.stub_responses(:get_widget, {}) + expect { wait(:success_true_matcher) }.to_not raise_error + end + + it 'succeeds when success is set to false and error is received' do + client.stub_responses(:get_widget, StandardError.new) + expect { wait(:success_false_matcher) }.to_not raise_error + end + + it 'retries and succeeds when matched' do + client.stub_responses( + :get_widget, + {}, + {}, + StandardError + ) + expect_any_instance_of(Waiters::Waiter).to receive(:sleep).twice + expect { wait(:success_false_matcher) }.to_not raise_error + end + + it 'fails when success is set to true and unexpected error is received' do + client.stub_responses(:get_widget, StandardError) + expect { wait(:success_true_matcher) } + .to raise_error(Waiters::UnexpectedError) + end + end + + context 'error type matcher' do + let(:matchers) do + { + 'ErrorTypeMatcher' => { + 'acceptors' => [{ 'state' => 'success', 'matcher' => { 'errorType' => 'Error' } }] + }, + 'AbsoluteErrorTypeMatcher' => { + 'acceptors' => [{ 'state' => 'success', 'matcher' => { 'errorType' => 'smithy.ruby.tests#Error' } }] + } + } + end + + it 'succeeds when error matches for relative shape name' do + client.stub_responses(:get_widget, 'Error') + expect { wait(:error_type_matcher) }.to_not raise_error + end + + it 'succeeds when error matches for absolute shape id' do + client.stub_responses(:get_widget, 'Error') + expect { wait(:absolute_error_type_matcher) }.to_not raise_error + end + + it 'retries and succeeds when matched' do + client.stub_responses( + :get_widget, + {}, + {}, + 'Error' + ) + expect_any_instance_of(Waiters::Waiter).to receive(:sleep).twice + expect { wait(:error_type_matcher) }.to_not raise_error + end + + it 'fails when error does not match' do + client.stub_responses(:get_widget, StandardError) + expect { wait(:error_type_matcher) } + .to raise_error(Waiters::UnexpectedError) + end + end + + context 'string equals comparator' do + let(:matchers) do + { + 'OutputStringPropertyMatcher' => { + 'acceptors' => [ + { + 'state' => 'success', + 'matcher' => { + 'output' => { + 'path' => 'stringProperty', + 'expected' => 'expected string', + 'comparator' => 'stringEquals' + } + } + } + ] + } + } + end + + it 'succeeds when output matches' do + client.stub_responses(:get_widget, { string_property: 'expected string' }) + expect { wait(:output_string_property_matcher) }.to_not raise_error + end + + it 'retries and succeeds when matched' do + client.stub_responses( + :get_widget, + { string_property: 'unexpected string' }, + { string_property: 'unexpected string' }, + { string_property: 'expected string' } + ) + expect_any_instance_of(Waiters::Waiter).to receive(:sleep).twice + expect { wait(:output_string_property_matcher) }.to_not raise_error + end + + it 'fails when output property does not match' do + client.stub_responses(:get_widget, { string_property: 'unexpected string' }) + expect { wait(:output_string_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + + it 'fails when output property is nil' do + client.stub_responses(:get_widget, {}) + expect { wait(:output_string_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + end + + context 'boolean equals comparator' do + let(:matchers) do + { + 'OutputBooleanPropertyMatcher' => { + 'acceptors' => [ + { + 'state' => 'success', + 'matcher' => { + 'output' => { + 'path' => 'booleanProperty', + 'expected' => 'false', + 'comparator' => 'booleanEquals' + } + } + } + ] + } + } + end + + it 'succeeds when output matches' do + client.stub_responses(:get_widget, { boolean_property: false }) + expect { wait(:output_boolean_property_matcher) }.to_not raise_error + end + + it 'retries and succeeds when matched' do + client.stub_responses( + :get_widget, + { boolean_property: true }, + { boolean_property: true }, + { boolean_property: false } + ) + expect_any_instance_of(Waiters::Waiter).to receive(:sleep).twice + expect { wait(:output_boolean_property_matcher) }.to_not raise_error + end + + it 'fails when output property does not match' do + client.stub_responses(:get_widget, { boolean_property: true }) + expect { wait(:output_boolean_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + + it 'fails when output property is nil' do + client.stub_responses(:get_widget, {}) + expect { wait(:output_boolean_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + end + + context 'all string equals comparator' do + let(:matchers) do + { + 'OutputStringArrayAllPropertyMatcher' => { + 'acceptors' => [ + { + 'state' => 'success', + 'matcher' => { + 'output' => { + 'path' => 'stringArrayProperty', + 'expected' => 'expected string', + 'comparator' => 'allStringEquals' + } + } + } + ] + } + } + end + + it 'succeeds when output matches' do + output = { + string_array_property: [ + 'expected string', + 'expected string', + 'expected string' + ] + } + client.stub_responses(:get_widget, output) + expect { wait(:output_string_array_all_property_matcher) }.to_not raise_error + end + + it 'retries and succeeds when matched' do + output_expected = { + string_array_property: [ + 'expected string', + 'expected string', + 'expected string' + ] + } + output_unexpected = { + string_array_property: [ + 'expected string', + 'unexpected string', + 'unexpected string' + ] + } + client.stub_responses( + :get_widget, + output_unexpected, + output_unexpected, + output_expected + ) + expect_any_instance_of(Waiters::Waiter).to receive(:sleep).twice + expect { wait(:output_string_array_all_property_matcher) }.to_not raise_error + end + + it 'fails when output property does not match' do + output_unexpected = { + string_array_property: [ + 'expected string', + 'unexpected string', + 'unexpected string' + ] + } + client.stub_responses(:get_widget, output_unexpected) + expect { wait(:output_string_array_all_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + + it 'fails when output property is empty' do + client.stub_responses(:get_widget, { string_array_property: [] }) + expect { wait(:output_string_array_all_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + + it 'fails when output property is nil' do + client.stub_responses(:get_widget, {}) + expect { wait(:output_string_array_all_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + end + + context 'any string equals comparator' do + let(:matchers) do + { + 'OutputStringArrayAnyPropertyMatcher' => { + 'acceptors' => [ + { + 'state' => 'success', + 'matcher' => { + 'output' => { + 'path' => 'stringArrayProperty', + 'expected' => 'expected string', + 'comparator' => 'anyStringEquals' + } + } + } + ] + } + } + end + + it 'succeeds when output matches' do + output = { + string_array_property: [ + 'some other string', + 'another string', + 'expected string', + 'yet another string' + ] + } + client.stub_responses(:get_widget, output) + expect { wait(:output_string_array_any_property_matcher) }.to_not raise_error + end + + it 'retries and succeeds when matched' do + output_expected = { + string_array_property: [ + 'some other string', + 'another string', + 'expected string', + 'yet another string' + ] + } + output_unexpected = { + string_array_property: [ + 'some other string', + 'another string', + 'unexpected string', + 'yet another string' + ] + } + client.stub_responses( + :get_widget, + output_unexpected, + output_unexpected, + output_expected + ) + expect_any_instance_of(Waiters::Waiter).to receive(:sleep).twice + expect { wait(:output_string_array_any_property_matcher) }.to_not raise_error + end + + it 'fails when output property does not match' do + output_unexpected = { + string_array_property: [ + 'some other string', + 'another string', + 'unexpected string', + 'yet another string' + ] + } + client.stub_responses(:get_widget, output_unexpected) + expect { wait(:output_string_array_any_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + + it 'fails when output property is empty' do + client.stub_responses(:get_widget, { string_array_property: [] }) + expect { wait(:output_string_array_any_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + + it 'fails when output property is nil' do + client.stub_responses(:get_widget, {}) + expect { wait(:output_string_array_any_property_matcher) } + .to raise_error(Waiters::MaxWaitTimeExceededError) + end + end + + context 'input output matcher' do + let(:matchers) do + { + 'InputOutputBooleanPropertyMatcher' => { + 'acceptors' => [ + { + 'state' => 'success', + 'matcher' => { + 'inputOutput' => { + 'path' => 'input.stringProperty == output.stringProperty', + 'expected' => 'true', + 'comparator' => 'booleanEquals' + } + } + } + ] + } + } + end + + it 'succeeds for boolean equals comparator' do + client.stub_responses(:get_widget, { string_property: 'input' }) + expect { wait(:input_output_boolean_property_matcher) }.to_not raise_error + end + end + + context 'order' do + let(:matchers) do + { + 'AcceptorOrderSuccessMatcher' => { + 'acceptors' => [ + { 'state' => 'success', 'matcher' => { 'errorType' => 'Error' } }, + { 'state' => 'failure', 'matcher' => { 'errorType' => 'Error' } } + ] + }, + 'AcceptorOrderFailureMatcher' => { + 'acceptors' => [ + { 'state' => 'failure', 'matcher' => { 'errorType' => 'Error' } }, + { 'state' => 'success', 'matcher' => { 'errorType' => 'Error' } } + ] + } + } + end + + it 'checks acceptors in order' do + client.stub_responses(:get_widget, 'Error') + expect { wait(:acceptor_order_success_matcher) }.to_not raise_error + expect { wait(:acceptor_order_failure_matcher) } + .to raise_error(Waiters::FailureStateError) + end + end + end + end + end +end diff --git a/gems/smithy/lib/smithy/generators/client.rb b/gems/smithy/lib/smithy/generators/client.rb index 811e72c63..689cb34b8 100644 --- a/gems/smithy/lib/smithy/generators/client.rb +++ b/gems/smithy/lib/smithy/generators/client.rb @@ -53,6 +53,7 @@ def source_files code_generated_plugins.each { |path, plugin| e.yield path, plugin.source } e.yield "lib/#{@gem_name}/types.rb", Views::Client::Types.new(@plan).render e.yield "lib/#{@gem_name}/schema.rb", Views::Client::Schema.new(@plan).render + e.yield "lib/#{@gem_name}/waiters.rb", Views::Client::Waiters.new(@plan).render e.yield "lib/#{@gem_name}/client.rb", Views::Client::Client.new(@plan, code_generated_plugins).render end end @@ -83,13 +84,13 @@ def rbs_files def code_generated_plugins Enumerator.new do |e| e.yield "lib/#{@gem_name}/plugins/auth.rb", Views::Client::Plugin.new( - class_name: "#{@plan.module_name}::Plugins::Auth", + class_name: "::#{@plan.module_name}::Plugins::Auth", require_path: 'plugins/auth', require_relative: true, source: Views::Client::AuthPlugin.new(@plan).render ) e.yield "lib/#{@gem_name}/plugins/endpoint.rb", Views::Client::Plugin.new( - class_name: "#{@plan.module_name}::Plugins::Endpoint", + class_name: "::#{@plan.module_name}::Plugins::Endpoint", require_path: 'plugins/endpoint', require_relative: true, source: Views::Client::EndpointPlugin.new(@plan).render diff --git a/gems/smithy/lib/smithy/templates/client/client.erb b/gems/smithy/lib/smithy/templates/client/client.erb index f76920bb9..20c8956bf 100644 --- a/gems/smithy/lib/smithy/templates/client/client.erb +++ b/gems/smithy/lib/smithy/templates/client/client.erb @@ -36,6 +36,72 @@ module <%= module_name %> end <% end -%> + # Polls an API operation until a resource enters a desired state. + # + # ## Basic Usage + # + # A waiter will call an API operation until: + # + # * It is successful + # * It enters a terminal state + # * It reaches the maximum wait time allowed + # + # In between attempts, the waiter will sleep. + # + # # polls in a loop, sleeping between attempts + # client.wait_until(waiter_name, params, options) + # + # ## Configuration + # + # You must configure the maximum amount of time in seconds a + # waiter should wait for. You may also configure the minimum + # and maximum amount of time in seconds to delay between + # retries. You can pass these configuration as the final + # arguments hash. + # + # weather = Weather::Client.new + # + # # poll for a maximum of 25 seconds + # weather.wait_until(:forecast_exists, { forecast_id: '1' }, { + # max_wait_time: 25, + # min_delay: 2, + # max_delay: 10 + # }) + # + # ## Handling Errors + # + # When a waiter is unsuccessful, it will raise an error. + # All the failure errors extend from + # {Smithy::Client::Waiters::WaiterFailed}. + # + # weather = Weather::Client.new + # begin + # weather.wait_until(:forecast_exists, { forecast_id: '1' }, max_wait_time: 60) + # rescue Smithy::Client::Waiters::WaiterFailed + # # resource did not enter the desired state in time + # end + # + # + # @param [Symbol] waiter_name + # @param [Hash] params ({}) + # @param [Hash] options ({}) + # @option options [Integer] :max_wait_time + # @option options [Integer] :min_delay + # @option options [Integer] :max_delay + # @return [nil] Returns `nil` if the waiter was successful. + # @raise [FailureStateError] Raised when the waiter terminates + # because the waiter has entered a state that it will not transition + # out of, preventing success. + # @raise [MaxWaitTimeExceededError] Raised when the configured + # maximum wait time is reached and the waiter is not yet successful. + # @raise [UnexpectedError] Raised when an error that is not + # expected is encountered while polling for a resource. + # @raise [NoSuchWaiterError] Raised when you request to wait + # for an unknown state. + def wait_until(waiter_name, params = {}, options = {}) + waiter(waiter_name, options).wait(params) + end + private def build_input(operation_name, params) @@ -52,6 +118,12 @@ module <%= module_name %> Smithy::Client::Input.new(handlers: handlers, context: context) end + def waiters +<% waiters.each do |line| -%> + <%= line %> +<% end -%> + end + class << self # @api private attr_reader :identifier diff --git a/gems/smithy/lib/smithy/templates/client/waiters.erb b/gems/smithy/lib/smithy/templates/client/waiters.erb new file mode 100644 index 000000000..70cb16706 --- /dev/null +++ b/gems/smithy/lib/smithy/templates/client/waiters.erb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# This is generated code! + +module <%= module_name %> + + # @api private + module Waiters +<% waiters.each do |waiter| -%> +<% if waiter.deprecated? -%> + # @deprecated +<% end -%> + # @api private + # +<% waiter.docstrings.each do |docstring| -%> + # <%= docstring %> +<% end -%> + # + class <%= waiter.name %> + def initialize(options = {}) + @client = options[:client] + @waiter = Smithy::Client::Waiters::Waiter.new( + max_wait_time: options[:max_wait_time], + min_delay: options[:min_delay] || <%= waiter.min_delay %>, + max_delay: options[:max_delay] || <%= waiter.max_delay %>, + poller: Smithy::Client::Waiters::Poller.new( + operation_name: :<%= waiter.operation_name %>, +<% waiter.acceptors.each do |acceptor| -%> + <%= acceptor %> +<% end -%> + ) + ) + end + + def wait(params = {}) + @waiter.wait(@client, params) + end + end + +<% end -%> + end +end \ No newline at end of file diff --git a/gems/smithy/lib/smithy/views/client.rb b/gems/smithy/lib/smithy/views/client.rb index 8acf691e3..0333cb324 100644 --- a/gems/smithy/lib/smithy/views/client.rb +++ b/gems/smithy/lib/smithy/views/client.rb @@ -44,3 +44,4 @@ module Client; end require_relative 'client/spec_helper' require_relative 'client/types' require_relative 'client/types_rbs' +require_relative 'client/waiters' diff --git a/gems/smithy/lib/smithy/views/client/client.rb b/gems/smithy/lib/smithy/views/client/client.rb index 222717853..b41965b76 100644 --- a/gems/smithy/lib/smithy/views/client/client.rb +++ b/gems/smithy/lib/smithy/views/client/client.rb @@ -67,6 +67,19 @@ def protocols @protocols ||= @plan.welds.map(&:protocols).reduce({}, :merge) end + def waiters + waiters = Views::Client::Waiters.new(@plan).waiters + return ['{}'] if waiters.empty? + + lines = ['{'] + waiters.each do |waiter| + lines << " #{waiter.name.underscore}: Waiters::#{waiter.name}," + end + lines.last.chomp!(',') if lines.last.end_with?(',') + lines << '}' + lines + end + private def option_docstrings(option) diff --git a/gems/smithy/lib/smithy/views/client/module.rb b/gems/smithy/lib/smithy/views/client/module.rb index 467e845de..cce720050 100644 --- a/gems/smithy/lib/smithy/views/client/module.rb +++ b/gems/smithy/lib/smithy/views/client/module.rb @@ -60,7 +60,7 @@ def relative_requires # paginators must come before schemas %w[types paginators schema auth_parameters auth_resolver client customizations errors endpoint_parameters - endpoint_provider] + endpoint_provider waiters] end private diff --git a/gems/smithy/lib/smithy/views/client/waiters.rb b/gems/smithy/lib/smithy/views/client/waiters.rb new file mode 100644 index 000000000..432dc74a0 --- /dev/null +++ b/gems/smithy/lib/smithy/views/client/waiters.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Smithy + module Views + module Client + # @api private + class Waiters < View + def initialize(plan) + @plan = plan + @model = @plan.model + super() + end + + def module_name + @plan.module_name + end + + def waiters + waiters = [] + operations = Model::ServiceIndex.new(@model).operations_for(@plan.service) + operations.each do |operation_id, operation| + waiters_from_trait = waitable_trait(operation) + next if waiters_from_trait.empty? + + operation_name = Model::Shape.name(operation_id).underscore + waiters_from_trait.map do |waiter_name, waiter| + waiters << Waiter.new(operation_name, waiter_name, waiter) + end + end + waiters.sort_by(&:name) + end + + private + + def waitable_trait(operation) + operation.fetch('traits', {}).fetch('smithy.waiters#waitable', {}) + end + + # @api private + class Waiter + def initialize(operation_name, name, waiter) + @operation_name = operation_name + @name = name + @documentation = waiter.fetch('documentation', '') + @acceptors = formatted_acceptors(waiter['acceptors']) + @min_delay = waiter['minDelay'] || 2 + @max_delay = waiter['maxDelay'] || 120 + @deprecated = waiter['deprecated'] + end + + attr_reader :operation_name, :name, :documentation, :acceptors, :min_delay, :max_delay + + def docstrings + @documentation.split("\n") + end + + def formatted_acceptors(acceptors) + acceptors.each { |acceptor| preprocess_acceptor(acceptor) } + + Util::HashFormatter.new( + wrap: false, + inline: false, + quote_strings: true + ).format(acceptors: acceptors).split("\n") + end + + def preprocess_acceptor(acceptor) + if (matcher = acceptor['matcher']['output'] || acceptor['matcher']['inputOutput']) + matcher['path'] = Util::Underscore.underscore_jmespath(matcher['path']) + elsif (error_type = acceptor['matcher']['errorType']) + acceptor['matcher']['errorType'] = Model::Shape.name(error_type) + end + end + + def deprecated? + !!@deprecated + end + end + end + end + end +end diff --git a/gems/smithy/spec/fixtures/waiters/model.json b/gems/smithy/spec/fixtures/waiters/model.json new file mode 100644 index 000000000..af68663c6 --- /dev/null +++ b/gems/smithy/spec/fixtures/waiters/model.json @@ -0,0 +1,367 @@ +{ + "smithy": "2.0", + "shapes": { + "smithy.protocols#StringList": { + "type": "list", + "member": { + "target": "smithy.api#String" + }, + "traits": { + "smithy.api#documentation": "A list of String shapes.", + "smithy.api#private": {} + } + }, + "smithy.protocols#rpcv2Cbor": { + "type": "structure", + "members": { + "http": { + "target": "smithy.protocols#StringList", + "traits": { + "smithy.api#documentation": "Priority ordered list of supported HTTP protocol versions." + } + }, + "eventStreamHttp": { + "target": "smithy.protocols#StringList", + "traits": { + "smithy.api#documentation": "Priority ordered list of supported HTTP protocol versions\nthat are required when using event streams." + } + } + }, + "traits": { + "smithy.api#documentation": "An RPC-based protocol that serializes CBOR payloads.", + "smithy.api#protocolDefinition": { + "traits": [ + "smithy.api#cors", + "smithy.api#endpoint", + "smithy.api#hostLabel", + "smithy.api#httpError" + ] + }, + "smithy.api#trait": { + "selector": "service" + }, + "smithy.api#traitValidators": { + "rpcv2Cbor.NoDocuments": { + "selector": "service ~> member :test(> document)", + "message": "This protocol does not support document types" + } + } + } + }, + "smithy.ruby.tests#GetOperation": { + "type": "operation", + "input": { + "target": "smithy.ruby.tests#OperationInput" + }, + "output": { + "target": "smithy.ruby.tests#OperationOutput" + }, + "traits": { + "smithy.waiters#waitable": { + "SuccessMatcher": { + "documentation": "Acceptor matches on successful request", + "acceptors": [ + { + "state": "success", + "matcher": { + "success": true + } + } + ] + } + } + } + }, + "smithy.ruby.tests#OperationInput": { + "type": "structure", + "members": { + "stringProperty": { + "target": "smithy.api#String" + } + } + }, + "smithy.ruby.tests#OperationOutput": { + "type": "structure", + "members": { + "stringProperty": { + "target": "smithy.api#String" + } + } + }, + "smithy.ruby.tests#WaiterService": { + "type": "service", + "version": "2022-11-30", + "operations": [ + { + "target": "smithy.ruby.tests#GetOperation" + } + ], + "traits": { + "smithy.protocols#rpcv2Cbor": {} + } + }, + "smithy.waiters#Acceptor": { + "type": "structure", + "members": { + "state": { + "target": "smithy.waiters#AcceptorState", + "traits": { + "smithy.api#documentation": "The state the acceptor transitions to when matched.", + "smithy.api#required": {} + } + }, + "matcher": { + "target": "smithy.waiters#Matcher", + "traits": { + "smithy.api#documentation": "The matcher used to test if the resource is in a given state.", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Represents an acceptor in a waiter's state machine.", + "smithy.api#private": {} + } + }, + "smithy.waiters#AcceptorState": { + "type": "enum", + "members": { + "SUCCESS": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "The waiter successfully finished waiting. This is a terminal\nstate that causes the waiter to stop.", + "smithy.api#enumValue": "success" + } + }, + "FAILURE": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "The waiter failed to enter into the desired state. This is a\nterminal state that causes the waiter to stop.", + "smithy.api#enumValue": "failure" + } + }, + "RETRY": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "The waiter will retry the operation. This state transition is\nimplicit if no accepter causes a state transition.", + "smithy.api#enumValue": "retry" + } + } + }, + "traits": { + "smithy.api#documentation": "The transition state of a waiter.", + "smithy.api#private": {} + } + }, + "smithy.waiters#Acceptors": { + "type": "list", + "member": { + "target": "smithy.waiters#Acceptor" + }, + "traits": { + "smithy.api#length": { + "min": 1 + }, + "smithy.api#private": {} + } + }, + "smithy.waiters#Matcher": { + "type": "union", + "members": { + "output": { + "target": "smithy.waiters#PathMatcher", + "traits": { + "smithy.api#documentation": "Matches on the successful output of an operation using a\nJMESPath expression." + } + }, + "inputOutput": { + "target": "smithy.waiters#PathMatcher", + "traits": { + "smithy.api#documentation": "Matches on both the input and output of an operation using a JMESPath\nexpression. Input parameters are available through the top-level\n`input` field, and output data is available through the top-level\n`output` field. This matcher can only be used on operations that\ndefine both input and output. This matcher is checked only if an\noperation completes successfully." + } + }, + "errorType": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "Matches if an operation returns an error and the error matches\nthe expected error type. If an absolute shape ID is provided, the\nerror is matched exactly on the shape ID. A shape name can be\nprovided to match an error in any namespace with the given name." + } + }, + "success": { + "target": "smithy.api#Boolean", + "traits": { + "smithy.api#documentation": "When set to `true`, matches when an operation returns a successful\nresponse. When set to `false`, matches when an operation fails with\nany error." + } + } + }, + "traits": { + "smithy.api#documentation": "Defines how an acceptor determines if it matches the current state of\na resource.", + "smithy.api#private": {} + } + }, + "smithy.waiters#NonEmptyString": { + "type": "string", + "traits": { + "smithy.api#length": { + "min": 1 + }, + "smithy.api#private": {} + } + }, + "smithy.waiters#NonEmptyStringList": { + "type": "list", + "member": { + "target": "smithy.waiters#NonEmptyString" + }, + "traits": { + "smithy.api#private": {} + } + }, + "smithy.waiters#PathComparator": { + "type": "enum", + "members": { + "STRING_EQUALS": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Matches if the return value is a string that is equal to the expected string.", + "smithy.api#enumValue": "stringEquals" + } + }, + "BOOLEAN_EQUALS": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Matches if the return value is a boolean that is equal to the string literal 'true' or 'false'.", + "smithy.api#enumValue": "booleanEquals" + } + }, + "ALL_STRING_EQUALS": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Matches if all values in the list matches the expected string.", + "smithy.api#enumValue": "allStringEquals" + } + }, + "ANY_STRING_EQUALS": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Matches if any value in the list matches the expected string.", + "smithy.api#enumValue": "anyStringEquals" + } + } + }, + "traits": { + "smithy.api#documentation": "Defines a comparison to perform in a PathMatcher.", + "smithy.api#private": {} + } + }, + "smithy.waiters#PathMatcher": { + "type": "structure", + "members": { + "path": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "A JMESPath expression applied to the input or output of an operation.", + "smithy.api#required": {} + } + }, + "expected": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "The expected return value of the expression.", + "smithy.api#required": {} + } + }, + "comparator": { + "target": "smithy.waiters#PathComparator", + "traits": { + "smithy.api#documentation": "The comparator used to compare the result of the expression with the\nexpected value.", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Defines how to test the result of a JMESPath expression against\nan expected value.", + "smithy.api#private": {} + } + }, + "smithy.waiters#Waiter": { + "type": "structure", + "members": { + "documentation": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "Documentation about the waiter. Can use CommonMark." + } + }, + "acceptors": { + "target": "smithy.waiters#Acceptors", + "traits": { + "smithy.api#documentation": "An ordered array of acceptors to check after executing an operation.", + "smithy.api#required": {} + } + }, + "minDelay": { + "target": "smithy.waiters#WaiterDelay", + "traits": { + "smithy.api#default": 2, + "smithy.api#documentation": "The minimum amount of time in seconds to delay between each retry.\nThis value defaults to 2 if not specified. If specified, this value\nMUST be greater than or equal to 1 and less than or equal to\n`maxDelay`." + } + }, + "maxDelay": { + "target": "smithy.waiters#WaiterDelay", + "traits": { + "smithy.api#default": 120, + "smithy.api#documentation": "The maximum amount of time in seconds to delay between each retry.\nThis value defaults to 120 if not specified (or, 2 minutes). If\nspecified, this value MUST be greater than or equal to 1." + } + }, + "deprecated": { + "target": "smithy.api#Boolean", + "traits": { + "smithy.api#documentation": "Indicates if the waiter is considered deprecated. A waiter SHOULD\nbe marked as deprecated if it has been replaced by another waiter or\nif it is no longer needed (for example, if a resource changes from\neventually consistent to strongly consistent)." + } + }, + "tags": { + "target": "smithy.waiters#NonEmptyStringList", + "traits": { + "smithy.api#documentation": "A list of tags associated with the waiter that allow waiters to be\ncategorized and grouped." + } + } + }, + "traits": { + "smithy.api#documentation": "Defines an individual operation waiter.", + "smithy.api#private": {} + } + }, + "smithy.waiters#WaiterDelay": { + "type": "integer", + "traits": { + "smithy.api#range": { + "min": 1 + } + } + }, + "smithy.waiters#WaiterName": { + "type": "string", + "traits": { + "smithy.api#pattern": "^[A-Z]+[A-Za-z0-9]*$" + } + }, + "smithy.waiters#waitable": { + "type": "map", + "key": { + "target": "smithy.waiters#WaiterName" + }, + "value": { + "target": "smithy.waiters#Waiter" + }, + "traits": { + "smithy.api#documentation": "Indicates that an operation has various named \"waiters\" that can be used\nto poll a resource until it enters a desired state.", + "smithy.api#length": { + "min": 1 + }, + "smithy.api#trait": { + "selector": "operation :not(-[input, output]-> structure > member > union[trait|streaming])" + } + } + } + } +} diff --git a/gems/smithy/spec/fixtures/waiters/model.smithy b/gems/smithy/spec/fixtures/waiters/model.smithy new file mode 100644 index 000000000..e06e63193 --- /dev/null +++ b/gems/smithy/spec/fixtures/waiters/model.smithy @@ -0,0 +1,38 @@ +$version: "2" + +namespace smithy.ruby.tests + +use smithy.waiters#waitable +use smithy.protocols#rpcv2Cbor + +@rpcv2Cbor +service WaiterService { + version: "2022-11-30", + operations: [GetOperation] +} + +@waitable( + SuccessMatcher: { + documentation: "Acceptor matches on successful request" + acceptors: [ + { + state: "success" + matcher: { + success: true + } + } + ] + } +) +operation GetOperation { + input: OperationInput, + output: OperationOutput +} + +structure OperationInput { + stringProperty: String +} + +structure OperationOutput { + stringProperty: String +} \ No newline at end of file diff --git a/gems/smithy/spec/fixtures/waiters/smithy-build.json b/gems/smithy/spec/fixtures/waiters/smithy-build.json new file mode 100644 index 000000000..6a48179f7 --- /dev/null +++ b/gems/smithy/spec/fixtures/waiters/smithy-build.json @@ -0,0 +1,9 @@ +{ + "version": "1.0", + "maven": { + "dependencies": [ + "software.amazon.smithy:smithy-waiters:1.56.0", + "software.amazon.smithy:smithy-protocol-traits:1.56.0" + ] + } +} \ No newline at end of file diff --git a/gems/smithy/spec/interfaces/client/waiters_spec.rb b/gems/smithy/spec/interfaces/client/waiters_spec.rb new file mode 100644 index 000000000..0916b4d4a --- /dev/null +++ b/gems/smithy/spec/interfaces/client/waiters_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +describe 'Client: Waiters' do + ['generated client gem', 'generated client from source code'].each do |context| + context context do + include_context context, 'Waiters' + + let(:client) { Waiters::Client.new(stub_responses: true) } + let(:no_such_waiter_error) { Smithy::Client::Waiters::NoSuchWaiterError } + + it 'returns nil when successful' do + expect(client.wait_until(:success_matcher, {}, max_wait_time: 60)).to be(nil) + end + + it 'raises waiter failed error when unsuccessful' do + client.stub_responses(:get_operation, StandardError) + expect do + client.wait_until(:success_matcher, {}, max_wait_time: 60) + end.to raise_error(Smithy::Client::Waiters::WaiterFailed) + end + + it 'raises an error for nonexistent waiters' do + expect do + client.wait_until(:nonexistent_waiter) + end.to raise_error(Smithy::Client::Waiters::NoSuchWaiterError) + end + end + end +end diff --git a/projections/shapes/lib/shapes.rb b/projections/shapes/lib/shapes.rb index d5e4ec991..44f4a8f9e 100644 --- a/projections/shapes/lib/shapes.rb +++ b/projections/shapes/lib/shapes.rb @@ -21,3 +21,4 @@ module ShapeService require_relative 'shapes/errors' require_relative 'shapes/endpoint_parameters' require_relative 'shapes/endpoint_provider' +require_relative 'shapes/waiters' diff --git a/projections/shapes/lib/shapes/client.rb b/projections/shapes/lib/shapes/client.rb index 5af751b48..ccf19db67 100644 --- a/projections/shapes/lib/shapes/client.rb +++ b/projections/shapes/lib/shapes/client.rb @@ -264,6 +264,72 @@ def operation(params = {}, options = {}) input.send_request(options) end + # Polls an API operation until a resource enters a desired state. + # + # ## Basic Usage + # + # A waiter will call an API operation until: + # + # * It is successful + # * It enters a terminal state + # * It reaches the maximum wait time allowed + # + # In between attempts, the waiter will sleep. + # + # # polls in a loop, sleeping between attempts + # client.wait_until(waiter_name, params, options) + # + # ## Configuration + # + # You must configure the maximum amount of time in seconds a + # waiter should wait for. You may also configure the minimum + # and maximum amount of time in seconds to delay between + # retries. You can pass these configuration as the final + # arguments hash. + # + # weather = Weather::Client.new + # + # # poll for a maximum of 25 seconds + # weather.wait_until(:forecast_exists, { forecast_id: '1' }, { + # max_wait_time: 25, + # min_delay: 2, + # max_delay: 10 + # }) + # + # ## Handling Errors + # + # When a waiter is unsuccessful, it will raise an error. + # All the failure errors extend from + # {Smithy::Client::Waiters::WaiterFailed}. + # + # weather = Weather::Client.new + # begin + # weather.wait_until(:forecast_exists, { forecast_id: '1' }, max_wait_time: 60) + # rescue Smithy::Client::Waiters::WaiterFailed + # # resource did not enter the desired state in time + # end + # + # + # @param [Symbol] waiter_name + # @param [Hash] params ({}) + # @param [Hash] options ({}) + # @option options [Integer] :max_wait_time + # @option options [Integer] :min_delay + # @option options [Integer] :max_delay + # @return [nil] Returns `nil` if the waiter was successful. + # @raise [FailureStateError] Raised when the waiter terminates + # because the waiter has entered a state that it will not transition + # out of, preventing success. + # @raise [MaxWaitTimeExceededError] Raised when the configured + # maximum wait time is reached and the waiter is not yet successful. + # @raise [UnexpectedError] Raised when an error that is not + # expected is encountered while polling for a resource. + # @raise [NoSuchWaiterError] Raised when you request to wait + # for an unknown state. + def wait_until(waiter_name, params = {}, options = {}) + waiter(waiter_name, options).wait(params) + end + private def build_input(operation_name, params) @@ -280,6 +346,10 @@ def build_input(operation_name, params) Smithy::Client::Input.new(handlers: handlers, context: context) end + def waiters + {} + end + class << self # @api private attr_reader :identifier diff --git a/projections/shapes/lib/shapes/waiters.rb b/projections/shapes/lib/shapes/waiters.rb new file mode 100644 index 000000000..89181cd0b --- /dev/null +++ b/projections/shapes/lib/shapes/waiters.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This is generated code! + +module ShapeService + + # @api private + module Waiters + end +end \ No newline at end of file diff --git a/projections/weather/lib/weather.rb b/projections/weather/lib/weather.rb index 523e21fc0..589f61c2b 100644 --- a/projections/weather/lib/weather.rb +++ b/projections/weather/lib/weather.rb @@ -22,3 +22,4 @@ module Weather require_relative 'weather/errors' require_relative 'weather/endpoint_parameters' require_relative 'weather/endpoint_provider' +require_relative 'weather/waiters' diff --git a/projections/weather/lib/weather/client.rb b/projections/weather/lib/weather/client.rb index ecee0f88e..63127a915 100644 --- a/projections/weather/lib/weather/client.rb +++ b/projections/weather/lib/weather/client.rb @@ -252,6 +252,72 @@ def list_cities(params = {}, options = {}) input.send_request(options) end + # Polls an API operation until a resource enters a desired state. + # + # ## Basic Usage + # + # A waiter will call an API operation until: + # + # * It is successful + # * It enters a terminal state + # * It reaches the maximum wait time allowed + # + # In between attempts, the waiter will sleep. + # + # # polls in a loop, sleeping between attempts + # client.wait_until(waiter_name, params, options) + # + # ## Configuration + # + # You must configure the maximum amount of time in seconds a + # waiter should wait for. You may also configure the minimum + # and maximum amount of time in seconds to delay between + # retries. You can pass these configuration as the final + # arguments hash. + # + # weather = Weather::Client.new + # + # # poll for a maximum of 25 seconds + # weather.wait_until(:forecast_exists, { forecast_id: '1' }, { + # max_wait_time: 25, + # min_delay: 2, + # max_delay: 10 + # }) + # + # ## Handling Errors + # + # When a waiter is unsuccessful, it will raise an error. + # All the failure errors extend from + # {Smithy::Client::Waiters::WaiterFailed}. + # + # weather = Weather::Client.new + # begin + # weather.wait_until(:forecast_exists, { forecast_id: '1' }, max_wait_time: 60) + # rescue Smithy::Client::Waiters::WaiterFailed + # # resource did not enter the desired state in time + # end + # + # + # @param [Symbol] waiter_name + # @param [Hash] params ({}) + # @param [Hash] options ({}) + # @option options [Integer] :max_wait_time + # @option options [Integer] :min_delay + # @option options [Integer] :max_delay + # @return [nil] Returns `nil` if the waiter was successful. + # @raise [FailureStateError] Raised when the waiter terminates + # because the waiter has entered a state that it will not transition + # out of, preventing success. + # @raise [MaxWaitTimeExceededError] Raised when the configured + # maximum wait time is reached and the waiter is not yet successful. + # @raise [UnexpectedError] Raised when an error that is not + # expected is encountered while polling for a resource. + # @raise [NoSuchWaiterError] Raised when you request to wait + # for an unknown state. + def wait_until(waiter_name, params = {}, options = {}) + waiter(waiter_name, options).wait(params) + end + private def build_input(operation_name, params) @@ -268,6 +334,10 @@ def build_input(operation_name, params) Smithy::Client::Input.new(handlers: handlers, context: context) end + def waiters + {} + end + class << self # @api private attr_reader :identifier diff --git a/projections/weather/lib/weather/waiters.rb b/projections/weather/lib/weather/waiters.rb new file mode 100644 index 000000000..ce7aac632 --- /dev/null +++ b/projections/weather/lib/weather/waiters.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This is generated code! + +module Weather + + # @api private + module Waiters + end +end \ No newline at end of file