Skip to content

Commit

Permalink
Handling multiple rate-limits
Browse files Browse the repository at this point in the history
  • Loading branch information
subnetmarco committed Jul 10, 2015
1 parent a01fbfa commit 55352bc
Show file tree
Hide file tree
Showing 11 changed files with 263 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ language: erlang

env:
global:
- CASSANDRA_VERSION=2.1.7
- CASSANDRA_VERSION=2.1.8
matrix:
- LUA=lua5.1

Expand Down
4 changes: 2 additions & 2 deletions .travis/setup_cassandra.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
CASSANDRA_BASE=apache-cassandra-$CASSANDRA_VERSION

sudo rm -rf /var/lib/cassandra/*
curl http://apache.spinellicreations.com/cassandra/$CASSANDRA_VERSION/$CASSANDRA_BASE-bin.tar.gz | tar xz
sudo sh $CASSANDRA_BASE/bin/cassandra
curl http://apache.mirrors.ionfish.org/cassandra/$CASSANDRA_VERSION/$CASSANDRA_BASE-bin.tar.gz | tar xz
sudo sh $CASSANDRA_BASE/bin/cassandra
1 change: 1 addition & 0 deletions database/migrations/cassandra/2015-06-09-170921_0.4.0.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ local Migration = {

up = function(options)
return [[
CREATE TABLE IF NOT EXISTS oauth2_credentials(
id uuid,
name text,
Expand Down
7 changes: 7 additions & 0 deletions kong/dao/schemas/plugins_configurations.lua
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ return {
return false, DaoError("No consumer can be configured for that plugin", constants.DATABASE_ERROR_TYPES.SCHEMA)
end

if value_schema.self_check and type(value_schema.self_check) == "function" then
local ok, err = value_schema.self_check(value_schema, plugin_t.value and plugin_t.value or {}, dao, is_update)
if not ok then
return false, err
end
end

if not is_update then
local res, err = dao.plugins_configurations:find_by_keys({
name = plugin_t.name,
Expand Down
50 changes: 41 additions & 9 deletions kong/plugins/ratelimiting/access.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,50 @@ function _M.execute(conf)
identifier = ngx.var.remote_addr
end

local usage = {}
local stop

-- Handle previous version of the rate-limiting plugin
local old_format = false
if conf.period and conf.limit then
old_format = true
conf[conf.period] = conf.limit -- Adapt to new format

-- Delete old properties
conf.period = nil
conf.limit = nil
end

-- Load current metric for configured period
local current_metric, err = dao.ratelimiting_metrics:find_one(ngx.ctx.api.id, identifier, current_timestamp, conf.period)
if err then
return responses.send_HTTP_INTERNAL_SERVER_ERROR(err)
for period, limit in pairs(conf) do
local current_metric, err = dao.ratelimiting_metrics:find_one(ngx.ctx.api.id, identifier, current_timestamp, period)
if err then
return responses.send_HTTP_INTERNAL_SERVER_ERROR(err)
end

-- What is the current usage for the configured period?
local current_usage = current_metric and current_metric.value or 0
local remaining = limit - current_usage

-- Recording usage
usage[period] = {
limit = limit,
remaining = remaining
}

if remaining <= 0 then
stop = period
end
end

-- What is the current usage for the configured period?
local current_usage = current_metric and current_metric.value or 0
local remaining = conf.limit - current_usage
ngx.header[constants.HEADERS.RATELIMIT_LIMIT] = conf.limit
ngx.header[constants.HEADERS.RATELIMIT_REMAINING] = math.max(0, remaining - 1) -- -1 for this current request
-- Adding headers
for k,v in pairs(usage) do
ngx.header[constants.HEADERS.RATELIMIT_LIMIT..(old_format and "" or "-"..k)] = v.limit
ngx.header[constants.HEADERS.RATELIMIT_REMAINING..(old_format and "" or "-"..k)] = math.max(0, (stop == nil or stop == k) and v.remaining - 1 or v.remaining) -- -1 for this current request
end

if remaining <= 0 then
-- If limit is exceeded, terminate the request
if stop then
ngx.ctx.stop_phases = true -- interrupt other phases of this request
return responses.send(429, "API rate limit exceeded")
end
Expand All @@ -37,6 +68,7 @@ function _M.execute(conf)
if stmt_err then
return responses.send_HTTP_INTERNAL_SERVER_ERROR(stmt_err)
end

end

return _M
44 changes: 40 additions & 4 deletions kong/plugins/ratelimiting/schema.lua
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
local DaoError = require "kong.dao.error"
local constants = require "kong.constants"

return {
fields = {
limit = { required = true, type = "number" },
period = { required = true, type = "string", enum = constants.RATELIMIT.PERIODS }
}
}
second = { type = "number" },
minute = { type = "number" },
hour = { type = "number" },
day = { type = "number" },
month = { type = "number" },
year = { type = "number" }
},
self_check = function(schema, plugin_t, dao, is_update)
local ordered_periods = { "second", "minute", "hour", "day", "month", "year"}
local has_value
local invalid_order
local invalid_value

for i, v in ipairs(ordered_periods) do
if plugin_t[v] then
has_value = true
if plugin_t[v] <=0 then
invalid_value = "Value for "..v.." must be greater than zero"
else
for t = i, #ordered_periods do
if plugin_t[ordered_periods[t]] and plugin_t[ordered_periods[t]] < plugin_t[v] then
invalid_order = "The value for "..ordered_periods[t].." cannot be lower than the value for "..v
end
end
end
end
end

if not has_value then
return false, DaoError("You need to set at least one limit: second, minute, hour, day, month, year", constants.DATABASE_ERROR_TYPES.SCHEMA)
elseif invalid_value then
return false, DaoError(invalid_value, constants.DATABASE_ERROR_TYPES.SCHEMA)
elseif invalid_order then
return false, DaoError(invalid_order, constants.DATABASE_ERROR_TYPES.SCHEMA)
end

return true
end
}
97 changes: 86 additions & 11 deletions spec/plugins/ratelimiting/access_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,47 @@ describe("RateLimiting Plugin", function()
spec_helper.insert_fixtures {
api = {
{ name = "tests ratelimiting 1", public_dns = "test3.com", target_url = "http://mockbin.com" },
{ name = "tests ratelimiting 2", public_dns = "test4.com", target_url = "http://mockbin.com" }
{ name = "tests ratelimiting 2", public_dns = "test4.com", target_url = "http://mockbin.com" },
{ name = "tests ratelimiting 3", public_dns = "test5.com", target_url = "http://mockbin.com" },
{ name = "tests ratelimiting 4", public_dns = "test6.com", target_url = "http://mockbin.com" }
},
consumer = {
{ custom_id = "provider_123" },
{ custom_id = "provider_124" }
},
plugin_configuration = {
{ name = "keyauth", value = {key_names = {"apikey"}, hide_credentials = true}, __api = 1 },
{ name = "ratelimiting", value = {period = "minute", limit = 6}, __api = 1 },
{ name = "ratelimiting", value = {period = "minute", limit = 8}, __api = 1, __consumer = 1 },
{ name = "ratelimiting", value = {period = "minute", limit = 6}, __api = 2 },
{ name = "ratelimiting", value = { minute = 6 }, __api = 1 },
{ name = "ratelimiting", value = { minute = 8 }, __api = 1, __consumer = 1 },
{ name = "ratelimiting", value = { minute = 6 }, __api = 2 },
{ name = "ratelimiting", value = { minute = 3, hour = 5 }, __api = 3 },
{ name = "ratelimiting", value = { minute = 33 }, __api = 4 }
},
keyauth_credential = {
{ key = "apikey122", __consumer = 1 },
{ key = "apikey123", __consumer = 2 }
}
}

-- Updating API test6.com with old plugin value, to check retrocompatibility
local dao_factory = spec_helper.get_env().dao_factory
-- Find API
local res, err = dao_factory.apis:find_by_keys({public_dns = 'test6.com'})
if err then error(err) end
-- Find Plugin Configuration
local res, err = dao_factory.plugins_configurations:find_by_keys({api_id = res[1].id})
if err then error(err) end
-- Set old value
local plugin_configuration = res[1]
plugin_configuration.value = {
period = "minute",
limit = 6
}
-- Update plugin configuration
local _, err = dao_factory.plugins_configurations:execute(
"update plugins_configurations SET value = '{\"limit\":6, \"period\":\"minute\"}' WHERE id = "..plugin_configuration.id.." and name = 'ratelimiting'")
if err then error(err) end

spec_helper.start_kong()
end)

Expand All @@ -56,8 +79,8 @@ describe("RateLimiting Plugin", function()
for i = 1, limit do
local _, status, headers = http_client.get(STUB_GET_URL, {}, {host = "test4.com"})
assert.are.equal(200, status)
assert.are.same(tostring(limit), headers["x-ratelimit-limit"])
assert.are.same(tostring(limit - i), headers["x-ratelimit-remaining"])
assert.are.same(tostring(limit), headers["x-ratelimit-limit-minute"])
assert.are.same(tostring(limit - i), headers["x-ratelimit-remaining-minute"])
end

-- Additonal request, while limit is 6/minute
Expand All @@ -67,11 +90,37 @@ describe("RateLimiting Plugin", function()
assert.are.equal("API rate limit exceeded", body.message)
end)

end)
it("should handle multiple limits", function()
local limits = {
minute = 3,
hour = 5
}

wait()

for i = 1, 3 do
local _, status, headers = http_client.get(STUB_GET_URL, {}, {host = "test5.com"})
assert.are.equal(200, status)

assert.are.same(tostring(limits.minute), headers["x-ratelimit-limit-minute"])
assert.are.same(tostring(limits.minute - i), headers["x-ratelimit-remaining-minute"])
assert.are.same(tostring(limits.hour), headers["x-ratelimit-limit-hour"])
assert.are.same(tostring(limits.hour - i), headers["x-ratelimit-remaining-hour"])
end

local response, status, headers = http_client.get(STUB_GET_URL, {}, {host = "test5.com"})
assert.are.equal("2", headers["x-ratelimit-remaining-hour"])
assert.are.equal("0", headers["x-ratelimit-remaining-minute"])
local body = cjson.decode(response)
assert.are.equal(429, status)
assert.are.equal("API rate limit exceeded", body.message)
end)

end)

describe("With authentication", function()

describe("Default plugin", function()
describe("Old plugin format", function()

it("should get blocked if exceeding limit", function()
wait()
Expand All @@ -80,12 +129,36 @@ describe("RateLimiting Plugin", function()
local limit = 6

for i = 1, limit do
local _, status, headers = http_client.get(STUB_GET_URL, {apikey = "apikey123"}, {host = "test3.com"})
local _, status, headers = http_client.get(STUB_GET_URL, {apikey = "apikey123"}, {host = "test6.com"})
assert.are.equal(200, status)
assert.are.same(tostring(limit), headers["x-ratelimit-limit"])
assert.are.same(tostring(limit - i), headers["x-ratelimit-remaining"])
end

-- Third query, while limit is 2/minute
local response, status = http_client.get(STUB_GET_URL, {apikey = "apikey123"}, {host = "test6.com"})
local body = cjson.decode(response)
assert.are.equal(429, status)
assert.are.equal("API rate limit exceeded", body.message)
end)

end)

describe("Default plugin", function()

it("should get blocked if exceeding limit", function()
wait()

-- Default ratelimiting plugin for this API says 6/minute
local limit = 6

for i = 1, limit do
local _, status, headers = http_client.get(STUB_GET_URL, {apikey = "apikey123"}, {host = "test3.com"})
assert.are.equal(200, status)
assert.are.same(tostring(limit), headers["x-ratelimit-limit-minute"])
assert.are.same(tostring(limit - i), headers["x-ratelimit-remaining-minute"])
end

-- Third query, while limit is 2/minute
local response, status = http_client.get(STUB_GET_URL, {apikey = "apikey123"}, {host = "test3.com"})
local body = cjson.decode(response)
Expand All @@ -106,8 +179,8 @@ describe("RateLimiting Plugin", function()
for i = 1, limit do
local _, status, headers = http_client.get(STUB_GET_URL, {apikey = "apikey122"}, {host = "test3.com"})
assert.are.equal(200, status)
assert.are.same(tostring(limit), headers["x-ratelimit-limit"])
assert.are.same(tostring(limit - i), headers["x-ratelimit-remaining"])
assert.are.same(tostring(limit), headers["x-ratelimit-limit-minute"])
assert.are.same(tostring(limit - i), headers["x-ratelimit-remaining-minute"])
end

local response, status = http_client.get(STUB_GET_URL, {apikey = "apikey122"}, {host = "test3.com"})
Expand All @@ -117,5 +190,7 @@ describe("RateLimiting Plugin", function()
end)

end)

end)

end)
43 changes: 43 additions & 0 deletions spec/plugins/ratelimiting/api_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
local json = require "cjson"
local http_client = require "kong.tools.http_client"
local spec_helper = require "spec.spec_helpers"

local BASE_URL = spec_helper.API_URL.."/apis/%s/plugins/"

describe("Rate Limiting API", function()
setup(function()
spec_helper.prepare_db()
spec_helper.insert_fixtures {
api = {
{ name = "tests ratelimiting 1", public_dns = "test1.com", target_url = "http://mockbin.com" }
}
}
spec_helper.start_kong()

local response = http_client.get(spec_helper.API_URL.."/apis/")
BASE_URL = string.format(BASE_URL, json.decode(response).data[1].id)
end)

teardown(function()
spec_helper.stop_kong()
end)

describe("POST", function()

it("should not save with empty value", function()
local response, status = http_client.post(BASE_URL, { name = "ratelimiting" })
local body = json.decode(response)
assert.are.equal(400, status)
assert.are.equal("You need to set at least one limit: second, minute, hour, day, month, year", body.message)
end)

it("should save with proper value", function()
local response, status = http_client.post(BASE_URL, { name = "ratelimiting", ["value.second"] = 10 })
local body = json.decode(response)
assert.are.equal(201, status)
assert.are.equal(10, body.value.second)
end)

end)

end)
36 changes: 36 additions & 0 deletions spec/plugins/ratelimiting/schema_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
local schemas = require "kong.dao.schemas_validation"
local validate_entity = schemas.validate_entity

local plugin_schema = require "kong.plugins.ratelimiting.schema"

describe("Rate Limiting schema", function()

it("should be invalid when no value is being set", function()
local values = {}
local valid, _, err = validate_entity(values, plugin_schema)
assert.falsy(valid)
assert.are.equal("You need to set at least one limit: second, minute, hour, day, month, year", err.message)
end)

it("should work when the proper value is being set", function()
local values = { second = 10 }
local valid, _, err = validate_entity(values, plugin_schema)
assert.truthy(valid)
assert.falsy(err)
end)

it("should work when the proper value are being set", function()
local values = { second = 10, hour = 20 }
local valid, _, err = validate_entity(values, plugin_schema)
assert.truthy(valid)
assert.falsy(err)
end)

it("should not work when invalid data is being set", function()
local values = { second = 20, hour = 10 }
local valid, _, err = validate_entity(values, plugin_schema)
assert.falsy(valid)
assert.are.equal("The value for hour cannot be lower than the value for second", err.message)
end)

end)
Loading

0 comments on commit 55352bc

Please sign in to comment.