Skip to content

Commit bd2226d

Browse files
authored
handle rate limit and implement retry logic (#207)
* Add test * handle rate limit and implement retry logic * Expose response headers into instance variables * Fix tests * Add test for client options * remove unused code * Add test case
1 parent 32c1155 commit bd2226d

14 files changed

Lines changed: 455 additions & 56 deletions

File tree

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ Style/TernaryParentheses:
124124
Style/PercentLiteralDelimiters:
125125
Enabled: false
126126

127+
Style/BracesAroundHashParameters:
128+
Enabled: false
129+
127130
Naming/VariableNumber:
128131
Enabled: false
129132

lib/twitter-ads/client.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def initialize(consumer_key, consumer_secret, access_token, access_token_secret,
3434
@consumer_secret = consumer_secret
3535
@access_token = access_token
3636
@access_token_secret = access_token_secret
37-
@options = opts
37+
@options = opts.fetch(:options, {})
3838
validate
3939
self
4040
end

lib/twitter-ads/cursor.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ def fetch_next
112112
def from_response(response)
113113
@next_cursor = response.body[:next_cursor]
114114
@total_count = response.body[:total_count].to_i if response.body.key?(:total_count)
115+
116+
TwitterAds::Utils.extract_response_headers(response.headers).each { |key, value|
117+
singleton_class.class_eval { attr_accessor key }
118+
instance_variable_set("@#{key}", value)
119+
}
120+
115121
response.body.fetch(:data, []).each do |object|
116122
@collection << if @klass&.method_defined?(:from_response)
117123
@klass.new(

lib/twitter-ads/error.rb

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,7 @@ def from_response(object)
5959

6060
# Server Errors (5XX)
6161
class ServerError < Error; end
62-
63-
class ServiceUnavailable < ServerError
64-
attr_reader :retry_after
65-
66-
def initialize(object)
67-
super object
68-
if object.headers['retry-after']
69-
@retry_after = object.headers['retry-after']
70-
end
71-
self
72-
end
73-
end
62+
class ServiceUnavailable < ServerError; end
7463

7564
# Client Errors (4XX)
7665
class ClientError < Error; end
@@ -80,12 +69,13 @@ class NotFound < ClientError; end
8069
class BadRequest < ClientError; end
8170

8271
class RateLimit < ClientError
83-
attr_reader :reset_at, :retry_after
72+
attr_reader :reset_at
8473

8574
def initialize(object)
8675
super object
87-
@retry_after = object.headers['retry-after']
88-
@reset_at = object.headers['rate_limit_reset']
76+
header = object.headers.fetch('x-account-rate-limit-reset', nil) ||
77+
object.headers.fetch('x-rate-limit-reset', nil)
78+
@reset_at = header.first.to_i
8979
self
9080
end
9181
end

lib/twitter-ads/http/request.rb

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,37 @@ def oauth_request
7171
token = OAuth::AccessToken.new(consumer, @client.access_token, @client.access_token_secret)
7272
request.oauth!(consumer.http, consumer, token)
7373

74+
handle_rate_limit = @client.options.fetch(:handle_rate_limit, false)
75+
retry_max = @client.options.fetch(:retry_max, 0)
76+
retry_delay = @client.options.fetch(:retry_delay, 1500)
77+
retry_on_status = @client.options.fetch(:retry_on_status, [500, 503])
78+
retry_count = 0
79+
retry_after = nil
80+
7481
write_log(request) if @client.options[:trace]
75-
response = consumer.http.request(request)
82+
while retry_count <= retry_max
83+
response = consumer.http.request(request)
84+
status_code = response.code.to_i
85+
break if status_code >= 200 && status_code < 300
86+
87+
if handle_rate_limit && retry_after.nil?
88+
rate_limit_reset = response.fetch('x-account-rate-limit-reset', nil) ||
89+
response.fetch('x-rate-limit-reset', nil)
90+
if status_code == 429
91+
retry_after = rate_limit_reset.to_i - Time.now.to_i
92+
@client.logger.warn('Request reached Rate Limit: resume in %d seconds' % retry_after)
93+
sleep(retry_after + 5)
94+
next
95+
end
96+
end
97+
98+
if retry_max.positive?
99+
break unless retry_on_status.include?(status_code)
100+
sleep(retry_delay / 1000)
101+
end
102+
103+
retry_count += 1
104+
end
76105
write_log(response) if @client.options[:trace]
77106

78107
Response.new(response.code, response.each {}, response.body)

lib/twitter-ads/http/response.rb

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ class Response
99
attr_reader :code,
1010
:headers,
1111
:raw_body,
12-
:body,
13-
:rate_limit_remaining,
14-
:rate_limit_reset
12+
:body
1513

1614
# Creates a new Response object instance.
1715
#
@@ -37,16 +35,6 @@ def initialize(code, headers, body)
3735
@body = raw_body
3836
end
3937

40-
if headers.key?('x-rate-limit-reset')
41-
@rate_limit = headers['x-rate-limit-limit'].first
42-
@rate_limit_remaining = headers['x-rate-limit-remaining'].first
43-
@rate_limit_reset = headers['x-rate-limit-reset'].first.to_i
44-
elsif headers.key?('x-cost-rate-limit-reset')
45-
@rate_limit = headers['x-cost-rate-limit-limit'].first
46-
@rate_limit_remaining = headers['x-cost-rate-limit-remaining'].first
47-
@rate_limit_reset = Time.at(headers['x-cost-rate-limit-reset'].first.to_i)
48-
end
49-
5038
self
5139
end
5240

lib/twitter-ads/resources/dsl.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ module InstanceMethods
2020
# @return [self] A fully hydrated instance of the current class.
2121
#
2222
# @since 0.1.0
23-
def from_response(object)
23+
def from_response(object, headers = nil)
24+
if !headers.nil?
25+
TwitterAds::Utils.extract_response_headers(headers).each { |key, value|
26+
singleton_class.class_eval { attr_accessor key }
27+
instance_variable_set("@#{key}", value)
28+
}
29+
end
30+
2431
self.class.properties.each do |name, type|
2532
value = nil
2633
if type == :time && object[name] && !object[name].empty?

lib/twitter-ads/utils.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ def deprecated(name, opts = {})
7070
warn message
7171
end
7272

73+
def extract_response_headers(headers)
74+
values = {}
75+
# only get "X-${name}" custom response headers
76+
headers.each { |key, value|
77+
if key =~ /^x-/
78+
values[key.gsub(/^x-/, '').tr('-', '_')] = \
79+
value.first =~ /^[0-9]*$/ ? value.first.to_i : value.first
80+
end
81+
}
82+
values
83+
end
84+
7385
end
7486

7587
end

spec/fixtures/tweet_preview.json

Lines changed: 0 additions & 24 deletions
This file was deleted.

spec/fixtures/tweet_previews.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"data_type": "tweet_previews",
3+
"request": {
4+
"params": {
5+
"tweet_ids": [
6+
"1130942781109596160",
7+
"1101254234031370240"
8+
],
9+
"tweet_type": "PUBLISHED",
10+
"account_id": "2iqph"
11+
}
12+
},
13+
"data": [
14+
{
15+
"tweet_id": "1130942781109596160",
16+
"preview": "<iframe class='tweet-preview' src='https://ton.smf1.twitter.com/ads-manager/tweet-preview/index.html?data=c29tZSByYW5kb20gYmFzZTY0IHN0cmluZ3MgaGVyZS4uLg=='>"
17+
},
18+
{
19+
"tweet_id": "1101254234031370240",
20+
"preview": "<iframe class='tweet-preview' src='https://ton.smf1.twitter.com/ads-manager/tweet-preview/index.html?data=c29tZSByYW5kb20gYmFzZTY0IHN0cmluZ3MgaGVyZS4uLg=='>"
21+
}
22+
]
23+
}

0 commit comments

Comments
 (0)