diff --git a/.generator/src/generator/templates/api_client.j2 b/.generator/src/generator/templates/api_client.j2 index 77dde98a8ced..80e7b5712dcf 100644 --- a/.generator/src/generator/templates/api_client.j2 +++ b/.generator/src/generator/templates/api_client.j2 @@ -44,71 +44,99 @@ module {{ module_name }} # the data deserialized from response body (could be nil), response status code and response headers. def call_api(http_method, path, opts = {}) request = build_request(http_method, path, opts) - if opts[:stream_body] - tempfile = nil - encoding = nil - - response = request.perform do | chunk | - unless tempfile - content_disposition = chunk.http_response.header['Content-Disposition'] - if content_disposition && content_disposition =~ /filename=/i - filename = content_disposition[/filename=['"]?([^'"\s]+)['"]?/, 1] - prefix = sanitize_filename(filename) - else - prefix = 'download-' - end - prefix = prefix + '-' unless prefix.end_with?('-') - unless encoding - encoding = chunk.encoding + attempt = 0 + loop do + if opts[:stream_body] + tempfile = nil + encoding = nil + + response = request.perform do | chunk | + unless tempfile + content_disposition = chunk.http_response.header['Content-Disposition'] + if content_disposition && content_disposition =~ /filename=/i + filename = content_disposition[/filename=['"]?([^'"\s]+)['"]?/, 1] + prefix = sanitize_filename(filename) + else + prefix = 'download-' + end + prefix = prefix + '-' unless prefix.end_with?('-') + unless encoding + encoding = chunk.encoding + end + tempfile = Tempfile.open(prefix, @config.temp_folder_path, encoding: encoding) + @tempfile = tempfile end - tempfile = Tempfile.open(prefix, @config.temp_folder_path, encoding: encoding) - @tempfile = tempfile + chunk.force_encoding(encoding) + tempfile.write(chunk) + end + if tempfile + tempfile.close + @config.logger.info "Temp file written to #{tempfile.path}, please copy the file to a proper folder "\ + "with e.g. `FileUtils.cp(tempfile.path, '/new/file/path')` otherwise the temp file "\ + "will be deleted automatically with GC. It's also recommended to delete the temp file "\ + "explicitly with `tempfile.delete`" end - chunk.force_encoding(encoding) - tempfile.write(chunk) + else + response = request.perform end - if tempfile - tempfile.close - @config.logger.info "Temp file written to #{tempfile.path}, please copy the file to a proper folder "\ - "with e.g. `FileUtils.cp(tempfile.path, '/new/file/path')` otherwise the temp file "\ - "will be deleted automatically with GC. It's also recommended to delete the temp file "\ - "explicitly with `tempfile.delete`" + + if @config.debugging + @config.logger.debug "HTTP response body ~BEGIN~\n#{response.body}\n~END~\n" end - else - response = request.perform - end - if @config.debugging - @config.logger.debug "HTTP response body ~BEGIN~\n#{response.body}\n~END~\n" - end + unless response.success? + if response.request_timeout? + fail APIError.new('Connection timed out') + elsif response.code == 0 + # Errors from libcurl will be made visible here + fail APIError.new(:code => 0, + :message => response.return_message) + else + body = response.body + if response.headers['Content-Encoding'].eql?('gzip') && !(body.nil? || body.empty?) then + gzip = Zlib::Inflate.new(Zlib::MAX_WBITS + 16) + body = gzip.inflate(body) + gzip.close + end + if should_retry(attempt, @config.max_retries, response.code, @config.enable_retry) + sleep calculate_retry_interval(response, @config.backoff_base, @config.backoff_multiplier, attempt, @config.timeout) + attempt = attempt + 1 + next + else + fail APIError.new(:code => response.code, + :response_headers => response.headers, + :response_body => body), + response.message + end + end + end - unless response.success? - if response.request_timeout? - fail APIError.new('Connection timed out') - elsif response.code == 0 - # Errors from libcurl will be made visible here - fail APIError.new(:code => 0, - :message => response.return_message) + if opts[:return_type] + data = deserialize(opts[:api_version], response, opts[:return_type]) else - body = response.body - if response.headers['Content-Encoding'].eql?('gzip') && !(body.nil? || body.empty?) then - gzip = Zlib::Inflate.new(Zlib::MAX_WBITS + 16) - body = gzip.inflate(body) - gzip.close - end - fail APIError.new(:code => response.code, - :response_headers => response.headers, - :response_body => body), - response.message + data = nil end + return data, response.code, response.headers end + end + + # Check if an http request should be retried + def should_retry(attempt, max_retries, http_code, enable_retry) + (http_code == 429 || http_code >= 500) && max_retries > attempt && enable_retry + end - if opts[:return_type] - data = deserialize(opts[:api_version], response, opts[:return_type]) + # Calculate the sleep interval between 2 retry attempts + def calculate_retry_interval(response, backoff_base, backoff_multiplier, attempt, timeout) + reset_header = response.headers['X-Ratelimit-Reset'] + if !reset_header.nil? && !reset_header.empty? + sleep_time = reset_header.to_i else - data = nil + sleep_time = (backoff_multiplier**attempt) * backoff_base + if timeout && timeout > 0 + sleep_time = [timeout, sleep_time].min + end end - return data, response.code, response.headers + sleep_time end # Build the HTTP request diff --git a/.generator/src/generator/templates/configuration.j2 b/.generator/src/generator/templates/configuration.j2 index 8ba6d2ab4607..9fc9363b6ec4 100644 --- a/.generator/src/generator/templates/configuration.j2 +++ b/.generator/src/generator/templates/configuration.j2 @@ -138,6 +138,16 @@ module {{ module_name }} # Password for proxy server authentication attr_accessor :http_proxypass + # Enable retry when rate limited + attr_accessor :enable_retry + + # Retry backoff calculation parameters + attr_accessor :backoff_base + attr_accessor :backoff_multiplier + + # Maximum number of retry attempts allowed + attr_accessor :max_retries + def initialize {%- set default_server = openapi.servers[0]|format_server %} @scheme = '{{ default_server.scheme }}' @@ -149,6 +159,10 @@ module {{ module_name }} @server_operation_variables = {} @api_key = {} @api_key_prefix = {} + @enable_retry = false + @backoff_base = 2 + @backoff_multiplier = 2 + @max_retries = 3 @timeout = nil @client_side_validation = true @verify_ssl = true @@ -188,6 +202,13 @@ module {{ module_name }} @@default ||= Configuration.new end + def backoff_base=(value) + if value < 2 + raise ArgumentError, 'backoff_base cannot be smaller than 2' + end + @backoff_base = value + end + def configure yield(self) if block_given? end diff --git a/README.md b/README.md index 829554c53b50..ffade8e7d3e6 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,29 @@ api_instance.list_incidents_with_pagination() do |incident| end ``` +### Retry + +To enable the client to retry when rate limited (status 429) or status 500 and above: + +```ruby +config = DatadogAPIClient::Configuration.new +config.enable_retry = true +client = DatadogAPIClient::APIClient.new(config) +``` + +The interval between 2 retry attempts will be the value of the `x-ratelimit-reset` response header when available. +If not, it will be : + +```ruby +(config.backoffMultiplier ** current_retry_count) * config.backoffBase +``` + +The maximum number of retry attempts is `3` by default and can be modified with + +```ruby +config.maxRetries +``` + ## Documentation If you are interested in general documentation for all public Datadog API endpoints, checkout the [general documentation site][api docs]. diff --git a/lib/datadog_api_client/api_client.rb b/lib/datadog_api_client/api_client.rb index 44686f9f0b7d..f44f4bbfd585 100644 --- a/lib/datadog_api_client/api_client.rb +++ b/lib/datadog_api_client/api_client.rb @@ -55,71 +55,99 @@ def self.default # the data deserialized from response body (could be nil), response status code and response headers. def call_api(http_method, path, opts = {}) request = build_request(http_method, path, opts) - if opts[:stream_body] - tempfile = nil - encoding = nil - - response = request.perform do | chunk | - unless tempfile - content_disposition = chunk.http_response.header['Content-Disposition'] - if content_disposition && content_disposition =~ /filename=/i - filename = content_disposition[/filename=['"]?([^'"\s]+)['"]?/, 1] - prefix = sanitize_filename(filename) - else - prefix = 'download-' - end - prefix = prefix + '-' unless prefix.end_with?('-') - unless encoding - encoding = chunk.encoding + attempt = 0 + loop do + if opts[:stream_body] + tempfile = nil + encoding = nil + + response = request.perform do | chunk | + unless tempfile + content_disposition = chunk.http_response.header['Content-Disposition'] + if content_disposition && content_disposition =~ /filename=/i + filename = content_disposition[/filename=['"]?([^'"\s]+)['"]?/, 1] + prefix = sanitize_filename(filename) + else + prefix = 'download-' + end + prefix = prefix + '-' unless prefix.end_with?('-') + unless encoding + encoding = chunk.encoding + end + tempfile = Tempfile.open(prefix, @config.temp_folder_path, encoding: encoding) + @tempfile = tempfile end - tempfile = Tempfile.open(prefix, @config.temp_folder_path, encoding: encoding) - @tempfile = tempfile + chunk.force_encoding(encoding) + tempfile.write(chunk) + end + if tempfile + tempfile.close + @config.logger.info "Temp file written to #{tempfile.path}, please copy the file to a proper folder "\ + "with e.g. `FileUtils.cp(tempfile.path, '/new/file/path')` otherwise the temp file "\ + "will be deleted automatically with GC. It's also recommended to delete the temp file "\ + "explicitly with `tempfile.delete`" end - chunk.force_encoding(encoding) - tempfile.write(chunk) + else + response = request.perform end - if tempfile - tempfile.close - @config.logger.info "Temp file written to #{tempfile.path}, please copy the file to a proper folder "\ - "with e.g. `FileUtils.cp(tempfile.path, '/new/file/path')` otherwise the temp file "\ - "will be deleted automatically with GC. It's also recommended to delete the temp file "\ - "explicitly with `tempfile.delete`" + + if @config.debugging + @config.logger.debug "HTTP response body ~BEGIN~\n#{response.body}\n~END~\n" end - else - response = request.perform - end - if @config.debugging - @config.logger.debug "HTTP response body ~BEGIN~\n#{response.body}\n~END~\n" - end + unless response.success? + if response.request_timeout? + fail APIError.new('Connection timed out') + elsif response.code == 0 + # Errors from libcurl will be made visible here + fail APIError.new(:code => 0, + :message => response.return_message) + else + body = response.body + if response.headers['Content-Encoding'].eql?('gzip') && !(body.nil? || body.empty?) then + gzip = Zlib::Inflate.new(Zlib::MAX_WBITS + 16) + body = gzip.inflate(body) + gzip.close + end + if should_retry(attempt, @config.max_retries, response.code, @config.enable_retry) + sleep calculate_retry_interval(response, @config.backoff_base, @config.backoff_multiplier, attempt, @config.timeout) + attempt = attempt + 1 + next + else + fail APIError.new(:code => response.code, + :response_headers => response.headers, + :response_body => body), + response.message + end + end + end - unless response.success? - if response.request_timeout? - fail APIError.new('Connection timed out') - elsif response.code == 0 - # Errors from libcurl will be made visible here - fail APIError.new(:code => 0, - :message => response.return_message) + if opts[:return_type] + data = deserialize(opts[:api_version], response, opts[:return_type]) else - body = response.body - if response.headers['Content-Encoding'].eql?('gzip') && !(body.nil? || body.empty?) then - gzip = Zlib::Inflate.new(Zlib::MAX_WBITS + 16) - body = gzip.inflate(body) - gzip.close - end - fail APIError.new(:code => response.code, - :response_headers => response.headers, - :response_body => body), - response.message + data = nil end + return data, response.code, response.headers end + end + + # Check if an http request should be retried + def should_retry(attempt, max_retries, http_code, enable_retry) + (http_code == 429 || http_code >= 500) && max_retries > attempt && enable_retry + end - if opts[:return_type] - data = deserialize(opts[:api_version], response, opts[:return_type]) + # Calculate the sleep interval between 2 retry attempts + def calculate_retry_interval(response, backoff_base, backoff_multiplier, attempt, timeout) + reset_header = response.headers['X-Ratelimit-Reset'] + if !reset_header.nil? && !reset_header.empty? + sleep_time = reset_header.to_i else - data = nil + sleep_time = (backoff_multiplier**attempt) * backoff_base + if timeout && timeout > 0 + sleep_time = [timeout, sleep_time].min + end end - return data, response.code, response.headers + sleep_time end # Build the HTTP request diff --git a/lib/datadog_api_client/configuration.rb b/lib/datadog_api_client/configuration.rb index 8bce152579a8..b72b25054d62 100644 --- a/lib/datadog_api_client/configuration.rb +++ b/lib/datadog_api_client/configuration.rb @@ -149,6 +149,16 @@ class Configuration # Password for proxy server authentication attr_accessor :http_proxypass + # Enable retry when rate limited + attr_accessor :enable_retry + + # Retry backoff calculation parameters + attr_accessor :backoff_base + attr_accessor :backoff_multiplier + + # Maximum number of retry attempts allowed + attr_accessor :max_retries + def initialize @scheme = 'https' @host = 'api.datadoghq.com' @@ -159,6 +169,10 @@ def initialize @server_operation_variables = {} @api_key = {} @api_key_prefix = {} + @enable_retry = false + @backoff_base = 2 + @backoff_multiplier = 2 + @max_retries = 3 @timeout = nil @client_side_validation = true @verify_ssl = true @@ -230,6 +244,13 @@ def self.default @@default ||= Configuration.new end + def backoff_base=(value) + if value < 2 + raise ArgumentError, 'backoff_base cannot be smaller than 2' + end + @backoff_base = value + end + def configure yield(self) if block_given? end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index d47d11b647eb..60a4d9df7f46 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -42,4 +42,21 @@ end end end + + describe '#backoff_base' do + context 'when setting a valid backoff_base value > 2' do + it 'sets the backoff_base attribute' do + config.backoff_base = 3 + expect(config.backoff_base).to eq(3) + end + end + end + + context 'when setting an invalid backoff_base value < 2' do + it 'raises an ArgumentError' do + expect { config.backoff_base = 1 }.to raise_error(ArgumentError, 'backoff_base cannot be smaller than 2') + end + end + + end diff --git a/spec/retry_spec.rb b/spec/retry_spec.rb new file mode 100644 index 000000000000..2730008c7363 --- /dev/null +++ b/spec/retry_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe 'logs_archive retry test' do + before do + DatadogAPIClient.configure do |c| + c.enable_retry = true + c.backoff_base = 2 + end + @api_instance = DatadogAPIClient::V2::LogsArchivesAPI.new + @base_path = @api_instance.api_client.build_request_url('') + @body = DatadogAPIClient::V2::LogsArchiveCreateRequest.new + allow_any_instance_of(DatadogAPIClient::APIClient).to receive(:sleep) + end + it 'should retry 3 times and sleep for the value of X-Ratelimit-Reset' do + fixture = File.read('spec/fixtures/logs_archive_unknown_nested_oneof.json') + stub_request(:post, "#{@base_path}api/v2/logs/config/archives") + .to_return( + {:body => fixture, :headers => {"Content-Type": "application/json", "X-Ratelimit-Reset" => "1"}, :status => 429}, + {:body => fixture, :headers => {"Content-Type": "application/json", "X-Ratelimit-Reset" => "1"}, :status => 429}, + {:body => fixture, :headers => {"Content-Type": "application/json", "X-Ratelimit-Reset" => "1"}, :status => 429}, + {:body => fixture, :headers => {"Content-Type": "application/json"}, :status => 299} + ) + data = @api_instance.create_logs_archive(@body) + expect(@api_instance::api_client).to have_received(:sleep).exactly(3).times.with(1) + expect(WebMock).to have_requested(:post, "#{@base_path}api/v2/logs/config/archives").times(4) + end + + it 'should retry 3 times and sleep for 2,4,8 seconds' do + fixture = File.read('spec/fixtures/logs_archive_unknown_nested_oneof.json') + stub_request(:post, "#{@base_path}api/v2/logs/config/archives") + .to_return( + {:body => fixture, :headers => {"Content-Type": "application/json"}, :status => 500}, + {:body => fixture, :headers => {"Content-Type": "application/json"}, :status => 500}, + {:body => fixture, :headers => {"Content-Type": "application/json"}, :status => 500}, + {:body => fixture, :headers => {"Content-Type": "application/json"}, :status => 299} + ) + + data = @api_instance.create_logs_archive(@body) + expect(@api_instance::api_client).to have_received(:sleep).exactly(1).times.with(2) + expect(@api_instance::api_client).to have_received(:sleep).exactly(1).times.with(4) + expect(@api_instance::api_client).to have_received(:sleep).exactly(1).times.with(8) + expect(WebMock).to have_requested(:post, "#{@base_path}api/v2/logs/config/archives").times(4) + end +end