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 7, 2015
1 parent 894878f commit c36ea77
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 32 deletions.
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
8 changes: 8 additions & 0 deletions kong/dao/schemas/plugins_configurations.lua
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ return {
return false, DaoError("No consumer can be configured for that plugin", constants.DATABASE_ERROR_TYPES.SCHEMA)
end

-- Invoke on_insert() on the plugin
if value_schema.on_insert and type(value_schema.on_insert) == "function" then
local valid, err = value_schema.on_insert(plugin_t.value or {}, dao, value_schema)
if not valid or err then
return false, DaoError(err, constants.DATABASE_ERROR_TYPES.SCHEMA)
end
end

local res, err = dao.plugins_configurations:find_by_keys({
name = plugin_t.name,
api_id = plugin_t.api_id,
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
42 changes: 37 additions & 5 deletions kong/plugins/ratelimiting/schema.lua
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
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" }
},
on_insert = function(plugin_t, dao, schema)
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, "You need to set at least one limit: second, minute, hour, day, month, year"
elseif invalid_value then
return false, invalid_value
elseif invalid_order then
return false, invalid_order
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)
8 changes: 4 additions & 4 deletions spec/unit/dao/cassandra/base_dao_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ describe("Cassandra", function()
},
plugin_configuration = {
{name = "keyauth", __api = 1},
{name = "ratelimiting", value = {period = "minute", limit = 6}, __api = 1},
{name = "ratelimiting", value = { minute = 6}, __api = 1},
{name = "filelog", value = {path = "/tmp/spec.log" }, __api = 1},

{name = "keyauth", __api = 2}
Expand Down Expand Up @@ -590,7 +590,7 @@ describe("Cassandra", function()
},
plugin_configuration = {
{name = "keyauth", __api = 1, __consumer = 1},
{name = "ratelimiting", value = {period = "minute", limit = 6}, __api = 1, __consumer = 1},
{name = "ratelimiting", value = { minute = 6}, __api = 1, __consumer = 1},
{name = "filelog", value = {path = "/tmp/spec.log" }, __api = 1, __consumer = 1},

{name = "keyauth", __api = 1, __consumer = 2}
Expand Down Expand Up @@ -658,8 +658,8 @@ describe("Cassandra", function()
},
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 = 6}, __api = 2 },
{ name = "ratelimiting", value = { minute = 6}, __api = 1 },
{ name = "ratelimiting", value = { minute = 6}, __api = 2 },
{ name = "filelog", value = { path = "/tmp/spec.log" }, __api = 1 }
}
}
Expand Down
5 changes: 2 additions & 3 deletions spec/unit/dao/entities_schemas_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,11 @@ describe("Entities Schemas", function()
assert.True(valid)

-- Failure
plugin = {name = "ratelimiting", api_id = "stub", value = {period = "hello"}}
plugin = {name = "ratelimiting", api_id = "stub", value = { second = "hello" }}

local valid, errors = validate_fields(plugin, plugins_configurations_schema)
assert.False(valid)
assert.equal("limit is required", errors["value.limit"])
assert.equal("\"hello\" is not allowed. Allowed values are: \"second\", \"minute\", \"hour\", \"day\", \"month\", \"year\"", errors["value.period"])
assert.equal("second is not a number", errors["value.second"])
end)

describe("on_insert", function()
Expand Down

0 comments on commit c36ea77

Please sign in to comment.