Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
0f224ac
Add waiters to weather model
richardwang1124 Apr 24, 2025
bbcb6cc
Initial code generation for waiters
richardwang1124 Apr 24, 2025
4da115b
Add conditional templating for waiters
richardwang1124 Apr 24, 2025
33bf1da
Add initial Waiters module
richardwang1124 Apr 24, 2025
ad95325
Change casing
richardwang1124 Apr 24, 2025
fd76a10
Start adding waiter tests
richardwang1124 Apr 25, 2025
c6a9395
Small fixes
richardwang1124 Apr 28, 2025
8f4856e
Dynamically create waiters
richardwang1124 Apr 28, 2025
4b0a05d
Add dynamic waiter to code gen
richardwang1124 Apr 28, 2025
3dd5e64
Debug test failure
richardwang1124 Apr 28, 2025
970f236
Revert fixture model changes
richardwang1124 Apr 29, 2025
c588576
Start adding tests
richardwang1124 Apr 29, 2025
5f70fa1
Add more unit tests
richardwang1124 Apr 30, 2025
ff32042
Add more unit tests
richardwang1124 Apr 30, 2025
0c8cf8b
Update test names
richardwang1124 May 1, 2025
63c6e6f
Add underscore methods
richardwang1124 May 1, 2025
d026d67
Cleanup
richardwang1124 May 1, 2025
f28e317
Remove runtime trait waiters
richardwang1124 May 2, 2025
01aca71
Add changes from review comments
richardwang1124 May 2, 2025
3ff3ca1
Underscore paths during code generation
richardwang1124 May 2, 2025
6decd06
Add documentation and deprecated traits to generated waiters
richardwang1124 May 2, 2025
779fe1a
Refactor unit tests
richardwang1124 May 5, 2025
e8e5f2b
Add more tests
richardwang1124 May 5, 2025
4b64c83
Remove weather changes
richardwang1124 May 5, 2025
dcfc31e
Merge decaf
richardwang1124 May 5, 2025
6da8bfb
Build projections with waiters
richardwang1124 May 5, 2025
b788ecd
Rubocop and changes from PR comments
richardwang1124 May 6, 2025
416a74d
Clean up and shave down unit tests
richardwang1124 May 7, 2025
93cd13d
Remove extra error from spec
richardwang1124 May 7, 2025
1fdd251
Add wait_until documentation
richardwang1124 May 7, 2025
b01734d
Move waiters errors to smithy client errors
richardwang1124 May 7, 2025
7a6b4d2
Preprocess error type
richardwang1124 May 7, 2025
f8493ed
Use docstrings
richardwang1124 May 7, 2025
6ef2dfd
Remove waiters/errors
richardwang1124 May 7, 2025
df63769
Add documentation check
richardwang1124 May 7, 2025
e58f50c
Fix documentation check
richardwang1124 May 7, 2025
874cf28
Handle raise response error enabled and use stub responses for tests
richardwang1124 May 8, 2025
c7c81dd
Update wait_until to return nil upon success
richardwang1124 May 8, 2025
470b0d3
Cleanup fixture
richardwang1124 May 8, 2025
dd11d0e
Fix RBS errors
richardwang1124 May 9, 2025
25394d5
Add some fixes from PR comments
richardwang1124 May 19, 2025
108834b
Add rest of PR comment fixes
richardwang1124 May 20, 2025
835e376
Define variables inside context
richardwang1124 May 20, 2025
3420325
Make PR comment changes
richardwang1124 May 21, 2025
70c3315
Add todo
richardwang1124 May 21, 2025
7ebe9b0
Fix namespace issue with plugins
May 21, 2025
1334bbe
Merge branch 'decaf' into feature/waiters
May 21, 2025
87185bc
Rubocop
May 21, 2025
61ada8c
Fix TODO
May 21, 2025
01a9483
Remove fixed test
richardwang1124 May 21, 2025
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
5 changes: 5 additions & 0 deletions gems/smithy-client/lib/smithy-client/waiters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

require_relative 'waiters/errors'
require_relative 'waiters/poller'
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
require_relative 'waiters/waiter'
54 changes: 54 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,54 @@
# frozen_string_literal: true

module Smithy
module Client
module Waiters
module Errors
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated

# 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"
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated

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

attr_reader :response
end

class MaxWaitTimeExceededError < WaiterFailed
MSG = "stopped waiting after maximum wait time of %s seconds was exceeded"

def initialize(max_wait_time)
super(MSG % [max_wait_time])
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
end
117 changes: 117 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,117 @@
# frozen_string_literal: true

module Smithy
module Client
module Waiters
class Poller
def initialize(options = {})
@operation_name = options[:operation_name]
@acceptors = options[:acceptors]
end

def call(client, params)
@input = params
begin
resp = client.send(@operation_name, params)
rescue StandardError => e
error = e
end
resp_or_error = resp || error
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
status = evaluate_acceptors(resp, error)
[resp_or_error, status]
end

private

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

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

def acceptor_matches?(matcher, resp, error)
matcher_type = matcher.keys[0]
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
send("matches_#{matcher_type}?", matcher[matcher_type], resp, error)
end

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

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

def matches_inputOutput?(path_matcher, resp, error)
return false unless error.nil? && @input

data = {
input: @input, ### Where do we get this?
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
output: resp
}
actual = JMESPath.search(underscore_jmespath(path_matcher['path']), data)
is_equal?(actual, path_matcher['expected'], path_matcher['comparator'])
end

