local util = require("util") local function assert_request_rejected(config, location_config, opts) stub(ngx, "exit") local global_throttle = require_without_cache("global_throttle") assert.has_no.errors(function() global_throttle.throttle(config, location_config) end) assert.stub(ngx.exit).was_called_with(config.status_code) if opts.with_cache then assert.are.same("c", ngx.var.global_rate_limit_exceeding) else assert.are.same("y", ngx.var.global_rate_limit_exceeding) end end local function assert_request_not_rejected(config, location_config) stub(ngx, "exit") local cache_safe_add_spy = spy.on(ngx.shared.global_throttle_cache, "safe_add") local global_throttle = require_without_cache("global_throttle") assert.has_no.errors(function() global_throttle.throttle(config, location_config) end) assert.stub(ngx.exit).was_not_called() assert.is_nil(ngx.var.global_rate_limit_exceeding) assert.spy(cache_safe_add_spy).was_not_called() end local function assert_short_circuits(f) local cache_get_spy = spy.on(ngx.shared.global_throttle_cache, "get") local resty_global_throttle = require_without_cache("resty.global_throttle") local resty_global_throttle_new_spy = spy.on(resty_global_throttle, "new") local global_throttle = require_without_cache("global_throttle") f(global_throttle) assert.spy(resty_global_throttle_new_spy).was_not_called() assert.spy(cache_get_spy).was_not_called() end local function assert_fails_open(config, location_config, ...) stub(ngx, "exit") stub(ngx, "log") local global_throttle = require_without_cache("global_throttle") assert.has_no.errors(function() global_throttle.throttle(config, location_config) end) assert.stub(ngx.exit).was_not_called() assert.stub(ngx.log).was_called_with(ngx.ERR, ...) assert.is_nil(ngx.var.global_rate_limit_exceeding) end local function stub_resty_global_throttle_process(ret1, ret2, ret3, f) local resty_global_throttle = require_without_cache("resty.global_throttle") local resty_global_throttle_mock = { process = function(self, key) return ret1, ret2, ret3 end } stub(resty_global_throttle, "new", resty_global_throttle_mock) f() assert.stub(resty_global_throttle.new).was_called() end local function cache_rejection_decision(namespace, key_value, desired_delay) local namespaced_key_value = namespace .. key_value local ok, err = ngx.shared.global_throttle_cache:safe_add(namespaced_key_value, true, desired_delay) assert.is_nil(err) assert.is_true(ok) assert.is_true(ngx.shared.global_throttle_cache:get(namespaced_key_value)) end describe("global_throttle", function() local snapshot local NAMESPACE = "31285d47b1504dcfbd6f12c46d769f6e" local LOCATION_CONFIG = { namespace = NAMESPACE, limit = 10, window_size = 60, key = {}, ignored_cidrs = {}, } local CONFIG = { memcached = { host = "memc.default.svc.cluster.local", port = 11211, connect_timeout = 50, max_idle_timeout = 10000, pool_size = 50, }, status_code = 429, } before_each(function() snapshot = assert:snapshot() ngx.var = { remote_addr = "127.0.0.1", global_rate_limit_exceeding = nil } end) after_each(function() snapshot:revert() ngx.shared.global_throttle_cache:flush_all() reset_ngx() end) it("short circuits when memcached is not configured", function() assert_short_circuits(function(global_throttle) assert.has_no.errors(function() global_throttle.throttle({ memcached = { host = "", port = 0 } }, LOCATION_CONFIG) end) end) end) it("short circuits when limit or window_size is not configured", function() assert_short_circuits(function(global_throttle) local location_config_copy = util.deepcopy(LOCATION_CONFIG) location_config_copy.limit = 0 assert.has_no.errors(function() global_throttle.throttle(CONFIG, location_config_copy) end) end) assert_short_circuits(function(global_throttle) local location_config_copy = util.deepcopy(LOCATION_CONFIG) location_config_copy.window_size = 0 assert.has_no.errors(function() global_throttle.throttle(CONFIG, location_config_copy) end) end) end) it("short circuits when remote_addr is in ignored_cidrs", function() local global_throttle = require_without_cache("global_throttle") local location_config = util.deepcopy(LOCATION_CONFIG) location_config.ignored_cidrs = { ngx.var.remote_addr } assert_short_circuits(function(global_throttle) assert.has_no.errors(function() global_throttle.throttle(CONFIG, location_config) end) end) end) it("rejects when exceeding limit has already been cached", function() local key_value = "foo" local location_config = util.deepcopy(LOCATION_CONFIG) location_config.key = { { nil, nil, nil, key_value } } cache_rejection_decision(NAMESPACE, key_value, 0.5) assert_request_rejected(CONFIG, location_config, { with_cache = true }) end) describe("when resty_global_throttle fails", function() it("fails open in case of initialization error", function() local too_long_namespace = "" for i=1,36,1 do too_long_namespace = too_long_namespace .. "a" end local location_config = util.deepcopy(LOCATION_CONFIG) location_config.namespace = too_long_namespace assert_fails_open(CONFIG, location_config, "faled to initialize resty_global_throttle: ", "'namespace' can be at most 35 characters") end) it("fails open in case of key processing error", function() stub_resty_global_throttle_process(nil, nil, "failed to process", function() assert_fails_open(CONFIG, LOCATION_CONFIG, "error while processing key: ", "failed to process") end) end) end) it("initializes resty_global_throttle with the right parameters", function() local resty_global_throttle = require_without_cache("resty.global_throttle") local resty_global_throttle_original_new = resty_global_throttle.new resty_global_throttle.new = function(namespace, limit, window_size, store_opts) local o, err = resty_global_throttle_original_new(namespace, limit, window_size, store_opts) if not o then return nil, err end o.process = function(self, key) return 1, nil, nil end local expected = LOCATION_CONFIG assert.are.same(expected.namespace, namespace) assert.are.same(expected.limit, limit) assert.are.same(expected.window_size, window_size) assert.are.same("memcached", store_opts.provider) assert.are.same(CONFIG.memcached.host, store_opts.host) assert.are.same(CONFIG.memcached.port, store_opts.port) assert.are.same(CONFIG.memcached.connect_timeout, store_opts.connect_timeout) assert.are.same(CONFIG.memcached.max_idle_timeout, store_opts.max_idle_timeout) assert.are.same(CONFIG.memcached.pool_size, store_opts.pool_size) return o, nil end local resty_global_throttle_new_spy = spy.on(resty_global_throttle, "new") local global_throttle = require_without_cache("global_throttle") assert.has_no.errors(function() global_throttle.throttle(CONFIG, LOCATION_CONFIG) end) assert.spy(resty_global_throttle_new_spy).was_called() end) it("rejects request and caches decision when limit is exceeding after processing a key", function() local desired_delay = 0.015 stub_resty_global_throttle_process(LOCATION_CONFIG.limit + 1, desired_delay, nil, function() assert_request_rejected(CONFIG, LOCATION_CONFIG, { with_cache = false }) local cache_key = LOCATION_CONFIG.namespace .. ngx.var.remote_addr assert.is_true(ngx.shared.global_throttle_cache:get(cache_key)) -- we assume it won't take more than this after caching -- until we execute the assertion below local delta = 0.001 local ttl = ngx.shared.global_throttle_cache:ttl(cache_key) assert.is_true(ttl > desired_delay - delta) assert.is_true(ttl <= desired_delay) end) end) it("rejects request and skip caching of decision when limit is exceeding after processing a key but desired delay is lower than the threshold", function() local desired_delay = 0.0009 stub_resty_global_throttle_process(LOCATION_CONFIG.limit, desired_delay, nil, function() assert_request_rejected(CONFIG, LOCATION_CONFIG, { with_cache = false }) local cache_key = LOCATION_CONFIG.namespace .. ngx.var.remote_addr assert.is_nil(ngx.shared.global_throttle_cache:get(cache_key)) end) end) it("allows the request when limit is not exceeding after processing a key", function() stub_resty_global_throttle_process(LOCATION_CONFIG.limit - 3, nil, nil, function() assert_request_not_rejected(CONFIG, LOCATION_CONFIG) end ) end) it("rejects with custom status code", function() cache_rejection_decision(NAMESPACE, ngx.var.remote_addr, 0.3) local config = util.deepcopy(CONFIG) config.status_code = 503 assert_request_rejected(config, LOCATION_CONFIG, { with_cache = true }) end) end)