rate limits: rework rate limit implementation.

Rework the rate limit implementation to make it more flexible:

* Allow setting different rate limits for different actions. Before we
  had a single rate limit for all write actions. Now different
  controller endpoints can have different limits.

* Allow actions to be rate limited by user ID, by IP address, or both.
  Before actions were only limited by user ID, which meant non-logged-in
  actions like creating new accounts or attempting to login couldn't be rate
  limited. Also, because actions were limited by user ID only, you could
  use multiple accounts with the same IP to get around limits.

Other changes:

* Remove the API Limit field from user profile pages.
* Remove the `remaining_api_limit` field from the `/profile.json` endpoint.
* Rename the `X-Api-Limit` header to `X-Rate-Limit` and change it from a
  number to a JSON object containing all the rate limit info
  (including the refill rate, the burst factor, the cost of the call,
  and the current limits).
* Fix a potential race condition where, if you flooded requests fast
  enough, you could exceed the rate limit. This was because we checked
  and updated the rate limit in two separate steps, which was racy;
  simultaneous requests could pass the check before the update happened.
  The new code uses some tricky SQL to check and update multiple limits
  in a single statement.
This commit is contained in:
evazion
2021-03-04 20:07:19 -06:00
parent 52adf87489
commit 4492610dfe
15 changed files with 260 additions and 135 deletions

View File

@@ -0,0 +1,72 @@
require 'test_helper'
class RateLimitTest < ActiveSupport::TestCase
context "RateLimit: " do
context "#limit! method" do
should "create a new rate limit object if none exists, or update it if it already exists" do
assert_difference("RateLimit.count", 1) do
RateLimiter.new("write", ["users/1"]).limited?
end
assert_difference("RateLimit.count", 0) do
RateLimiter.new("write", ["users/1"]).limited?
end
assert_difference("RateLimit.count", 1) do
RateLimiter.new("write", ["users/1", "ip/1.2.3.4"]).limited?
end
assert_difference("RateLimit.count", 0) do
RateLimiter.new("write", ["users/1", "ip/1.2.3.4"]).limited?
end
end
should "include the cost of the first action when initializing the limit" do
limiter = RateLimiter.new("write", ["users/1"], burst: 10, cost: 1)
assert_equal(9, limiter.rate_limits.first.points)
end
should "be limited if the point count is negative" do
freeze_time
create(:rate_limit, action: "write", key: "users/1", points: -1)
limiter = RateLimiter.new("write", ["users/1"], cost: 1)
assert_equal(true, limiter.limited?)
assert_equal(-1, limiter.rate_limits.first.points)
end
should "not be limited if the point count was positive before the action" do
freeze_time
create(:rate_limit, action: "write", key: "users/1", points: 0.01)
limiter = RateLimiter.new("write", ["users/1"], cost: 1)
assert_equal(false, limiter.limited?)
assert_equal(-0.99, limiter.rate_limits.first.points)
end
should "refill the points at the correct rate" do
freeze_time
create(:rate_limit, action: "write", key: "users/1", points: -2)
limiter = RateLimiter.new("write", ["users/1"], cost: 1, rate: 1, burst: 10)
assert_equal(true, limiter.limited?)
assert_equal(-2, limiter.rate_limits.first.points)
travel 1.second
limiter = RateLimiter.new("write", ["users/1"], cost: 1, rate: 1, burst: 10)
assert_equal(true, limiter.limited?)
assert_equal(-1, limiter.rate_limits.first.points)
travel 5.second
limiter = RateLimiter.new("write", ["users/1"], cost: 1, rate: 1, burst: 10)
assert_equal(false, limiter.limited?)
assert_equal(3, limiter.rate_limits.first.points)
travel 60.second
limiter = RateLimiter.new("write", ["users/1"], cost: 1, rate: 1, burst: 10)
assert_equal(false, limiter.limited?)
assert_equal(9, limiter.rate_limits.first.points)
end
end
end
end