Add information about all rate limits in TooManyRequests error#17
Add information about all rate limits in TooManyRequests error#17sferik merged 1 commit intosferik:mainfrom
Conversation
lib/x/errors/too_many_requests.rb
Outdated
| def overall_limits | ||
| { | ||
| "rate-limit" => { | ||
| "limit" => response["x-rate-limit-limit"].to_i, | ||
| "remaining" => response["x-rate-limit-remaining"].to_i, | ||
| "reset_at" => Time.at(response["x-rate-limit-reset"].to_i) | ||
| } | ||
| }.tap do |limits| | ||
| if response["x-app-limit-24hour-limit"] | ||
| limits["app-limit-24hour"] = { | ||
| "limit" => response["x-app-limit-24hour-limit"].to_i, | ||
| "remaining" => response["x-app-limit-24hour-remaining"].to_i, | ||
| "reset_at" => Time.at(response["x-app-limit-24hour-reset"].to_i) | ||
| } | ||
| end | ||
|
|
||
| if response["x-user-limit-24hour-limit"] | ||
| limits["user-limit-24hour"] = { | ||
| "limit" => response["x-user-limit-24hour-limit"].to_i, | ||
| "remaining" => response["x-user-limit-24hour-remaining"].to_i, | ||
| "reset_at" => Time.at(response["x-user-limit-24hour-reset"].to_i) | ||
| } | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
I don't really like what this method looks like. Le me know if you have a better idea.
There was a problem hiding this comment.
I refactored to find limits by using the regex /x-(.*-limit.*)-limit/
lib/x/errors/too_many_requests.rb
Outdated
| [(next_available_at - Time.now).ceil, 0].max | ||
| end | ||
|
|
||
| alias_method :retry_after, :next_available_in |
There was a problem hiding this comment.
Every request before retry_after are supposed to fail.
lib/x/errors/too_many_requests.rb
Outdated
| end | ||
|
|
||
| def next_available_at | ||
| overall_limits.values.select { |v| v["remaining"] == 0 }.map { |v| v["reset_at"] }.max |
There was a problem hiding this comment.
This takes the max time of reached limits.
|
Hi @GCorbel, Thank you for submitting this pull request! I think it makes sense to expose all of the API’s rate limit header values as methods on As for your specific implementation, it appears that several linter checks are failing. Some of these issues can be corrected automatically with the following command: That’s a good place to start, but still leaves 10 issues that need to be corrected manually. The two big ones are related to the length and complexity of the The remaining linter complaints are about the tests, but those should be simplified when Mutation tests are also failing, but don’t worry about fixing those until after you make the other changes to the code/tests. Thanks again for your patch! |
|
Hello, I will fix Rubocop issue. I suppose this have to be added in the "contributing" section in the readme. |
test/x/too_many_requests_test.rb
Outdated
|
|
||
| response["x-user-limit-24hour-limit"] = "300" | ||
| response["x-user-limit-24hour-remaining"] = "299" | ||
| response["x-user-limit-24hour-reset"] = (Time.now + 300).to_i.to_s |
There was a problem hiding this comment.
I tried to refactor tests with the following code. This way, there is no need to define Net::HTTPTooManyRequests in specs and all limits are visible in every test.
diff --git a/test/x/too_many_requests_test.rb b/test/x/too_many_requests_test.rb
index c7406ff..b967d86 100644
--- a/test/x/too_many_requests_test.rb
+++ b/test/x/too_many_requests_test.rb
@@ -4,80 +4,81 @@ module X
class TooManyRequestsTest < Minitest::Test
cover TooManyRequests
+ def add_limit(name, limits)
+ @response["x-#{name}-limit"] = limits[:limit].to_s
+ @response["x-#{name}-remaining"] = limits[:remaining].to_s
+ @response["x-#{name}-reset"] = limits[:reset_at].to_i.to_s
+ end
+
def setup
Time.stub :now, Time.utc(1983, 11, 24) do
- response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
- response["x-rate-limit-limit"] = "100"
- response["x-rate-limit-remaining"] = "0"
- response["x-rate-limit-reset"] = (Time.now + 60).to_i.to_s
-
- response["x-app-limit-24hour-limit"] = "200"
- response["x-app-limit-24hour-remaining"] = "199"
- response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s
-
- response["x-user-limit-24hour-limit"] = "300"
- response["x-user-limit-24hour-remaining"] = "299"
- response["x-user-limit-24hour-reset"] = (Time.now + 300).to_i.to_s
-
- @exception = TooManyRequests.new(response: response)
+ @response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
+ @exception = TooManyRequests.new(response: @response)
end
end
def test_initialize_with_empty_response
- response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
- exception = TooManyRequests.new(response: response)
-
- assert_equal 0, exception.limit
- assert_equal 0, exception.remaining
- assert_equal Time.at(0).utc, exception.reset_at
- assert_equal 0, exception.reset_in
+ assert_equal 0, @exception.limit
+ assert_equal 0, @exception.remaining
+ assert_equal Time.at(0).utc, @exception.reset_at
+ assert_equal 0, @exception.reset_in
assert_equal "Too Many Requests", @exception.message
end
def test_limit
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+
assert_equal 100, @exception.limit
end
def test_limit_with_header
- response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
- response["x-rate-limit-limit"] = "100"
- exception = TooManyRequests.new(response: response)
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
- assert_equal 100, exception.limit
+ assert_equal 100, @exception.limit
end
def test_remaining
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+
assert_equal 0, @exception.remaining
end
def test_remaining_with_header
- response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
- response["x-rate-limit-remaining"] = "5"
- exception = TooManyRequests.new(response: response)
+ add_limit("rate-limit", limit: 100, remaining: 5, reset_at: Time.now + 60)
- assert_equal 5, exception.remaining
+ assert_equal 5, @exception.remaining
end
def test_reset_at
Time.stub :now, Time.utc(1983, 11, 24) do
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+
assert_equal Time.now + 60, @exception.reset_at
end
end
def test_reset_in
Time.stub :now, Time.utc(1983, 11, 24) do
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+
assert_equal 60, @exception.reset_in
end
end
def test_retry_after
Time.stub :now, Time.utc(1983, 11, 24) do
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+
assert_equal 60, @exception.retry_after
end
end
def test_all_limits
Time.stub :now, Time.utc(1983, 11, 24) do
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+ add_limit("app-limit-24hour", limit: 200, remaining: 199, reset_at: Time.now + 200)
+ add_limit("user-limit-24hour", limit: 300, remaining: 299, reset_at: Time.now + 300)
+
excepted = {
"rate-limit" => {
"limit" => 100,
@@ -102,7 +103,9 @@ module X
def test_next_available_at
Time.stub :now, Time.utc(1983, 11, 24) do
- @exception.response["x-app-limit-24hour-remaining"] = "0"
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+ add_limit("app-limit-24hour", limit: 200, remaining: 0, reset_at: Time.now + 200)
+ add_limit("user-limit-24hour", limit: 300, remaining: 299, reset_at: Time.now + 300)
assert_equal Time.at(Time.now.to_i + 200), @exception.next_available_at
end
@@ -110,26 +113,24 @@ module X
def test_next_available_in
Time.stub :now, Time.utc(1983, 11, 24) do
- @exception.response["x-app-limit-24hour-remaining"] = "0"
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 60)
+ add_limit("app-limit-24hour", limit: 200, remaining: 0, reset_at: Time.now + 200)
+ add_limit("user-limit-24hour", limit: 300, remaining: 299, reset_at: Time.now + 300)
assert_equal 200, @exception.next_available_in
end
end
def test_reset_in_minimum_value
- response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
- response["x-rate-limit-reset"] = (Time.now - 60).to_i.to_s
- exception = TooManyRequests.new(response: response)
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now - 60)
- assert_equal 0, exception.reset_in
+ assert_equal 0, @exception.reset_in
end
def test_reset_in_ceil
- response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
- response["x-rate-limit-reset"] = (Time.now + 61).to_i.to_s
- exception = TooManyRequests.new(response: response)
+ add_limit("rate-limit", limit: 100, remaining: 0, reset_at: Time.now + 61)
- assert_equal 61, exception.reset_in
+ assert_equal 61, @exception.reset_in
end
end
end
I'm not sure if it's really an improvement because it's a little bit verbose. Let me know what you think.
There was a problem hiding this comment.
I’m not a fan of the add_limit helper method. It doesn’t seem like it simplifies very much, it just adds a layer of indirection.
There was a problem hiding this comment.
I agree. I first did :
def setup
Time.stub :now, Time.utc(1983, 11, 24) do
response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
response["x-rate-limit-limit"] = "100"
response["x-rate-limit-remaining"] = "0"
response["x-rate-limit-reset"] = (Time.now + 60).to_i.to_s
response["x-app-limit-24hour-limit"] = "200"
response["x-app-limit-24hour-remaining"] = "199"
response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s
response["x-user-limit-24hour-limit"] = "300"
response["x-user-limit-24hour-remaining"] = "299"
response["x-user-limit-24hour-reset"] = (Time.now + 300).to_i.to_s
@exception = TooManyRequests.new(response: response)
end
end
But Rubocop said that the method is too long.
There was a problem hiding this comment.
I would refactor it into smaller methods. Something like this:
def setup
Time.stub :now, Time.utc(1983, 11, 24) do
response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
setup_rate_limit!(response)
setup_app_limit!(response)
setup_user_limit!(response)
@exception = TooManyRequests.new(response: response)
end
end
def setup_rate_limit!(response)
response["x-rate-limit-limit"] = "100"
response["x-rate-limit-remaining"] = "0"
response["x-rate-limit-reset"] = (Time.now + 60).to_i.to_s
end
def setup_app_limit!(response)
response["x-app-limit-24hour-limit"] = "200"
response["x-app-limit-24hour-remaining"] = "199"
response["x-app-limit-24hour-reset"] = (Time.now + 200).to_i.to_s
end
def setup_user_limit!(response)
response["x-user-limit-24hour-limit"] = "300"
response["x-user-limit-24hour-remaining"] = "299"
response["x-user-limit-24hour-reset"] = (Time.now + 300).to_i.to_s
endThere was a problem hiding this comment.
I see there is some test for the class TooManyRequests in test/x/error_text.rb and in this file. Some looks like duplicates. Do I miss something ?
Otherwise, I spotted some potential things to change. I prefer to note them so they may be changed in another MR and you are aware of this.
Those tests look very similar to me :
def test_limit
assert_equal 100, @exception.limit
end
def test_limit_with_header
response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
response["x-rate-limit-limit"] = "100"
exception = TooManyRequests.new(response: response)
assert_equal 100, exception.limit
end
2 tests are needed ?
Here, there are assertions on exception and @exception :
def test_initialize_with_empty_response
response = Net::HTTPTooManyRequests.new("1.1", 429, "Too Many Requests")
exception = TooManyRequests.new(response: response)
assert_equal 0, exception.limit
assert_equal 0, exception.remaining
assert_equal Time.at(0).utc, exception.reset_at
assert_equal 0, exception.reset_in
assert_equal "Too Many Requests", @exception.message
end
Having to repeat Time.stub :now, Time.utc(1983, 11, 24) is annoying. I see no way to do it simply and globally without adding a gem.
There was a problem hiding this comment.
2 tests are needed ?
I agree these tests look redundant and can be removed/combined, as long as mutation coverage doesn’t drop below 100%.
There was a problem hiding this comment.
Having to repeat
Time.stub :now, Time.utc(1983, 11, 24)is annoying. I see no way to do it simply and globally without adding a gem.
I previously used the timecop gem for this but removed it in this refactoring of the errors, since I didn’t find the added dependency to be worth the cost vs. using Time.stub. If you would like to propose using another gem for this, feel free to do so, but please put it in a separate pull request, so that decision can be evaluated independently of your other proposed changes.
test/x/too_many_requests_test.rb
Outdated
| def test_all_limits | ||
| Time.stub :now, Time.utc(1983, 11, 24) do | ||
| excepted = { | ||
| "rate-limit" => {"limit" => 100, "remaining" => 0, "reset_at" => Time.at(Time.now.to_i + 60)}, | ||
| "app-limit-24hour" => {"limit" => 200, "remaining" => 199, "reset_at" => Time.at(Time.now.to_i + 200)}, | ||
| "user-limit-24hour" => {"limit" => 300, "remaining" => 299, "reset_at" => Time.at(Time.now.to_i + 300)} | ||
| } | ||
|
|
||
| assert_equal excepted, @exception.all_limits | ||
| end | ||
| end |
There was a problem hiding this comment.
I first wrote :
excepted = {
"rate-limit" => {
"limit" => 100,
"remaining" => 0,
"reset_at" => Time.at(Time.now.to_i + 60)
},
"app-limit-24hour" => {
"limit" => 200,
"remaining" => 199,
"reset_at" => Time.at(Time.now.to_i + 200)
},
"user-limit-24hour" => {
"limit" => 300,
"remaining" => 299,
"reset_at" => Time.at(Time.now.to_i + 300)
}
}
But rubocop complained about the method length. To me, it's a bit harder to read in one line. Let me know what you think.
There was a problem hiding this comment.
To me, this is an indication that the all_limits method is too complex. I would probably factor out a X::RateLimit object that has limit, remaining, reset_at, and reset_in instance methods. Then the X::TooManyRequests error class could define an all_limits instance method (which I would rename to limits) that returns an array of up to three of these objects, depending on which headers are present in the response. Then the limit, remaining, reset_at, and reset_in instance methods on X::TooManyRequests could use this array to return the right value.
There was a problem hiding this comment.
Here is a rough version of what I had in mind. Note: I haven’t actually run or tested this code, so there might be bugs!
module X
class RateLimit
attr_reader :response, :type
TYPES = ["rate-limit", "app-limit-24hour", "user-limit-24hour"]
def initialize(response, type)
@response = response
@type = type
end
def limit
response["x-#{type}-limit"].to_i
end
def remaining
response["x-#{type}-remaining"].to_i
end
def reset_at
Time.at(response["x-#{type}-reset"].to_i)
end
def reset_in
[(reset_at - Time.now).ceil, 0].max
end
alias_method :retry_after, :reset_in
end
endmodule X
class TooManyRequests < ClientError
def limits
RateLimit::TYPES.filter_map do |type|
RateLimit.new(response, type) if response["x-#{type}-limit"]
end
end
def reset_at
limits.select { |limit| limit.remaining.zero? }.map(&:reset_at).max
end
def reset_in
[(reset_at - Time.now).ceil, 0].max
end
alias_method :retry_after, :reset_in
end
endThere was a problem hiding this comment.
["rate-limit", "app-limit-24hour", "user-limit-24hour"] are the limit I saw, but I wouldn't be surprised if there are some others. This is why I like response.to_hash.keys.filter_map { |k| k.match(/x-(.*-limit.*)-(\w+)/)&.captures&.uniq&.first }.
I will propose some changes.
There was a problem hiding this comment.
Hmmm, my inclination is to be more explicit. I wouldn’t want this error-handling code to break if the API starts returning another header that accidentally matches the regular expression. If we discover more rate limit types, we can always add them. That seems a bit safer.
There was a problem hiding this comment.
I applied comments. That's much better.
I still have rbs file to change.Is there a way to generate it ?
There was a problem hiding this comment.
Yes, you can use typeprof to generate RBS, but the definitions are not always as precise as they should be.
There was a problem hiding this comment.
I fixed the RBS file.
Running I didn't really try to fix the error. EDIT I understand mutant is a commercial software and I have no license. |
Yes, please feel free to update the Contributing section in the |
lib/x/errors/too_many_requests.rb
Outdated
| private | ||
|
|
||
| def limits | ||
| @limits ||= response.to_hash.keys.filter_map { |k, _v| k.match(/x-(.*-limit.*)-limit/)&.captures&.first } |
There was a problem hiding this comment.
I believe _v should removed, since you call keys before filter_map, so there are only keys (no values) mapped from the hash.
There was a problem hiding this comment.
Right, I changed that.
It is commercial software but it’s free for open source projects, like this one. @mbj How do other open source projects allow contributors to run mutant on their forks? |
|
FTR: When the 24 hours limit is reached, the 15 minutes may have remaining calls. This is correctly handled my what I did. With the code I gave in #17 (comment), I had this result : |
lib/x/errors/too_many_requests.rb
Outdated
| @rate_limits ||= limit_names.to_h do |limit| | ||
| [limit, RateLimit.new(limit, response)] | ||
| end |
There was a problem hiding this comment.
I kept a hash instead of an Array as you suggested. This allows finding a specific limit.
There was a problem hiding this comment.
Thinking some more about this, I’m tempted to only return rate limits with zero remaining requests here. Including others may cause confusion about the cause of the error. In that case, it would be unusual for there to be more than one element returned.
This class should also define a singular rate_limit instance method, which returns the rate limit with the farthest reset time.
Making these changes would dramatically simplify the reset_at definition.
Ask the contributor to add the official remote to the git remotes. If any upstream matches the authorized one the license check passes. |
|
@mbj Thank you for explaining! @GCorbel This patch is coming along quite nicely. I just left another comment but I think it’s almost done. After addressing that comment, the final step is to get mutation coverage back up to 100%, which you should be able to do now. Thanks so much for all your work on this patch! |
lib/x/errors/too_many_requests.rb
Outdated
| @rate_limits ||= limit_types.to_h { |type| [type, RateLimit.new(type: type, response: response)] } | ||
| .select { |_, limit| limit.remaining.zero? } |
There was a problem hiding this comment.
Rubocop and ruby standard aren't agreed here.
Rubocop gives :
lib/x/errors/too_many_requests.rb:12:9: C: [Correctable] Layout/MultilineMethodCallIndentation: Align .select with .to_h on line 11.
.select { |_, limit| limit.remaining.zero? }
^^^^^^^
If I correct, standard gives :
standard: Use Ruby Standard Style (https://github.com/standardrb/standard)
lib/x/errors/too_many_requests.rb:12:35: Layout/MultilineMethodCallIndentation: Use 2 (not 28) spaces for indenting an expression in an
assignment spanning multiple lines.
I prefer the "standard" version
There was a problem hiding this comment.
When Standard and RuboCop defaults conflict, I always go with Standard Ruby. Feel free to edit .rubocop.yml to make it consistent with Standard Ruby, so the linter check passes.
|
I succeeded to run mutant and fixed all issues (it was tough). I'm satisfied of the result. Linter fail because there is a conflict between standard and rubocop. |
@GCorbel Curious as mutants docs suck, did it make sense to you? And now my explanation (I read the mutant commit with a positive smile): Mutant tries to "reduce what the code does semantically" and make the tests proof: hey I actually need that extra bit. And often times this also involves not calling The key to pass mutant is NOT to write more tests, but actually make your code ONLY do what the tests ask for not more. Mutation testing is much more an automated code review technique than a metric. And each of the semantic reductions mutant does get reported as a diff that can be read like:
|
|
@GCorbel This is looking very good! Before I merge, I was wondering if you wanted to do any of the following:
I still think we should be more explicit about specifying rate limit types vs. inferring them using a regular expression. I will probably make that change before releasing this code, but I can do that after merging. I’m also tempted to make the |
|
@GCorbel Here’s a patch you can apply to get the linter to pass: diff --git a/.rubocop.yml b/.rubocop.yml
index 28bcde4..800fd5d 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -31,6 +31,11 @@ Layout/HashAlignment:
EnforcedColonStyle: key
EnforcedLastArgumentHashStyle: always_inspect
+Layout/MultilineMethodCallIndentation:
+ Enabled: true
+ EnforcedStyle: indented
+ IndentationWidth: ~
+
Layout/ParameterAlignment:
Enabled: true
EnforcedStyle: with_fixed_indentation |
It was not obvious at all to understand what mutant does.
I had to change It's a bit the same for : Here, I guess |
I did. I think you can squash commits when you merge the PR with GitHub.
I changed |
sferik
left a comment
There was a problem hiding this comment.
Looks good! Thanks again for this contribution to the library!
|
Thanks. I enjoyed to work with you, on this PR. |
|
Thanks again for your contribution! I just released version 0.13.0, which includes this change, and posted about it. |
Some endpoints have different kind of limits as DMs and Likes.
I found no official list of headers, but I saw x-user-limit-24hour-limit and x-app-limit-24hour-limit.
For example, when the 15 minutes and 24 hours limits of requests allowed "POST likes" are reached,
x-rate-limit-resetgives the next reset for the 15 minutes limit. The reset time for the 24 hours limit is displayed inx-user-limit-24hour-reset. Every request performed before the time inx-user-limit-24hour-resetwill fail.I added the code to have the information about all limits and changed
retry_afterto return the time that we can send requests again.This script :
Return this value :