Skip to content
New issue

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

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

Already on GitHub? # to your account

retry requests when rate limit reached #315

Merged
merged 5 commits into from
Aug 18, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions droplet_kit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = '>= 2.5.0'

spec.add_dependency 'faraday', '>= 0.15'
spec.add_dependency 'faraday-retry', '~> 2.2.0'
spec.add_dependency 'kartograph', '~> 0.2.8'
spec.add_dependency 'resource_kit', '~> 0.1.5'
spec.add_dependency 'virtus', '>= 1.0.3', '<= 3'
Expand Down
29 changes: 23 additions & 6 deletions lib/droplet_kit/client.rb
Original file line number Diff line number Diff line change
@@ -1,30 +1,47 @@
# frozen_string_literal: true

require 'faraday'
require 'faraday/retry'
require 'droplet_kit/utils'

module DropletKit
class Client
DEFAULT_OPEN_TIMEOUT = 60
DEFAULT_TIMEOUT = 120
DEFAULT_RETRY_MAX = 3
DEFAULT_RETRY_WAIT_MIN = 0

DIGITALOCEAN_API = 'https://api.digitalocean.com'

attr_reader :access_token, :api_url, :open_timeout, :timeout, :user_agent
attr_reader :access_token, :api_url, :open_timeout, :timeout, :user_agent, :retry_max, :retry_wait_min

def initialize(options = {})
options = DropletKit::Utils.transform_keys(options, &:to_sym)
@access_token = options[:access_token]
@api_url = options[:api_url] || DIGITALOCEAN_API
@open_timeout = options[:open_timeout] || DEFAULT_OPEN_TIMEOUT
@timeout = options[:timeout] || DEFAULT_TIMEOUT
@user_agent = options[:user_agent]
@access_token = options[:access_token]
@api_url = options[:api_url] || DIGITALOCEAN_API
@open_timeout = options[:open_timeout] || DEFAULT_OPEN_TIMEOUT
@timeout = options[:timeout] || DEFAULT_TIMEOUT
@user_agent = options[:user_agent]
@retry_max = options[:retry_max] || DEFAULT_RETRY_MAX
@retry_wait_min = options[:retry_wait_min] || DEFAULT_RETRY_WAIT_MIN
end

def connection
@faraday ||= Faraday.new connection_options do |req|
req.adapter :net_http
req.options.open_timeout = open_timeout
req.options.timeout = timeout
req.request :retry, {
max: @retry_max,
interval: @retry_wait_min,
retry_statuses: [429],
# faraday-retry supports both the Retry-After and RateLimit-Reset
# headers, however, it favours the RateLimit-Reset one. To force it
# to use the Retry-After header, we override the header that it
# expects for the RateLimit-Reset header to something that we know
# we don't set.
rate_limit_reset_header: 'undefined'
Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Looks like we're providing a Unix timestamp and it's expecting seconds until?

Copy link
Member Author

Choose a reason for hiding this comment

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

I've since spent some time testing this manually, and while you're right, I think this is still the best approach for the library.

I've restored the previous 429 status code handling if the Retry-After header is present, so that when the burst limit is reached, the client will wait, but if it's not, a rate limit error is raised. For this to work, this line is needed, since the faraday-retry middleware executes before the error handling logic. If it's not present, faraday-retry takes over and tries to wait until the RateLimit-Reset header, which can (in the absolute worst possible case) wait for an hour. The client being unresponsive for an hour makes it feel like the client is broken, so I opted to return an error in that case.

Copy link
Member

Choose a reason for hiding this comment

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

Is there a way to override this altogether? Should this be conditional on retry_max being set to greater than 0?

Copy link
Member Author

Choose a reason for hiding this comment

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

Update to be conditional on retry_max being set greater than 0.

}
end
end

Expand Down
6 changes: 0 additions & 6 deletions lib/droplet_kit/error_handling_resourcable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@ def self.included(base)
case response.status
when 200...299
next
when 429
error = DropletKit::RateLimitReached.new("#{response.status}: #{response.body}")
error.limit = response.headers['RateLimit-Limit']
error.remaining = response.headers['RateLimit-Remaining']
error.reset_at = response.headers['RateLimit-Reset']
raise error
else
raise DropletKit::Error, "#{response.status}: #{response.body}"
end
Expand Down
20 changes: 20 additions & 0 deletions spec/lib/droplet_kit/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@

expect(client.timeout).to eq(timeout)
end

it 'allows retry max to be set' do
retry_max = 10
client = described_class.new(
'access_token' => 'my-token',
'retry_max' => retry_max
)

expect(client.retry_max).to eq(retry_max)
end

it 'allows retry wait min to be set' do
retry_wait_min = 3
client = described_class.new(
'access_token' => 'my-token',
'retry_wait_min' => retry_wait_min
)

expect(client.retry_wait_min).to eq(retry_wait_min)
end
end

describe '#method_missing' do
Expand Down
6 changes: 6 additions & 0 deletions spec/lib/droplet_kit/resources/account_resource_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@
let(:method) { :get }
let(:action) { :info }
end

it_behaves_like 'resource that handles rate limit retries' do
let(:path) { '/v2/account' }
let(:method) { :get }
let(:action) { :info }
end
end
end
7 changes: 7 additions & 0 deletions spec/lib/droplet_kit/resources/droplet_resource_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ def check_droplet(droplet, tags = [], overrides = {})
let(:action) { :find }
let(:arguments) { { id: 123 } }
end

it_behaves_like 'resource that handles rate limit retries' do
let(:path) { '/v2/droplets/123' }
let(:method) { :get }
let(:action) { :find }
let(:arguments) { { id: 123 } }
end
end

describe '#create' do
Expand Down
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
SimpleCov.start

require 'faraday'
require 'faraday/retry'
require 'addressable/uri'
require 'droplet_kit'
require 'webmock/rspec'
Expand Down
16 changes: 0 additions & 16 deletions spec/support/shared_examples/common_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,6 @@
shared_examples_for 'resource that handles common errors' do
let(:arguments) { {} }

it 'handles rate limit' do
response_body = { id: :rate_limit, message: 'Too much!!!' }
stub_do_api(path, method).to_return(body: response_body.to_json, status: 429, headers: {
'RateLimit-Limit' => 1200,
'RateLimit-Remaining' => 1193,
'RateLimit-Reset' => 1_402_425_459
})

expect { resource.send(action, arguments).to_a }.to raise_exception(DropletKit::Error) do |exception|
expect(exception.message).to match(/#{response_body[:message]}/)
expect(exception.limit).to eq 1200
expect(exception.remaining).to eq 1193
expect(exception.reset_at).to eq '1402425459'
end
end

it 'handles unauthorized' do
response_body = { id: :unauthorized, message: 'Nuh uh.' }

Expand Down
34 changes: 34 additions & 0 deletions spec/support/shared_examples/rate_limit_retry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

shared_examples_for 'resource that handles rate limit retries' do
let(:arguments) { {} }

it 'handles rate limit' do
response_body = { id: :rate_limit, message: 'example' }
stub_do_api(path, method).to_return(
[
{
body: nil,
status: 429,
headers: {
'RateLimit-Limit' => 1200,
'RateLimit-Remaining' => 1193,
'RateLimit-Reset' => 1_402_425_459,
'Retry-After' => 0 # Retry immediately in tests.
}
},
{
body: response_body.to_json,
status: 200,
headers: {
'RateLimit-Limit' => 1200,
'RateLimit-Remaining' => 1192,
'RateLimit-Reset' => 1_402_425_459
}
}
]
)

expect { resource.send(action, arguments) }.not_to raise_error
end
end