-
Notifications
You must be signed in to change notification settings - Fork 18
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 response handler #37
base: v2.x.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,7 +9,8 @@ const schema = Joi.object().keys({ | |
type: [Joi.string(), Joi.func()], | ||
limitd: Joi.object(), | ||
onError: Joi.func(), | ||
extractKey: Joi.func() | ||
extractKey: Joi.func(), | ||
limitResponseHandler: Joi.func() | ||
}).requiredKeys('type', 'event', 'limitd', 'extractKey'); | ||
|
||
function setResponseHeader(request, header, value) { | ||
|
@@ -43,6 +44,17 @@ function getMinimumLimit(limit1, limit2) { | |
if (!limit1) { return limit2; } | ||
if (!limit2) { return limit1; } | ||
|
||
// Even if we have decided not to answer with an error immediately, | ||
// if some limit is non conformat then we will consider it the | ||
// minimum applicable | ||
if (!limit1.conformant && limit2.conformant) { | ||
return limit1; | ||
} | ||
|
||
if (limit1.conformant && !limit2.conformant) { | ||
return limit2; | ||
} | ||
|
||
if (limit1 && limit2.remaining > limit1.remaining) { | ||
return limit1; | ||
} | ||
|
@@ -55,6 +67,16 @@ function setupRateLimitEventExt(server, options) { | |
const extractKey = options.extractKey; | ||
const onError = options.onError; | ||
|
||
const limitResponseHandler = options.limitResponseHandler || ((result, req, reply) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in this case it is mostly the same because this is a builder not used at high rate, I'd prefer to keep the function as closed as possible to their usage unless there are perf reasons not to do it. |
||
const error = Boom.tooManyRequests(); | ||
error.output.headers = new RateLimitHeaders( | ||
result.limit, | ||
result.remaining, | ||
result.reset); | ||
|
||
reply(error); | ||
}); | ||
|
||
const extractKeyAndTakeToken = function(limitd, request, reply, type) { | ||
extractKey(request, reply, (err, key) =>{ | ||
if (err) { return reply(err); } | ||
|
@@ -77,18 +99,12 @@ function setupRateLimitEventExt(server, options) { | |
request.plugins.patova = request.plugins.patova || {}; | ||
request.plugins.patova.limit = newMinimumLimitResponse; | ||
|
||
if (newMinimumLimitResponse.conformant) { | ||
if (currentLimitResponse.conformant) { | ||
// We continue only if the request is conformat so far | ||
return reply.continue(); | ||
} | ||
|
||
const error = Boom.tooManyRequests(); | ||
error.output.headers = new RateLimitHeaders( | ||
newMinimumLimitResponse.limit, | ||
newMinimumLimitResponse.remaining, | ||
newMinimumLimitResponse.reset); | ||
|
||
reply(error); | ||
limitResponseHandler(currentLimitResponse, request, reply); | ||
}); | ||
}); | ||
}; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -127,6 +127,20 @@ describe('options validation', () => { | |
}); | ||
}); | ||
|
||
it ('should fail if limitResponseHandler is not a function', () => { | ||
plugin.register(null, { | ||
type: 'user', | ||
event: 'onRequest', | ||
limitd: getLimitdClient(), | ||
limitResponseHandler: 'string', | ||
extractKey: EXTRACT_KEY_NOOP | ||
}, err => { | ||
expect(err.details).to.have.length(1); | ||
|
||
const firstError = err.details[0]; | ||
expect(firstError.message).to.equal('"limitResponseHandler" must be a Function'); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('with server', () => { | ||
|
@@ -502,45 +516,132 @@ function itBehavesLikeWhenLimitdIsRunning(options) { | |
}); | ||
|
||
describe('when limitd responds not conformant', () => { | ||
before((done) => { | ||
server.start({ replyError: false }, [ | ||
{ | ||
type: options.bucket3type, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest' | ||
}, | ||
{ | ||
type: options.emptyType, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest' | ||
}, | ||
{ | ||
type: options.bucket3type, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest' | ||
} | ||
], done); | ||
describe('and when limitResponseHandler is not provided', () => { | ||
before((done) => { | ||
server.start({ replyError: false }, [ | ||
{ | ||
type: options.bucket3type, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest' | ||
}, | ||
{ | ||
type: options.emptyType, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest' | ||
}, | ||
{ | ||
type: options.bucket3type, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest' | ||
} | ||
], done); | ||
}); | ||
|
||
after(server.stop); | ||
|
||
it('should send response with 429 if limit has passed for some plugin configuration and set limit header', function(done){ | ||
const request = { method: 'POST', url: '/users', payload: { } }; | ||
server.inject(request, res => { | ||
const body = JSON.parse(res.payload); | ||
const headers = res.headers; | ||
|
||
expect(res.statusCode).to.equal(429); | ||
expect(body.error).to.equal('Too Many Requests'); | ||
|
||
expect(headers['x-ratelimit-limit']).to.equal(0); | ||
expect(headers['x-ratelimit-remaining']).to.equal(0); | ||
expect(headers['x-ratelimit-reset']).to.equal(0); | ||
|
||
done(); | ||
}); | ||
}); | ||
}); | ||
|
||
after(server.stop); | ||
describe('and when limitResponseHandler is provided', () => { | ||
let callParams = []; | ||
|
||
before((done) => { | ||
server.start({ replyError: false }, [ | ||
{ | ||
type: options.bucket3type, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest', | ||
limitResponseHandler: (result, request, reply) => { | ||
callParams.push(result); | ||
|
||
reply.continue() | ||
} | ||
}, | ||
{ | ||
type: options.emptyType, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest', | ||
limitResponseHandler: (result, request, reply) => { | ||
callParams.push(result); | ||
|
||
reply.continue(); | ||
} | ||
}, | ||
{ | ||
type: options.bucket3type, | ||
limitd: getLimitdClient(address), | ||
extractKey: (request, reply, done) => { done(null, 'key'); }, | ||
event: 'onRequest', | ||
limitResponseHandler: (result, request, reply) => { | ||
callParams.push(result); | ||
|
||
reply.continue() | ||
} | ||
} | ||
], done); | ||
}); | ||
|
||
it('should send response with 429 if limit has passed for some plugin configuration and set limit header', function(done){ | ||
const request = { method: 'POST', url: '/users', payload: { } }; | ||
server.inject(request, res => { | ||
const body = JSON.parse(res.payload); | ||
const headers = res.headers; | ||
after(server.stop); | ||
|
||
expect(res.statusCode).to.equal(429); | ||
expect(body.error).to.equal('Too Many Requests'); | ||
it('should call each handler a single time', (done) => { | ||
const request = { method: 'POST', url: '/users', payload: { } }; | ||
server.inject(request, res => { | ||
expect(callParams.length).to.equal(3); | ||
|
||
expect(headers['x-ratelimit-limit']).to.equal(0); | ||
expect(headers['x-ratelimit-remaining']).to.equal(0); | ||
expect(headers['x-ratelimit-reset']).to.equal(0); | ||
done(); | ||
}); | ||
}); | ||
|
||
done(); | ||
it('should call each handler with their own result', (done) => { | ||
const request = { method: 'POST', url: '/users', payload: { } }; | ||
server.inject(request, res => { | ||
expect(callParams[0].limit).to.equal(3); | ||
expect(callParams[1].limit).to.equal(0); | ||
expect(callParams[2].limit).to.equal(3); | ||
|
||
done(); | ||
}); | ||
}); | ||
|
||
it('should call limitResponseHandler to handle the answer', function(done){ | ||
const request = { method: 'POST', url: '/users', payload: { } }; | ||
server.inject(request, res => { | ||
expect(res.statusCode).to.equal(200); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to spy on I'd also add a test with a custom handler that returns non-429 result (to witness the handler overriding the default error code). That would also self-document one use-case showing how to use the new handler. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think that the kind of test patova has let us spy in an easy way and non-hacky way. Also we have other test above for the same condition that check that there is a 429 response in this case. I could maybe check the response handler for the result if we want to be 100% sure in this particular test case |
||
expect(res.payload).to.eql('created'); | ||
|
||
done(); | ||
}); | ||
}); | ||
|
||
it('should return non-conformant values as the minimum when they apply', function(done){ | ||
const request = { method: 'POST', url: '/users', payload: { } }; | ||
server.inject(request, res => { | ||
expect(res.headers['x-ratelimit-limit']).to.equal(0); | ||
expect(res.headers['x-ratelimit-remaining']).to.equal(0); | ||
expect(res.headers['x-ratelimit-reset']).to.equal(0); | ||
|
||
done(); | ||
}); | ||
}); | ||
}); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would rename this option to
takeResultHandler
.Wouldn't it be more generic to handle conformant and non-conformant results? The same way we could
reply.continue()
on non-conformant, it might be useful to reject even for conformant?If we stay with only non-conformant, then I would rename the option to
nonConformantResultHandler
instead.I would rename
limitResult
totakeResult
since it's the result object returned from limitd'stake()
method.Maybe we should describe succinctly the default behavior when the option is omitted.
This sentence needs better wording. Maybe:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
takeResultHandler
seems really tied to limitd and in theory there might be N takes and we will only call the handler one time.Regarding renaming to take result my main concern is that as final user you should not have to know how limitd works to use patova, so you should not care if limitd has a method called take IMO.
I will make it more generic more generic to handle the non-limit case too, although in general I resist to make changes I don't feel have a clear use case and fail to see a clear one for the "conformant" case. Maybe not including the headers, or changing some of them I guess? In any case I see some side use cases as the one I pointed out previously, so 👍