From 04fabf009f90c4fc7da785abf29a6a7f2c0e53f5 Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Fri, 5 Jun 2026 17:04:17 -0700 Subject: [PATCH 01/10] Code changes --- .../lib/smithy-client/plugins/retry_errors.rb | 70 ++++++------------- .../lib/smithy-client/retry/adaptive.rb | 42 +++++------ .../retry/exponential_backoff.rb | 36 ++++++---- .../lib/smithy-client/retry/quota.rb | 8 +-- .../lib/smithy-client/retry/standard.rb | 32 ++++----- .../lib/smithy-client/retry/token.rb | 10 +++ .../spec/smithy-client/retry/quota_spec.rb | 6 +- 7 files changed, 100 insertions(+), 104 deletions(-) diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index 720d85137..ef308b8af 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -6,25 +6,21 @@ module Plugins # @api private class RetryErrors < Plugin option( - :retry_strategy, + :retry_mode, default: 'standard', doc_default: "'standard'", doc_type: 'String, Class', docstring: <<~DOCS) - The retry strategy to use when retrying errors. This can be one of the following: + The retry strategy to use when retrying errors. This must be one of the following: * `standard` - A standardized retry strategy used by the AWS SDKs. This includes support for retry quotas, which limit the number of unsuccessful retries a client can make. * `adaptive` - An experimental retry strategy that includes all the functionality of the `standard` strategy along with automatic client side throttling. This is a provisional strategy that may change behavior in the future. - * Any instance of a class that implements the following methods: - - `acquire_initial_retry_token(token_scope)` - - `refresh_retry_token(retry_token, error_info)` - - `record_success(retry_token)` DOCS option( - :retry_max_attempts, + :max_attempts, default: 3, doc_type: Integer, docstring: <<~DOCS) @@ -32,40 +28,6 @@ class RetryErrors < Plugin the initial attempt. Used in the `standard` and `adaptive` retry strategies. DOCS - option( - :retry_max_delay, - default: 20, - docstring: <<~DOCS) - The maximum delay, in seconds, between retry attempts. This option is ignored - if a custom `retry_backoff` is provided. Used in the `standard` and `adaptive` - retry strategies. - DOCS - - option( - :retry_base_delay, - default: 2, - docstring: <<~DOCS) - The base delay, in seconds, used to calculate the exponential backoff for - retry attempts. This option is ignored if a custom `retry_backoff` is provided. - Used in the `standard` and `adaptive` retry strategies. - DOCS - - option( - :retry_backoff, - doc_default: 'Smithy::Client::Retry::ExponentialBackoff.new', - rbs_type: 'Smithy::Client::Retry::ExponentialBackoff', - doc_type: '#call(attempts)', - docstring: <<~DOCS) do |config| - A callable object that calculates a backoff delay for a retry attempt. The callable - should accept a single argument, `attempts`, that represents the number of attempts - that have been made. Used in the `standard` and `adaptive` retry strategies. - DOCS - Retry::ExponentialBackoff.new( - retry_base_delay: config.retry_base_delay, - retry_max_delay: config.retry_max_delay - ) - end - option( :adaptive_retry_wait_to_fill, default: true, @@ -76,23 +38,24 @@ class RetryErrors < Plugin not retry instead of sleeping. DOCS + # @api private undocumented + option(:retry_strategy) + def after_initialize(client) config = client.config config.retry_strategy = - case config.retry_strategy + case config.retry_mode when 'standard' Retry::Standard.new( - max_attempts: config.retry_max_attempts, - backoff: config.retry_backoff + max_attempts: config.max_attempts ) when 'adaptive' Retry::Adaptive.new( - max_attempts: config.retry_max_attempts, - backoff: config.retry_backoff, + max_attempts: config.max_attempts, wait_to_fill: config.adaptive_retry_wait_to_fill ) else - config.retry_strategy + raise ArgumentError, 'Must provide either standard` or `adaptive` for retry_mode' end end @@ -112,15 +75,23 @@ def handle(context, retry_strategy, token) return response unless retryable?(context.http_request) error_info = Http::ErrorInspector.new(error, context.http_response) + retry_strategy.request_bookkeeping(error_info) token = retry_strategy.refresh_retry_token(token, error_info) + # nil token means neither delay nor retry & return response right away. return response unless token Kernel.sleep(token.retry_delay) + + return response if token.no_retry_reason == :quota_exhausted else retry_strategy.record_success(token) return response end + retry_request(context, response, retry_strategy, token) + end + + def retry_request(context, response, retry_strategy, token) reset_request(context) reset_response(context, response) context.retries += 1 @@ -141,6 +112,11 @@ def reset_response(context, response) response.error = nil end + # TODO: Revisit after trait is finalized. + def long_polling_operation?(context) + context.operation.traits.key?('smithy.api#longPoll') + end + def track_feature(retry_strategy, &block) case retry_strategy when Retry::Standard then Features.track('RETRY_MODE_STANDARD', &block) diff --git a/gems/smithy-client/lib/smithy-client/retry/adaptive.rb b/gems/smithy-client/lib/smithy-client/retry/adaptive.rb index 98ecfad3f..dab1cb123 100644 --- a/gems/smithy-client/lib/smithy-client/retry/adaptive.rb +++ b/gems/smithy-client/lib/smithy-client/retry/adaptive.rb @@ -15,15 +15,11 @@ class Adaptive # not retry instead of sleeping. def initialize(options = {}) super() - @backoff = options[:backoff] || ExponentialBackoff.new( - base_delay: options[:base_delay], - max_delay: options[:max_delay] - ) + @backoff = options[:backoff] || ExponentialBackoff.new @max_attempts = options[:max_attempts] || 3 - @wait_to_fill = options[:wait_to_fill] || true + @wait_to_fill = options.fetch(:wait_to_fill, true) @client_rate_limiter = ClientRateLimiter.new @quota = Quota.new - @capacity_amount = 0 end # @return [#call] @@ -35,6 +31,12 @@ def initialize(options = {}) # @return [Boolean] attr_reader :wait_to_fill + def request_bookkeeping(error_info) + @client_rate_limiter.update_sending_rate( + error_info.error_type == 'Throttling' + ) + end + def acquire_initial_retry_token(_token_scope = nil) @client_rate_limiter.token_bucket_acquire(1, wait_to_fill: @wait_to_fill) Token.new @@ -43,33 +45,31 @@ def acquire_initial_retry_token(_token_scope = nil) def refresh_retry_token(retry_token, error_info) return unless error_info.retryable? - @client_rate_limiter.update_sending_rate( - error_info.error_type == 'Throttling' - ) return if retry_token.retry_count >= @max_attempts - 1 - @capacity_amount = @quota.checkout_capacity(error_info) - return unless @capacity_amount.positive? + @client_rate_limiter.token_bucket_acquire(1, wait_to_fill: @wait_to_fill) + + capacity_amount = @quota.checkout_capacity(error_info) + delay = @backoff.call(retry_token.retry_count, error_info) + retry_token.capacity_amount = capacity_amount + + if capacity_amount.zero? + retry_token.retry_delay = delay + retry_token.no_retry_reason = :quota_exhausted + return retry_token + end - delay = compute_delay(error_info, retry_token.retry_count) retry_token.retry_count += 1 retry_token.retry_delay = delay + retry_token.no_retry_reason = nil retry_token end def record_success(retry_token) @client_rate_limiter.update_sending_rate(false) - @quota.release(@capacity_amount) + @quota.release(retry_token.capacity_amount) retry_token end - - private - - def compute_delay(error_info, retry_count) - return @backoff.call(retry_count) unless error_info.hints[:retry_after] - - [error_info.hints[:retry_after], @backoff.max_delay].min - end end end end diff --git a/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb b/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb index 76624b3c5..9eb93df3b 100644 --- a/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb +++ b/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb @@ -5,23 +5,33 @@ module Client module Retry # Default exponential backoff retry strategy for retrying requests. class ExponentialBackoff - def initialize(options = {}) - @base_delay = options[:base_delay] || 2 - @max_delay = options[:max_delay] || 20 - end - - # @return [Numeric] - attr_reader :base_delay - - # @return [Numeric] - attr_reader :max_delay + MAX_BACKOFF = 20 + EXPONENTIAL_BASE = 2 # Calculates a delay based on exponential backoff strategy. Uses full jitter approach. # @param [Integer] attempts + # @param [Smithy::Client::Http::ErrorInspector] error_info # @return [Numeric] delay in seconds - def call(attempts) - delay = (@base_delay**attempts) - [delay, @max_delay].min * Kernel.rand + def call(attempts, error_info) + # From SEP: t_i = b * min(x * r^i, MAX_BACKOFF) + calculated_delay = backoff_scalar_x(error_info) * (EXPONENTIAL_BASE**attempts) + t_i = Kernel.rand * [calculated_delay, MAX_BACKOFF].min + apply_retry_after(t_i, error_info) + end + + private + + def apply_retry_after(t_i, error_info) + retry_after = error_info.hints[:retry_after] + return t_i unless retry_after + + # Clamp retry delay to t_i < delay < t_i + 5 per SEP. + delay = [t_i, retry_after].max + [delay, t_i + 5].min + end + + def backoff_scalar_x(error_info) + error_info.error_type == 'Throttling' ? 1 : 0.05 end end end diff --git a/gems/smithy-client/lib/smithy-client/retry/quota.rb b/gems/smithy-client/lib/smithy-client/retry/quota.rb index 9466a4200..d24c46120 100644 --- a/gems/smithy-client/lib/smithy-client/retry/quota.rb +++ b/gems/smithy-client/lib/smithy-client/retry/quota.rb @@ -7,9 +7,9 @@ module Retry # Used in 'standard' and 'adaptive' retry modes. class Quota INITIAL_RETRY_TOKENS = 500 - RETRY_COST = 5 + RETRY_COST = 14 NO_RETRY_INCREMENT = 1 - TIMEOUT_RETRY_COST = 10 + THROTTLING_RETRY_COST = 5 def initialize @mutex = Mutex.new @@ -23,8 +23,8 @@ def initialize def checkout_capacity(error_info) @mutex.synchronize do capacity_amount = - if error_info.error_type == 'Transient' - TIMEOUT_RETRY_COST + if error_info.error_type == 'Throttling' + THROTTLING_RETRY_COST else RETRY_COST end diff --git a/gems/smithy-client/lib/smithy-client/retry/standard.rb b/gems/smithy-client/lib/smithy-client/retry/standard.rb index a3bc5b113..60f77c7d9 100644 --- a/gems/smithy-client/lib/smithy-client/retry/standard.rb +++ b/gems/smithy-client/lib/smithy-client/retry/standard.rb @@ -11,13 +11,9 @@ class Standard # will be made for a single request, including the initial attempt. def initialize(options = {}) super() - @backoff = options[:backoff] || ExponentialBackoff.new( - base_delay: options[:base_delay], - max_delay: options[:max_delay] - ) + @backoff = options[:backoff] || ExponentialBackoff.new @max_attempts = options[:max_attempts] || 3 @quota = Quota.new - @capacity_amount = 0 end # @return [#call] @@ -26,6 +22,9 @@ def initialize(options = {}) # @return [Integer] attr_reader :max_attempts + # Noop in standard mode. Overridden in adaptive mode. + def request_bookkeeping(_error_info); end + def acquire_initial_retry_token(_token_scope = nil) Token.new end @@ -35,27 +34,26 @@ def refresh_retry_token(retry_token, error_info) return if retry_token.retry_count >= @max_attempts - 1 - @capacity_amount = @quota.checkout_capacity(error_info) - return unless @capacity_amount.positive? + capacity_amount = @quota.checkout_capacity(error_info) + delay = @backoff.call(retry_token.retry_count, error_info) + retry_token.capacity_amount = capacity_amount + + if capacity_amount.zero? + retry_token.retry_delay = delay + retry_token.no_retry_reason = :quota_exhausted + return retry_token + end - delay = compute_delay(error_info, retry_token.retry_count) retry_token.retry_count += 1 retry_token.retry_delay = delay + retry_token.no_retry_reason = nil retry_token end def record_success(retry_token) - @quota.release(@capacity_amount) + @quota.release(retry_token.capacity_amount) retry_token end - - private - - def compute_delay(error_info, retry_count) - return @backoff.call(retry_count) unless error_info.hints[:retry_after] - - [error_info.hints[:retry_after], @backoff.max_delay].min - end end end end diff --git a/gems/smithy-client/lib/smithy-client/retry/token.rb b/gems/smithy-client/lib/smithy-client/retry/token.rb index 93eb38469..93380b0c7 100644 --- a/gems/smithy-client/lib/smithy-client/retry/token.rb +++ b/gems/smithy-client/lib/smithy-client/retry/token.rb @@ -8,6 +8,8 @@ class Token def initialize @retry_count = 0 @retry_delay = 0 + @capacity_amount = nil + @no_retry_reason = nil end # The number of times the operation has been retried. @@ -17,6 +19,14 @@ def initialize # The delay before the next retry. # @return [Numeric] attr_accessor :retry_delay + + # The quota capacity token has taken. + # @return [Integer] + attr_accessor :capacity_amount + + # The reason for no-retry. + # @return [Symbol, nil] :quota_exhausted, or nil + attr_accessor :no_retry_reason end end end diff --git a/gems/smithy-client/spec/smithy-client/retry/quota_spec.rb b/gems/smithy-client/spec/smithy-client/retry/quota_spec.rb index 09aaadee8..08724ae19 100644 --- a/gems/smithy-client/spec/smithy-client/retry/quota_spec.rb +++ b/gems/smithy-client/spec/smithy-client/retry/quota_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative '../../spec_helper' + module Smithy module Client module Retry @@ -18,11 +20,11 @@ module Retry end it 'checks out the timeout cost when the error is a networking error' do - error = double('ErrorInspector', error_type: 'Transient') + error = double('ErrorInspector', error_type: 'Throttling') checked_out_capacity = subject.checkout_capacity(error) expect(checked_out_capacity) - .to eq(Retry::Quota::TIMEOUT_RETRY_COST) + .to eq(Retry::Quota::THROTTLING_RETRY_COST) end it 'returns 0 when there is insufficient capacity' do From 4e7f834109ce0cdc01abdc3d2fe21479b9096eb2 Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Mon, 8 Jun 2026 09:24:40 -0700 Subject: [PATCH 02/10] Update retry tests. --- .../plugins/retry_errors_spec.rb | 182 ++++++++++-------- 1 file changed, 107 insertions(+), 75 deletions(-) diff --git a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb index b61cdb6eb..a1f5b0714 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb @@ -15,61 +15,47 @@ module Plugins expect(client.config).to respond_to(:retry_strategy) end - it 'adds a :retry_max_attempts option to config' do - expect(client.config).to respond_to(:retry_max_attempts) + it 'adds a :retry_mode option to config' do + expect(client.config).to respond_to(:retry_mode) end - it 'adds a :retry_backoff option to config' do - expect(client.config).to respond_to(:retry_backoff) + it 'adds a :max_attempts option to config' do + expect(client.config).to respond_to(:max_attempts) end it 'adds an :adaptive_retry_wait_to_fill option to config' do expect(client.config).to respond_to(:adaptive_retry_wait_to_fill) end - it 'creates a Standard retry strategy from a string' do - client = client_class.new(retry_strategy: 'standard') + it 'creates a Standard retry strategy from retry_mode' do + client = client_class.new(retry_mode: 'standard', stub_responses: true) expect(client.config.retry_strategy).to be_a(Retry::Standard) end - it 'creates an Adaptive retry strategy from a string' do - client = client_class.new(retry_strategy: 'adaptive') + it 'creates an Adaptive retry strategy from retry_mode' do + client = client_class.new(retry_mode: 'adaptive', stub_responses: true) expect(client.config.retry_strategy).to be_a(Retry::Adaptive) end - it 'uses a custom retry strategy' do - retry_strategy = double('CustomRetryStrategy') - client = client_class.new(retry_strategy: retry_strategy) - expect(client.config.retry_strategy).to be(retry_strategy) - end - - it 'passes flat options to the standard retry strategy class' do + it 'passes max_attempts to the standard retry strategy' do client = client_class.new( - retry_strategy: 'standard', - retry_max_attempts: 5, - retry_backoff: 2 + retry_mode: 'standard', + max_attempts: 5, + stub_responses: true ) - expect(client.config.retry_max_attempts).to eq(5) - expect(client.config.retry_backoff).to eq(2) - retry_strategy = client.config.retry_strategy - expect(retry_strategy.max_attempts).to eq(5) - expect(retry_strategy.backoff).to eq(2) + expect(client.config.max_attempts).to eq(5) + expect(client.config.retry_strategy.max_attempts).to eq(5) end - it 'passes flat options to the adaptive retry strategy class' do + it 'passes options to the adaptive retry strategy' do client = client_class.new( - retry_strategy: 'adaptive', - retry_max_attempts: 5, - retry_backoff: 2, - adaptive_retry_wait_to_fill: 5 + retry_mode: 'adaptive', + max_attempts: 5, + adaptive_retry_wait_to_fill: false, + stub_responses: true ) - expect(client.config.retry_max_attempts).to eq(5) - expect(client.config.retry_backoff).to eq(2) - expect(client.config.adaptive_retry_wait_to_fill).to eq(5) - retry_strategy = client.config.retry_strategy - expect(retry_strategy.max_attempts).to eq(5) - expect(retry_strategy.backoff).to eq(2) - expect(retry_strategy.wait_to_fill).to eq(5) + expect(client.config.max_attempts).to eq(5) + expect(client.config.retry_strategy.max_attempts).to eq(5) end it 'adds the handler by default' do @@ -109,15 +95,15 @@ module Plugins test_case_def = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 495, retries: 1, delay: 1 } + expect: { available_capacity: 486, retries: 1, delay: 0.05 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 490, retries: 2, delay: 2 } + expect: { available_capacity: 472, retries: 2, delay: 0.1 } }, { response: { status_code: 200, error: nil }, - expect: { available_capacity: 495, retries: 2 } + expect: { available_capacity: 486, retries: 2 } } # success ] @@ -128,15 +114,15 @@ module Plugins test_case_def = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 495, retries: 1, delay: 1 } + expect: { available_capacity: 486, retries: 1, delay: 0.05 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 490, retries: 2, delay: 2 } + expect: { available_capacity: 472, retries: 2, delay: 0.1 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 490, retries: 2 } + expect: { available_capacity: 472, retries: 2 } } # failure ] @@ -144,12 +130,12 @@ module Plugins end it 'fails due to retry quota reached after a single retry' do - quota.instance_variable_set(:@available_capacity, 5) + quota.instance_variable_set(:@available_capacity, 14) test_case_def = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 0, retries: 1, delay: 1 } + expect: { available_capacity: 0, retries: 1, delay: 0.05 } }, { response: { status_code: 500, error: service_error }, @@ -179,23 +165,23 @@ module Plugins test_case_def = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 495, retries: 1, delay: 1 } + expect: { available_capacity: 486, retries: 1, delay: 0.05 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 490, retries: 2, delay: 2 } + expect: { available_capacity: 472, retries: 2, delay: 0.1 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 485, retries: 3, delay: 4 } + expect: { available_capacity: 458, retries: 3, delay: 0.2 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 480, retries: 4, delay: 8 } + expect: { available_capacity: 444, retries: 4, delay: 0.4 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 480, retries: 4 } + expect: { available_capacity: 444, retries: 4 } } ] @@ -203,45 +189,62 @@ module Plugins end it 'does not exceed the max backoff time' do - config.retry_strategy = Retry::Standard.new(max_delay: 3) retry_strategy.instance_variable_set(:@max_attempts, 5) test_case_def = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 495, retries: 1, delay: 1 } + expect: { available_capacity: 486, retries: 1, delay: 0.05 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 490, retries: 2, delay: 2 } + expect: { available_capacity: 472, retries: 2, delay: 0.1 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 485, retries: 3, delay: 3 } + expect: { available_capacity: 458, retries: 3, delay: 0.2 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 480, retries: 4, delay: 3 } + expect: { available_capacity: 444, retries: 4, delay: 0.4 } }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 480, retries: 4 } + expect: { available_capacity: 444, retries: 4 } } ] + # MAX_BACKOFF is 20s; with base 0.05 and max_attempts 5, + # max delay is 0.05*2^3=0.4 which is well under 20s. + # Verify the cap works by checking no delay exceeds MAX_BACKOFF. handle_with_retry(test_case_def) end - it 'clamps retry_after hint to max_delay' do - config.retry_strategy = Retry::Standard.new(max_delay: 3) + it 'clamps retry_after hint' do allow(Kernel).to receive(:rand).and_return(1) response.context.http_response.headers['retry-after'] = '9999' test_case_def = [ { + # retry_after=9999s, t_i=0.05, clamped to t_i+5=5.05 response: { status_code: 503, error: service_error }, - expect: { available_capacity: 495, retries: 1, delay: 3 } + expect: { available_capacity: 486, retries: 1, delay: 5.05 } + }, + { + response: { status_code: 200, error: nil }, + expect: { available_capacity: 500, retries: 1 } + } + ] + + handle_with_retry(test_case_def) + end + + it 'throttling error costs 5 tokens and uses 1s base backoff' do + test_case_def = [ + { + response: { status_code: 429, error: service_error }, + expect: { available_capacity: 495, retries: 1, delay: 1.0 } }, { response: { status_code: 200, error: nil }, @@ -253,21 +256,17 @@ module Plugins end it 'fails due to retry quota bucket exhaustion' do - config.retry_max_attempts = 5 - quota.instance_variable_set(:@available_capacity, 10) + config.max_attempts = 5 + quota.instance_variable_set(:@available_capacity, 20) test_case_def = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 5, retries: 1, delay: 1 } + expect: { available_capacity: 6, retries: 1, delay: 0.05 } }, { response: { status_code: 502, error: service_error }, - expect: { available_capacity: 0, retries: 2, delay: 2 } - }, - { - response: { status_code: 503, error: service_error }, - expect: { available_capacity: 0, retries: 2 } + expect: { available_capacity: 6, retries: 1 } } ] @@ -275,21 +274,21 @@ module Plugins end it 'recovers after successful responses' do - config.retry_max_attempts = 5 - quota.instance_variable_set(:@available_capacity, 15) + config.max_attempts = 5 + quota.instance_variable_set(:@available_capacity, 30) test_case_def = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 10, retries: 1, delay: 1 } + expect: { available_capacity: 16, retries: 1, delay: 0.05 } }, { response: { status_code: 502, error: service_error }, - expect: { available_capacity: 5, retries: 2, delay: 2 } + expect: { available_capacity: 2, retries: 2, delay: 0.1 } }, { response: { status_code: 200, error: nil }, - expect: { available_capacity: 10, retries: 2 } + expect: { available_capacity: 16, retries: 2 } } ] handle_with_retry(test_case_def) @@ -297,16 +296,48 @@ module Plugins test_case_post_success = [ { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 5, retries: 1, delay: 1 } + expect: { available_capacity: 2, retries: 1, delay: 0.05 } }, { response: { status_code: 200, error: nil }, - expect: { available_capacity: 10, retries: 1 } + expect: { available_capacity: 16, retries: 1 } } ] reset_request handle_with_retry(test_case_post_success) end + + it 'shares retry quota across requests' do + test_case_def = [ + { + response: { status_code: 500, error: service_error }, + expect: { available_capacity: 486, retries: 1, delay: 0.05 } + }, + { + response: { status_code: 500, error: service_error }, + expect: { available_capacity: 472, retries: 2, delay: 0.1 } + }, + { + response: { status_code: 200, error: nil }, + expect: { available_capacity: 486, retries: 2 } + } + ] + handle_with_retry(test_case_def) + + # Second request shares the same quota (486 remaining) + test_case_def2 = [ + { + response: { status_code: 500, error: service_error }, + expect: { available_capacity: 472, retries: 1, delay: 0.05 } + }, + { + response: { status_code: 200, error: nil }, + expect: { available_capacity: 486, retries: 1 } + } + ] + reset_request + handle_with_retry(test_case_def2) + end end context 'adaptive mode' do @@ -318,16 +349,17 @@ module Plugins client_rate_limiter.instance_variable_set(:@last_max_rate, 10) end - it 'clamps retry_after hint to max_delay' do - config.retry_strategy = Retry::Adaptive.new(max_delay: 3) + it 'clamps retry_after hint' do + config.retry_strategy = Retry::Adaptive.new allow(Kernel).to receive(:rand).and_return(1) response.context.http_response.headers['retry-after'] = '9999' test_case_def = [ { + # retry_after=9999s, t_i=0.05, clamped to t_i+5=5.05 response: { status_code: 503, error: service_error }, - expect: { retries: 1, delay: 3 } + expect: { retries: 1, delay: 5.05 } }, { response: { status_code: 200, error: nil }, From 171f88cee9bca85b448a97e574e141fb0bb3792c Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Mon, 8 Jun 2026 10:07:34 -0700 Subject: [PATCH 03/10] Comment & rubocop fixes. --- .../lib/smithy-client/plugins/retry_errors.rb | 48 +++++++++++-------- .../lib/smithy-client/retry/standard.rb | 2 +- .../plugins/retry_errors_spec.rb | 3 +- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index ef308b8af..710575bb9 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -11,12 +11,15 @@ class RetryErrors < Plugin doc_default: "'standard'", doc_type: 'String, Class', docstring: <<~DOCS) - The retry strategy to use when retrying errors. This must be one of the following: - * `standard` - A standardized retry strategy used by the AWS SDKs. This includes support - for retry quotas, which limit the number of unsuccessful retries a client can make. - * `adaptive` - An experimental retry strategy that includes all the functionality of the - `standard` strategy along with automatic client side throttling. This is a provisional - strategy that may change behavior in the future. + Specifies which retry algorithm to use. Values are: + + * `standard` - A standardized set of retry rules across the AWS SDKs. + This includes support for retry quotas, which limit the number of + unsuccessful retries a client can make. This is the default + value if no retry mode is provided. + + * `adaptive` - A retry mode that includes all the functionality of + `standard` mode along with automatic client side throttling. DOCS option( @@ -71,26 +74,33 @@ def call(context) def handle(context, retry_strategy, token) response = track_feature(retry_strategy) { @handler.call(context) } - if (error = response.error) - return response unless retryable?(context.http_request) - - error_info = Http::ErrorInspector.new(error, context.http_response) - retry_strategy.request_bookkeeping(error_info) - token = retry_strategy.refresh_retry_token(token, error_info) - # nil token means neither delay nor retry & return response right away. - return response unless token - - Kernel.sleep(token.retry_delay) - - return response if token.no_retry_reason == :quota_exhausted - else + unless response.error retry_strategy.record_success(token) return response end + return response unless retryable?(context.http_request) + + token = handle_error(context, response, retry_strategy, token) + return response unless token retry_request(context, response, retry_strategy, token) end + def handle_error(context, response, retry_strategy, token) + error_info = Http::ErrorInspector.new(response.error, context.http_response) + retry_strategy.request_bookkeeping(error_info) + token = retry_strategy.refresh_retry_token(token, error_info) + return unless token + + if token.no_retry_reason == :quota_exhausted + Kernel.sleep(token.retry_delay) if long_polling_operation?(context) + return + end + + Kernel.sleep(token.retry_delay) + token + end + def retry_request(context, response, retry_strategy, token) reset_request(context) reset_response(context, response) diff --git a/gems/smithy-client/lib/smithy-client/retry/standard.rb b/gems/smithy-client/lib/smithy-client/retry/standard.rb index 60f77c7d9..63d839346 100644 --- a/gems/smithy-client/lib/smithy-client/retry/standard.rb +++ b/gems/smithy-client/lib/smithy-client/retry/standard.rb @@ -22,7 +22,7 @@ def initialize(options = {}) # @return [Integer] attr_reader :max_attempts - # Noop in standard mode. Overridden in adaptive mode. + # Noop in standard mode; only applicable to adaptive mode. def request_bookkeeping(_error_info); end def acquire_initial_retry_token(_token_scope = nil) diff --git a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb index a1f5b0714..0d75ac13b 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb @@ -75,7 +75,8 @@ module Plugins config.build! end - let(:context) { HandlerContext.new(config: config) } + let(:operation) { double('operation', traits: {}) } + let(:context) { HandlerContext.new(config: config, operation: operation) } let(:response) { Response.new(context: context) } let(:service_error) { ServiceError.new(context, Schema::EmptyStructure.new) } From 252d94344ad18130365604d850c57ed7f31b50e3 Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Mon, 8 Jun 2026 10:30:09 -0700 Subject: [PATCH 04/10] Comment changes --- gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index 710575bb9..3f4091140 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -9,7 +9,7 @@ class RetryErrors < Plugin :retry_mode, default: 'standard', doc_default: "'standard'", - doc_type: 'String, Class', + doc_type: String, docstring: <<~DOCS) Specifies which retry algorithm to use. Values are: @@ -28,7 +28,7 @@ class RetryErrors < Plugin doc_type: Integer, docstring: <<~DOCS) The maximum number attempts that will be made for a single request, including - the initial attempt. Used in the `standard` and `adaptive` retry strategies. + the initial attempt. DOCS option( From 26860b33dc9fb330d6a594697a986195d76db702 Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Mon, 8 Jun 2026 11:12:32 -0700 Subject: [PATCH 05/10] Fix test --- .../spec/smithy-client/plugins/retry_errors_spec.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb index 0d75ac13b..602baf5f5 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb @@ -191,6 +191,7 @@ module Plugins it 'does not exceed the max backoff time' do retry_strategy.instance_variable_set(:@max_attempts, 5) + stub_const('Smithy::Client::Retry::ExponentialBackoff::MAX_BACKOFF', 0.2) test_case_def = [ { @@ -207,7 +208,7 @@ module Plugins }, { response: { status_code: 500, error: service_error }, - expect: { available_capacity: 444, retries: 4, delay: 0.4 } + expect: { available_capacity: 444, retries: 4, delay: 0.2 } }, { response: { status_code: 500, error: service_error }, @@ -215,9 +216,6 @@ module Plugins } ] - # MAX_BACKOFF is 20s; with base 0.05 and max_attempts 5, - # max delay is 0.05*2^3=0.4 which is well under 20s. - # Verify the cap works by checking no delay exceeds MAX_BACKOFF. handle_with_retry(test_case_def) end From 8e50860bc9052f6b964ea3aee656ccc88048e48a Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Mon, 8 Jun 2026 13:05:38 -0700 Subject: [PATCH 06/10] Update handler addition logic now that retry_errors is present in upstream. --- .../smithy-client/lib/smithy-client/plugins/retry_errors.rb | 4 +++- .../lib/smithy-client/plugins/stub_responses.rb | 6 ------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index 3f4091140..fdb94a381 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -136,7 +136,9 @@ def track_feature(retry_strategy, &block) end end - handler(Handler, step: :retry) + def add_handlers(handlers, config) + handlers.add(Handler, step: :retry) unless config.stub_responses + end end end end diff --git a/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb b/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb index 092138613..66b201788 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb @@ -28,12 +28,6 @@ def add_handlers(handlers, config) handlers.add(StubHandler, step: :send) end - def after_initialize(client) - return unless client.config.stub_responses - - client.handlers.remove(RetryErrors::Handler) - end - # Returns a registered stubbed response instead of a real response. # @api private class StubHandler < Client::Handler From 26bb0a69702d6797e702e8fc8142fbdec7fd1c3e Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Mon, 8 Jun 2026 13:21:37 -0700 Subject: [PATCH 07/10] Add long-polling tests. --- .../plugins/retry_errors_spec.rb | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb index 602baf5f5..3f22318e9 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb @@ -339,6 +339,81 @@ module Plugins end end + context 'long-polling operations' do + before(:each) do + config.retry_strategy = Retry::Standard.new + allow(Kernel).to receive(:rand).and_return(1) + allow(operation).to receive(:traits) + .and_return({ 'smithy.api#longPoll' => {} }) + end + + it 'backs off after transient error when token bucket empty' do + quota.instance_variable_set(:@available_capacity, 0) + + test_case_def = [ + { + response: { status_code: 500, error: service_error }, + expect: { available_capacity: 0, retries: 0, delay: 0.05 } + } + ] + handle_with_retry(test_case_def) + end + + it 'backs off after throttling error when token bucket empty' do + quota.instance_variable_set(:@available_capacity, 0) + + test_case_def = [ + { + response: { status_code: 429, error: service_error }, + expect: { available_capacity: 0, retries: 0, delay: 1.0 } + } + ] + handle_with_retry(test_case_def) + end + + it 'does not delay when max attempts exceeded' do + test_case_def = [ + { + response: { status_code: 500, error: service_error }, + expect: { retries: 1, delay: 0.05 } + }, + { + response: { status_code: 500, error: service_error }, + expect: { retries: 2, delay: 0.1 } + }, + { + response: { status_code: 500, error: service_error }, + expect: { retries: 2 } + } + ] + handle_with_retry(test_case_def) + end + + it 'does not delay on success' do + test_case_def = [ + { + response: { status_code: 500, error: service_error }, + expect: { retries: 1, delay: 0.05 } + }, + { + response: { status_code: 200, error: nil }, + expect: { retries: 1 } + } + ] + handle_with_retry(test_case_def) + end + + it 'does not delay on non-retryable errors' do + test_case_def = [ + { + response: { status_code: 404, error: service_error }, + expect: { retries: 0 } + } + ] + handle_with_retry(test_case_def) + end + end + context 'adaptive mode' do before(:each) do config.retry_strategy = Retry::Adaptive.new From 6a26c83f939b54eb3381ded164d93886f960cec2 Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Tue, 9 Jun 2026 13:50:10 -0700 Subject: [PATCH 08/10] Fix typos --- gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index fdb94a381..c623aa09a 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -16,7 +16,7 @@ class RetryErrors < Plugin * `standard` - A standardized set of retry rules across the AWS SDKs. This includes support for retry quotas, which limit the number of unsuccessful retries a client can make. This is the default - value if no retry mode is provided. + value if no retry mode is provided. * `adaptive` - A retry mode that includes all the functionality of `standard` mode along with automatic client side throttling. @@ -58,7 +58,7 @@ def after_initialize(client) wait_to_fill: config.adaptive_retry_wait_to_fill ) else - raise ArgumentError, 'Must provide either standard` or `adaptive` for retry_mode' + raise ArgumentError, 'Must provide either `standard` or `adaptive` for retry_mode' end end From 15e634e48ff0136942f9133f3e6ce8627cdc37e2 Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Wed, 10 Jun 2026 15:29:30 -0700 Subject: [PATCH 09/10] Resolve Juli's comments. --- .../lib/smithy-client/plugins/retry_errors.rb | 83 ++++++++++++------- .../smithy-client/plugins/stub_responses.rb | 20 +++++ .../lib/smithy-client/retry/adaptive.rb | 26 +++--- .../retry/exponential_backoff.rb | 3 +- .../lib/smithy-client/retry/quota.rb | 2 +- .../lib/smithy-client/retry/standard.rb | 20 +++-- .../plugins/retry_errors_spec.rb | 8 +- 7 files changed, 106 insertions(+), 56 deletions(-) diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index c623aa09a..0ae29a340 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -7,18 +7,17 @@ module Plugins class RetryErrors < Plugin option( :retry_mode, - default: 'standard', - doc_default: "'standard'", - doc_type: String, + default: :standard, + doc_type: Symbol, docstring: <<~DOCS) Specifies which retry algorithm to use. Values are: - * `standard` - A standardized set of retry rules across the AWS SDKs. + * `:standard` - A standardized set of retry rules across the Smithy-based SDKs. This includes support for retry quotas, which limit the number of unsuccessful retries a client can make. This is the default value if no retry mode is provided. - * `adaptive` - A retry mode that includes all the functionality of + * `:adaptive` - A retry mode that includes all the functionality of `standard` mode along with automatic client side throttling. DOCS @@ -41,25 +40,54 @@ class RetryErrors < Plugin not retry instead of sleeping. DOCS - # @api private undocumented - option(:retry_strategy) + option( + :retry_strategy, + doc_type: 'Object', + docstring: <<~DOCS) + The retry strategy used by the client. If not provided, a default strategy is built + based on `:retry_mode` — either `Standard` or `Adaptive`. + + A custom strategy must respond to: + * `#acquire_initial_retry_token` - returns a token + * `#refresh_retry_token(token, error_info)` - returns a token or nil + * `#record_success(token)` - records a successful request + * `#request_bookkeeping(error_info = nil)` - updates internal state + DOCS + + REQUIRED_STRATEGY_METHODS = %i[ + acquire_initial_retry_token + refresh_retry_token + record_success + request_bookkeeping + ].freeze def after_initialize(client) config = client.config - config.retry_strategy = - case config.retry_mode - when 'standard' - Retry::Standard.new( - max_attempts: config.max_attempts - ) - when 'adaptive' - Retry::Adaptive.new( - max_attempts: config.max_attempts, - wait_to_fill: config.adaptive_retry_wait_to_fill - ) - else - raise ArgumentError, 'Must provide either `standard` or `adaptive` for retry_mode' - end + if config.retry_strategy + validate_strategy(config.retry_strategy) + else + config.retry_strategy = build_strategy(config) + end + end + + private + + def validate_strategy(strategy) + missing = REQUIRED_STRATEGY_METHODS.reject { |m| strategy.respond_to?(m) } + return if missing.empty? + + raise ArgumentError, "Custom retry_strategy must respond to: #{missing.join(', ')}" + end + + def build_strategy(config) + case config.retry_mode + when :standard + Retry::Standard.new(max_attempts: config.max_attempts) + when :adaptive + Retry::Adaptive.new(max_attempts: config.max_attempts, wait_to_fill: config.adaptive_retry_wait_to_fill) + else + raise ArgumentError, 'Must provide either :standard or :adaptive for retry_mode' + end end # @api private @@ -74,21 +102,22 @@ def call(context) def handle(context, retry_strategy, token) response = track_feature(retry_strategy) { @handler.call(context) } + error_info = Http::ErrorInspector.new(response.error, context.http_response) if response.error + retry_strategy.request_bookkeeping(error_info) + unless response.error retry_strategy.record_success(token) return response end return response unless retryable?(context.http_request) - token = handle_error(context, response, retry_strategy, token) + token = handle_error(context, retry_strategy, token, error_info) return response unless token retry_request(context, response, retry_strategy, token) end - def handle_error(context, response, retry_strategy, token) - error_info = Http::ErrorInspector.new(response.error, context.http_response) - retry_strategy.request_bookkeeping(error_info) + def handle_error(context, retry_strategy, token, error_info) token = retry_strategy.refresh_retry_token(token, error_info) return unless token @@ -136,9 +165,7 @@ def track_feature(retry_strategy, &block) end end - def add_handlers(handlers, config) - handlers.add(Handler, step: :retry) unless config.stub_responses - end + handler(Handler, step: :retry) end end end diff --git a/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb b/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb index 66b201788..9bd98b4eb 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/stub_responses.rb @@ -28,6 +28,26 @@ def add_handlers(handlers, config) handlers.add(StubHandler, step: :send) end + def after_initialize(client) + return unless client.config.stub_responses + + remove_common_handlers(client) + remove_context_handlers(client) + end + + private + + def remove_common_handlers(client) + # Handlers removed when stubbing regardless of context. + # Subclasses should not override this method. + end + + def remove_context_handlers(client) + # Context-specific handler removals. Override in subclasses + # to remove domain-specific handlers (e.g., domain-specific retry handler). + client.handlers.remove(RetryErrors::Handler) + end + # Returns a registered stubbed response instead of a real response. # @api private class StubHandler < Client::Handler diff --git a/gems/smithy-client/lib/smithy-client/retry/adaptive.rb b/gems/smithy-client/lib/smithy-client/retry/adaptive.rb index dab1cb123..8f33480a9 100644 --- a/gems/smithy-client/lib/smithy-client/retry/adaptive.rb +++ b/gems/smithy-client/lib/smithy-client/retry/adaptive.rb @@ -5,8 +5,6 @@ module Client module Retry # Adaptive retry strategy for retrying requests. class Adaptive - # @option [#call] :backoff (ExponentialBackoff.new) A callable object that - # calculates a backoff delay for a retry attempt. # @option [Integer] :max_attempts (3) The maximum number of attempts that # will be made for a single request, including the initial attempt. # @option [Boolean] :wait_to_fill When true, the request will sleep until @@ -15,26 +13,23 @@ class Adaptive # not retry instead of sleeping. def initialize(options = {}) super() - @backoff = options[:backoff] || ExponentialBackoff.new @max_attempts = options[:max_attempts] || 3 @wait_to_fill = options.fetch(:wait_to_fill, true) @client_rate_limiter = ClientRateLimiter.new @quota = Quota.new end - # @return [#call] - attr_reader :backoff - # @return [Integer] attr_reader :max_attempts # @return [Boolean] attr_reader :wait_to_fill - def request_bookkeeping(error_info) - @client_rate_limiter.update_sending_rate( - error_info.error_type == 'Throttling' - ) + # Updates internal state based on the response outcome. + # @param [Http::ErrorInspector, nil] error_info The error info, or nil on success. + def request_bookkeeping(error_info = nil) + is_throttle = error_info&.error_type == 'Throttling' + @client_rate_limiter.update_sending_rate(is_throttle) end def acquire_initial_retry_token(_token_scope = nil) @@ -42,7 +37,7 @@ def acquire_initial_retry_token(_token_scope = nil) Token.new end - def refresh_retry_token(retry_token, error_info) + def refresh_retry_token(retry_token, error_info) # rubocop:disable Metrics/AbcSize return unless error_info.retryable? return if retry_token.retry_count >= @max_attempts - 1 @@ -50,7 +45,7 @@ def refresh_retry_token(retry_token, error_info) @client_rate_limiter.token_bucket_acquire(1, wait_to_fill: @wait_to_fill) capacity_amount = @quota.checkout_capacity(error_info) - delay = @backoff.call(retry_token.retry_count, error_info) + delay = backoff.call(retry_token.retry_count, error_info) retry_token.capacity_amount = capacity_amount if capacity_amount.zero? @@ -66,10 +61,15 @@ def refresh_retry_token(retry_token, error_info) end def record_success(retry_token) - @client_rate_limiter.update_sending_rate(false) @quota.release(retry_token.capacity_amount) retry_token end + + private + + def backoff + @backoff ||= ExponentialBackoff.new + end end end end diff --git a/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb b/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb index 9eb93df3b..047b809b2 100644 --- a/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb +++ b/gems/smithy-client/lib/smithy-client/retry/exponential_backoff.rb @@ -3,7 +3,8 @@ module Smithy module Client module Retry - # Default exponential backoff retry strategy for retrying requests. + # @api private + # Default exponential backoff for retrying requests. class ExponentialBackoff MAX_BACKOFF = 20 EXPONENTIAL_BASE = 2 diff --git a/gems/smithy-client/lib/smithy-client/retry/quota.rb b/gems/smithy-client/lib/smithy-client/retry/quota.rb index d24c46120..c5286e7f1 100644 --- a/gems/smithy-client/lib/smithy-client/retry/quota.rb +++ b/gems/smithy-client/lib/smithy-client/retry/quota.rb @@ -4,7 +4,7 @@ module Smithy module Client module Retry # @api private - # Used in 'standard' and 'adaptive' retry modes. + # Used in :standard and :adaptive retry modes. class Quota INITIAL_RETRY_TOKENS = 500 RETRY_COST = 14 diff --git a/gems/smithy-client/lib/smithy-client/retry/standard.rb b/gems/smithy-client/lib/smithy-client/retry/standard.rb index 63d839346..47391cd71 100644 --- a/gems/smithy-client/lib/smithy-client/retry/standard.rb +++ b/gems/smithy-client/lib/smithy-client/retry/standard.rb @@ -5,25 +5,21 @@ module Client module Retry # Standard retry strategy for retrying requests. class Standard - # @option [#call] :backoff (ExponentialBackoff.new) A callable object that - # calculates a backoff delay for a retry attempt. # @option [Integer] :max_attempts (3) The maximum number of attempts that # will be made for a single request, including the initial attempt. def initialize(options = {}) super() - @backoff = options[:backoff] || ExponentialBackoff.new @max_attempts = options[:max_attempts] || 3 @quota = Quota.new end - # @return [#call] - attr_reader :backoff - # @return [Integer] attr_reader :max_attempts - # Noop in standard mode; only applicable to adaptive mode. - def request_bookkeeping(_error_info); end + # Updates internal state based on the response outcome. + # @param [Http::ErrorInspector, nil] error_info The error info, or nil on success. + # No-op for Standard retry strategy. + def request_bookkeeping(error_info = nil); end def acquire_initial_retry_token(_token_scope = nil) Token.new @@ -35,7 +31,7 @@ def refresh_retry_token(retry_token, error_info) return if retry_token.retry_count >= @max_attempts - 1 capacity_amount = @quota.checkout_capacity(error_info) - delay = @backoff.call(retry_token.retry_count, error_info) + delay = backoff.call(retry_token.retry_count, error_info) retry_token.capacity_amount = capacity_amount if capacity_amount.zero? @@ -54,6 +50,12 @@ def record_success(retry_token) @quota.release(retry_token.capacity_amount) retry_token end + + private + + def backoff + @backoff ||= ExponentialBackoff.new + end end end end diff --git a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb index 3f22318e9..a7c7a7261 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb @@ -28,18 +28,18 @@ module Plugins end it 'creates a Standard retry strategy from retry_mode' do - client = client_class.new(retry_mode: 'standard', stub_responses: true) + client = client_class.new(retry_mode: :standard, stub_responses: true) expect(client.config.retry_strategy).to be_a(Retry::Standard) end it 'creates an Adaptive retry strategy from retry_mode' do - client = client_class.new(retry_mode: 'adaptive', stub_responses: true) + client = client_class.new(retry_mode: :adaptive, stub_responses: true) expect(client.config.retry_strategy).to be_a(Retry::Adaptive) end it 'passes max_attempts to the standard retry strategy' do client = client_class.new( - retry_mode: 'standard', + retry_mode: :standard, max_attempts: 5, stub_responses: true ) @@ -49,7 +49,7 @@ module Plugins it 'passes options to the adaptive retry strategy' do client = client_class.new( - retry_mode: 'adaptive', + retry_mode: :adaptive, max_attempts: 5, adaptive_retry_wait_to_fill: false, stub_responses: true From a78969ae5d99465f6c14b735d6fb9ce106066134 Mon Sep 17 00:00:00 2001 From: Sichan Yoo Date: Fri, 12 Jun 2026 14:59:04 -0700 Subject: [PATCH 10/10] Revert retry_mode config option back tos tring. --- .../lib/smithy-client/plugins/retry_errors.rb | 14 +++++++------- .../smithy-client/plugins/retry_errors_spec.rb | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb index 0ae29a340..01730a060 100644 --- a/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb +++ b/gems/smithy-client/lib/smithy-client/plugins/retry_errors.rb @@ -7,17 +7,17 @@ module Plugins class RetryErrors < Plugin option( :retry_mode, - default: :standard, - doc_type: Symbol, + default: 'standard', + doc_type: String, docstring: <<~DOCS) Specifies which retry algorithm to use. Values are: - * `:standard` - A standardized set of retry rules across the Smithy-based SDKs. + * `standard` - A standardized set of retry rules across the Smithy-based SDKs. This includes support for retry quotas, which limit the number of unsuccessful retries a client can make. This is the default value if no retry mode is provided. - * `:adaptive` - A retry mode that includes all the functionality of + * `adaptive` - A retry mode that includes all the functionality of `standard` mode along with automatic client side throttling. DOCS @@ -81,12 +81,12 @@ def validate_strategy(strategy) def build_strategy(config) case config.retry_mode - when :standard + when 'standard' Retry::Standard.new(max_attempts: config.max_attempts) - when :adaptive + when 'adaptive' Retry::Adaptive.new(max_attempts: config.max_attempts, wait_to_fill: config.adaptive_retry_wait_to_fill) else - raise ArgumentError, 'Must provide either :standard or :adaptive for retry_mode' + raise ArgumentError, "Must provide either 'standard' or 'adaptive' for retry_mode" end end diff --git a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb index a7c7a7261..3f22318e9 100644 --- a/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb +++ b/gems/smithy-client/spec/smithy-client/plugins/retry_errors_spec.rb @@ -28,18 +28,18 @@ module Plugins end it 'creates a Standard retry strategy from retry_mode' do - client = client_class.new(retry_mode: :standard, stub_responses: true) + client = client_class.new(retry_mode: 'standard', stub_responses: true) expect(client.config.retry_strategy).to be_a(Retry::Standard) end it 'creates an Adaptive retry strategy from retry_mode' do - client = client_class.new(retry_mode: :adaptive, stub_responses: true) + client = client_class.new(retry_mode: 'adaptive', stub_responses: true) expect(client.config.retry_strategy).to be_a(Retry::Adaptive) end it 'passes max_attempts to the standard retry strategy' do client = client_class.new( - retry_mode: :standard, + retry_mode: 'standard', max_attempts: 5, stub_responses: true ) @@ -49,7 +49,7 @@ module Plugins it 'passes options to the adaptive retry strategy' do client = client_class.new( - retry_mode: :adaptive, + retry_mode: 'adaptive', max_attempts: 5, adaptive_retry_wait_to_fill: false, stub_responses: true