Skip to content

Waiters #292

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: decaf
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions gems/smithy-client/lib/smithy-client/waiters/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module Smithy
module Client
module Errors

# Raised when a waiter detects a condition where the waiter can never
# succeed.
class WaiterFailed < StandardError; end

class FailureStateError < WaiterFailed
MSG = "stopped waiting, encountered a failure state"

def initialize(response)
@response = response
super(MSG)
end

attr_reader :response
end

class MaxWaitTimeExceededError < WaiterFailed
MSG = "stopped waiting after maximum wait time was exceeded"

def initialize
super(MSG)
end
end

class UnexpectedError < WaiterFailed
MSG = "stopped waiting due to an unexpected error: %s"

def initialize(error)
@error = error
super(MSG % [error.message])
end

attr_reader :error
end

# Raised when attempting to get a waiter by name and the waiter has not
# been defined.
class NoSuchWaiterError < ArgumentError
MSG = "no such waiter"

def initialize
super(MSG)
end
end
end
end
end
96 changes: 96 additions & 0 deletions gems/smithy-client/lib/smithy-client/waiters/poller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

module Smithy
module Client
module Waiters
class Poller
def initialize(operation_name, acceptors)
@operation_name = operation_name
@acceptors = acceptors
end

def call(client, params)
resp = client.send(@operation_name, params)
status = evaluate_acceptors(resp)
[resp, status]
end

def evaluate_acceptors(resp)
@acceptors.each do |acceptor|
return acceptor['state'] if acceptor_matches?(acceptor['matcher'], resp)
end

# If none of the acceptors match and an error was encountered,
# transition to failure state. Otherwise, transition to retry state.
if resp.error?
'error'
else
'retry'
end
end

def acceptor_matches?(matcher, resp)
matcher_type = matcher.keys[0]
send("matches_#{matcher_type}?")
end

def matches_output?(path_matcher, resp)
return false if resp.error || resp.data.nil?

actual = JMESPath.search(path_matcher['path'], resp.data)
is_equal?(actual, expected, path_matcher['comparator'])
end

def matches_inputOutput?(path_matcher, resp)
return false if resp.error

data = {
input: input, ### Where do we get this?
output: resp.data
}

actual = JMESPath.search(path_matcher['path'], data)
is_equal?(actual, expected, path_matcher['comparator'])
end

def matches_success?(path_matcher, resp)
if path_matcher['success']
resp.error.nil?
else
resp.error?
end
end

def matches_errorType?(path_matcher, resp)
return false unless resp.error

error = path_matcher['errorType'].split('#').last.split('#').first
error == resp.error
end

def is_equal?(actual, expected, comparator)
case comparator
when 'stringEquals'
return actual == expected
when 'booleanEquals'
return actual.to_s == expected
when 'allStringEquals'
return false if actual.empty?

actual.each do |value|
return false if value != expected
end
return true
when 'anyStringEquals'
return false if actual.empty?

actual.each do |value|
return true if value == expected
end
return false
end
end
end
end
end
end
50 changes: 50 additions & 0 deletions gems/smithy-client/lib/smithy-client/waiters/waiter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module Smithy
module Client
module Waiters
class Waiter
def initialize(options = {})
@remaining_time = options[:max_wait_time]
@min_delay = options[:min_delay]
@max_delay = options[:max_delay]
@poller = options[:poller]
end

def wait(client, params)
poll(client, params)
end

private

def poll(client, params)
attempts = 0
loop do
resp, status = @poller.call(client, params)

case status
when :retry
when :success then return resp
when :failure then return Errors::FailureStateError
when :error then return Errors::UnexpectedError
end

return Errors::MaxWaitTimeExceededError if @remaining_time == 0