def matches_success?(path_matcher, resp, error)
if path_matcher == true
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
!resp.nil?
else
!error.nil?
end
end

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

err = path_matcher.split('#').last.split('#').first
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
error.class.to_s.include?(err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't they be equal here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least when I was testing the actual error raised will include namespaces too (e.g. actual error is SampleClient1::Errors::MyError vs expected error MyError or smithy.ruby.tests#MyError).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the same preprocessing problem right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm preprocessing would ensure the expected error would always be MyError instead of smithy.ruby.tests#MyError but the actual error returned from the request would still be SampleClient1::Errors::MyError. Will it be guaranteed that the last part of the actual error after the :: delimiter will be equal to the expected error if it's supposed to match?

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.nil? || actual.empty?

actual.each do |value|
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
return false if value != expected
end
return true
when 'anyStringEquals'
return false if actual.nil? || actual.empty?

actual.each do |value|
return true if value == expected
end
return false
end
end

def underscore(string)
string.gsub(/::/, '/')
.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
.gsub(/([a-z\d])([A-Z])/,'\1_\2')
.tr("-", "_")
.downcase
end

def underscore_jmespath(expression)
expression
.gsub(' or ', '||')
.gsub(/(?<![`'])\b\w+\b(?![`'])/) { |str| underscore(str) }
end
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
end
end
end
end
69 changes: 69 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,69 @@
# frozen_string_literal: true

module Smithy
module Client
module Waiters
class Waiter
def initialize(options = {})
unless options[:max_wait_time].is_a?(Integer)
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
raise ArgumentError, 'Waiter must be initialized with `:max_wait_time`'
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
end

@max_wait_time = options[:max_wait_time]
@remaining_time = @max_wait_time
@min_delay = options[:min_delay]
@max_delay = options[:max_delay]
if @max_delay < 1
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
raise ArgumentError, '`:max_delay` must be greater than 0'
end
if @min_delay < 1 || @min_delay > @max_delay
raise ArgumentError, '`:min_delay` must be greater than 0 and less than or equal to `:max_delay`'
end

@poller = options[:poller]
@client = options[:client] # runtime approach
end

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

# Used for runtime approach
def wait_custom(params)
poll(@client, params)
end

private

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

case status.to_sym
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
when :retry
when :success then return resp
when :failure then raise Errors::FailureStateError.new(resp)
when :error then raise Errors::UnexpectedError.new(resp)
end

raise Errors::MaxWaitTimeExceededError.new(@max_wait_time) 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 = rand(@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
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
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
81 changes: 81 additions & 0 deletions gems/smithy/lib/smithy/templates/client/client.erb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ module <%= module_name %>
end

<% end -%>
<% if waiters? -%>
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
def wait_until(waiter_name, params = {}, options = {})
Comment thread
richardwang1124 marked this conversation as resolved.
w = waiter(waiter_name, options)
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
w.wait(params)
end

<% end -%>
# runtime approach
def wait_until_custom(waiter_name, params = {}, options = {})
operation_name, waiter_config = find_waiter(waiter_name)
poller = poller_custom(operation_name, waiter_config["acceptors"])
waiter = waiter_custom(waiter_config, options, poller)
waiter.wait_custom(params)
end

private

def build_input(operation_name, params)
Expand All @@ -51,6 +66,72 @@ module <%= module_name %>
context[:gem_version] = '<%= gem_version %>'
Smithy::Client::Input.new(handlers: handlers, context: context)
end
<% if waiters? %>
def waiter(waiter_name, options = {})
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
waiter_class = waiters[waiter_name]
if waiter_class
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
waiter_class.new(options.merge(client: self))
else
raise Smithy::Client::Waiters::Errors::NoSuchWaiterError.new
end
end

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

# runtime approach
Comment thread
richardwang1124 marked this conversation as resolved.
Outdated
def find_waiter(waiter_name)
operations = config.service.operations
operations.each do |operation_name, operation|
if (trait = waitable_trait(operation))
trait.each do |name, waiter|
if underscore(name) == waiter_name.to_s
return [operation_name, waiter]
end
end
end
end
raise Smithy::Client::Waiters::Errors::NoSuchWaiterError.new
end

# runtime approach
def waitable_trait(operation)
if operation.traits && !operation.traits['smithy.waiters#waitable'].nil?
operation.traits['smithy.waiters#waitable']
end
end

# runtime approach
def underscore(string)
string.gsub(/::/, '/')
.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
.gsub(/([a-z\d])([A-Z])/,'\1_\2')
.tr("-", "_")
.downcase
end

# runtime approach
def poller_custom(operation_name, acceptors)
Smithy::Client::Waiters::Poller.new(
operation_name: operation_name.to_sym,
acceptors: acceptors
)
end

# runtime approach
def waiter_custom(waiter_config, options, poller)
Smithy::Client::Waiters::Waiter.new(
max_wait_time: options[:max_wait_time],
min_delay: options[:min_delay] || waiter_config[:min_delay] || 2,
max_delay: options[:max_delay] || waiter_config[:max_delay] || 120,
poller: poller,
client: self
)
end

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

# This is generated code!

require 'smithy-client/waiters'

module <%= module_name %>
# @api private
module Waiters
<% waiters.each do |waiter| -%>
# @api private
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 %>
)
)
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'
Loading