Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Retry-After" header #929

Merged
merged 5 commits into from
Oct 10, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- OIDC Authentication policy (only usable directly by the configuration file) [PR #904](https://github.com/3scale/apicast/pull/904)
- IP check policy. This policy allows to accept or deny requests based on the IP [PR #907](https://github.com/3scale/apicast/pull/907), [PR #923](https://github.com/3scale/apicast/pull/923), [THREESCALE-1353](https://issues.jboss.org/browse/THREESCALE-1353)
- Delete operation in the headers policy [PR #928](https://github.com/3scale/apicast/pull/928), [THREESCALE-1354](https://issues.jboss.org/browse/THREESCALE-1354)
- "Retry-After" header in the response when rate-limited by the 3scale backend [PR #929](https://github.com/3scale/apicast/pull/929), [THREESCALE-1380](https://issues.jboss.org/browse/THREESCALE-1380)

### Changed

Expand Down
12 changes: 9 additions & 3 deletions gateway/src/apicast/backend_client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,21 @@ local function create_token_path(service_id)
end

-- Returns the authorize options that 3scale backend accepts. Those options
-- are specified via headers. Right now there are 2:
-- are specified via headers:
-- - rejection_reason_header: asks backend to return why a call is denied
-- (limits exceeded, application key invalid, etc.)
-- - no_nody: when enabled, backend will not return a response body. The
-- - no_body: when enabled, backend will not return a response body. The
-- body has many information like metrics, limits, etc. This information is
-- parsed only when using oauth. By enabling this option will save some work
-- to the 3scale backend and reduce network traffic.
-- - limit_headers: when enabled and the request is rate-limited, backend
-- returns the number of seconds remaining until the limit expires. It
-- returns -1 when there are no limits. With this header, backend returns
Copy link

@ioggstream ioggstream Jan 24, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidor https://tools.ietf.org/html/rfc7231#section-7.1.3

A delay-seconds value is a non-negative decimal integer, representing time in seconds.

In extensions.md I'd add a notice on that difference with the specs.

-- more information but we do not need it for now.
-- For the complete specs check:
-- https://github.com/3scale/apisonator/blob/master/docs/extensions.md
local function authorize_options(using_oauth)
local headers = { ['3scale-options'] = 'rejection_reason_header=1' }
local headers = { ['3scale-options'] = 'rejection_reason_header=1&limit_headers=1' }

if not using_oauth then
headers['3scale-options'] = headers['3scale-options'] .. '&no_body=1'
Expand Down
7 changes: 6 additions & 1 deletion gateway/src/apicast/errors.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,14 @@ function _M.authorization_failed(service)
return exit()
end

function _M.limits_exceeded(service)
function _M.limits_exceeded(service, retry_after)
ngx.log(ngx.INFO, 'limits exceeded for service ', service.id)
ngx.var.cached_key = nil

if retry_after then
ngx.header['Retry-After'] = retry_after
end

ngx.status = service.limits_exceeded_status
ngx.header.content_type = service.limits_exceeded_headers
ngx.print(service.error_limits_exceeded)
Expand Down
18 changes: 15 additions & 3 deletions gateway/src/apicast/proxy.lua
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,13 @@ function _M:authorize(service, usage, credentials, ttl)
local backend = build_backend_client(self, service)
local res = backend:authrep(formatted_usage, credentials, self.extra_params_backend_authrep)

local authorized, rejection_reason = self:handle_backend_response(cached_key, res, ttl)
local authorized, rejection_reason, retry_after = self:handle_backend_response(
cached_key, res, ttl
)

if not authorized then
if rejection_reason == 'limits_exceeded' then
return errors.limits_exceeded(service)
return errors.limits_exceeded(service, retry_after)
else -- Generic error for now. Maybe return different ones in the future.
return errors.authorization_failed(service)
end
Expand Down Expand Up @@ -348,15 +351,24 @@ local function rejection_reason(response_headers)
return response_headers and response_headers['3scale-rejection-reason']
end

-- Returns the '3scale-limit-reset' from the headers of a 3scale backend
-- response.
-- This header is set only when enabled via the '3scale-options' header of the
-- request.
local function limit_reset(response_headers)
return response_headers and response_headers['3scale-limit-reset']
end

function _M:handle_backend_response(cached_key, response, ttl)
ngx.log(ngx.DEBUG, '[backend] response status: ', response.status, ' body: ', response.body)

self.cache_handler(self.cache, cached_key, response, ttl)

local authorized = (response.status == 200)
local unauthorized_reason = not authorized and rejection_reason(response.headers)
local retry_after = not authorized and limit_reset(response.headers)

return authorized, unauthorized_reason
return authorized, unauthorized_reason, retry_after
end

if custom_config then
Expand Down
4 changes: 2 additions & 2 deletions spec/backend_client_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ local backend_calls_metrics = require 'apicast.metrics.3scale_backend_calls'
describe('backend client', function()

local test_backend
local options_header_oauth = 'rejection_reason_header=1'
local options_header_no_oauth = 'rejection_reason_header=1&no_body=1'
local options_header_oauth = 'rejection_reason_header=1&limit_headers=1'
local options_header_no_oauth = 'rejection_reason_header=1&limit_headers=1&no_body=1'

before_each(function()
test_backend = http_ng.backend()
Expand Down
27 changes: 27 additions & 0 deletions spec/errors_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
local errors = require 'apicast.errors'
local Service = require 'apicast.configuration.service'

describe('Errors', function()
describe('.limits_exceeded', function()
before_each(function()
ngx.var = {}
ngx.header = {}
end)

local test_service = Service.new({ id = 1 })

it('sets the Retry-After header when a value for it is received', function()
local retry_after = 60

errors.limits_exceeded(test_service, retry_after)

assert.equals(retry_after, ngx.header['Retry-After'])
end)

it('does not set the Retry-After header when a value for it is not received', function()
errors.limits_exceeded(test_service)

assert.is_nil(ngx.header['Retry-After'])
end)
end)
end)
24 changes: 24 additions & 0 deletions spec/proxy_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ local configuration_store = require 'apicast.configuration_store'
local Service = require 'apicast.configuration.service'
local Usage = require 'apicast.usage'
local test_backend_client = require 'resty.http_ng.backend.test'
local errors = require 'apicast.errors'

describe('Proxy', function()
local configuration, proxy, test_backend
Expand Down Expand Up @@ -125,6 +126,29 @@ describe('Proxy', function()
-- Does not call backend because the call is cached
assert.stub(test_backend.send).was_not_called()
end)

it('returns "limits exceeded" with the "Retry-After" given by the 3scale backend', function()
ngx.header = {}
ngx.var = { cached_key = "uk" } -- authorize() expects creds to be set up
stub(errors, 'limits_exceeded')
local retry_after = 60
local usage = Usage.new()
usage:add('hits', 1)

test_backend.expect({}).respond_with(
{
status = 409,
headers = {
['3scale-limit-reset'] = retry_after,
['3scale-rejection-reason'] = 'limits_exceeded'
}
}
)

proxy:authorize(service, usage, { user_key = 'uk' })

assert.stub(errors.limits_exceeded).was_called_with(service, retry_after)
end)
end)

describe('.handle_backend_response', function()
Expand Down
51 changes: 49 additions & 2 deletions t/apicast.t
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ status code (429).

location /transactions/authrep.xml {
content_by_lua_block {
if ngx.var['http_3scale_options'] == 'rejection_reason_header=1&no_body=1' then
if ngx.var['http_3scale_options'] == 'rejection_reason_header=1&limit_headers=1&no_body=1' then
ngx.header['3scale-rejection-reason'] = 'limits_exceeded';
end
ngx.status = 409;
Expand Down Expand Up @@ -695,7 +695,7 @@ Limits exceeded

location /transactions/authrep.xml {
content_by_lua_block {
if ngx.var['http_3scale_options'] == 'rejection_reason_header=1&no_body=1' then
if ngx.var['http_3scale_options'] == 'rejection_reason_header=1&limit_headers=1&no_body=1' then
ngx.header['3scale-rejection-reason'] = 'limits_exceeded';
end
ngx.status = 409;
Expand Down Expand Up @@ -766,3 +766,50 @@ credentials missing!
--- error_code: 401
--- no_error_log
[error]

=== TEST 21: returns 'Retry-After' header when rate-limited by 3scale backend
--- http_config
lua_package_path "$TEST_NGINX_LUA_PATH";
include $TEST_NGINX_UPSTREAM_CONFIG;
init_by_lua_block {
require('apicast.configuration_loader').mock({
services = {
{
backend_version = 1,
proxy = {
api_backend = "http://127.0.0.1:$TEST_NGINX_SERVER_PORT/api-backend/",
proxy_rules = {
{ pattern = '/', http_method = 'GET', metric_system_name = 'hits' }
}
}
}
}
})
}
--- config
include $TEST_NGINX_APICAST_CONFIG;

location /transactions/authrep.xml {
content_by_lua_block {
local expected_3scale_opts = 'rejection_reason_header=1&limit_headers=1&no_body=1'
if ngx.var['http_3scale_options'] == expected_3scale_opts then
ngx.header['3scale-rejection-reason'] = 'limits_exceeded';
ngx.header['3scale-limit-reset'] = 60
end
ngx.status = 409;
ngx.exit(ngx.HTTP_OK);
}
}

location /api-backend/ {
echo 'yay';
}
--- request
GET /?user_key=value
--- response_headers
Retry-After: 60
--- response_body chomp
Limits exceeded
--- error_code: 429
--- no_error_log
[error]