delay = delay(attempts)
@remaining_time -= delay
sleep(delay)
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 = random(@min_delay, delay)
delay = @remaining_time if @remaining_time - delay <= @min_delay
delay
end
end
end
end
end
1 change: 1 addition & 0 deletions gems/smithy/lib/smithy/generators/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def source_files
e.yield "lib/#{@gem_name}/endpoint_parameters.rb", Views::Client::EndpointParameters.new(@plan).render
e.yield "lib/#{@gem_name}/endpoint_provider.rb", Views::Client::EndpointProvider.new(@plan).render
e.yield "lib/#{@gem_name}/paginators.rb", Views::Client::Paginators.new(@plan).render
e.yield "lib/#{@gem_name}/waiters.rb", Views::Client::Waiters.new(@plan).render
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
Expand Down
23 changes: 23 additions & 0 deletions gems/smithy/lib/smithy/templates/client/client.erb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ module <%= module_name %>
input.send_request(options)
end

<% end -%>
<% if waiters? -%>
def wait_until(waiter_name, params = {}, options = {})
w = waiter(waiter_name, options)
w.wait(params)
end

<% end -%>
private

Expand All @@ -51,6 +58,22 @@ module <%= module_name %>
context[:gem_version] = '<%= gem_version %>'
Smithy::Client::Input.new(handlers: handlers, context: context)
end
<% if waiters? %>
def waiter(waiter_name, options = {})
waiter_class = waiters[waiter_name]
if waiter_class
waiter_class.new(options.merge(client: self))
else
raise Errors::NoSuchWaiterError
end
end

def waiters
<% waiters.each do |line| -%>
<%= line %>
<% end -%>
end
<% end -%>

class << self
# @api private
Expand Down
31 changes: 31 additions & 0 deletions gems/smithy/lib/smithy/templates/client/waiters.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

# This is generated code!

module <%= module_name %>
# @api private
module Waiters
<% waiters.each do |waiter| -%>
# @api private
class <%= waiter.name %>
def initialize(options = {})
@client = options[:client]
@waiter = 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: Poller.new(
operation_name: <%= waiter.operation_name %>,
<%= waiter.acceptors %>
)
)
end

def wait(params = {})
@waiter.wait(@client, params)
end
end

<% end -%>
end
end
1 change: 1 addition & 0 deletions gems/smithy/lib/smithy/views/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
16 changes: 16 additions & 0 deletions gems/smithy/lib/smithy/views/client/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ def protocols
@protocols ||= @plan.welds.map(&:protocols).reduce({}, :merge)
end

def waiters
waiters = Views::Client::Waiters.new(@plan).waiters
code = [' {']
waiters.each_with_index do |waiter, i|
line = ' ' + waiter.name.underscore + ': Waiters::' + waiter.name + ','
line.chomp!(',') if i == waiters.length - 1
code << line
end
code << ' }'
code
end

def waiters?
Views::Client::Waiters.new(@plan).waiters.size > 0
end

private

def option_docstrings(option)
Expand Down
2 changes: 1 addition & 1 deletion gems/smithy/lib/smithy/views/client/module.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def relative_requires
return %i[customizations types schema] if @plan.type == :schema

# paginators must come before schemas
%w[types paginators schema auth_parameters auth_resolver client customizations errors endpoint_parameters
%w[types paginators waiters schema auth_parameters auth_resolver client customizations errors endpoint_parameters
endpoint_provider]
end
end
Expand Down
70 changes: 70 additions & 0 deletions gems/smithy/lib/smithy/views/client/waiters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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
Model::ServiceIndex
.new(@model)
.operations_for(@plan.service)
.map 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|
Waiter.new(operation_name, waiter_name, waiter)
end
end
.flatten
.compact
.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, waiter)
@operation_name = operation
@name = name
@documentation = waiter['documentation']
@acceptors = formatted_acceptors(waiter['acceptors'])
@min_delay = waiter['minDelay'] || 2
@max_delay = waiter['maxDelay'] || 120
@deprecated = waiter['deprecated']
@tags = waiter['tags']
end

attr_reader :operation_name, :name, :documentation, :acceptors, :min_delay, :max_delay, :deprecated, :tags

def formatted_acceptors(acceptors)
Util::HashFormatter.new(
wrap: false,
inline: false,
quote_strings: true,
indent: ' '
).format(acceptors: acceptors)
end
end
end
end
end
end
Loading
Loading