Skip to content
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Divvy Changelog

## v1.4.0 (2019-05-08)

* Feature: Support named rules.

## v1.3.0 (2018-06-06)

* Feature: Add support for JSON-based configuration.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Divvy is a quota / rate limiter service, implemented in NodeJS and backed by Red
With Divvy you can express policies like:

* Limit `GET /printer/supplies` to 100 requests per minute globally.
* Limit `POST /printer/print` to
* Limit `POST /printer/print` to
* 60 requests/minute for authorized users.
* 5 requests/minute for everyone else, by IP address.
* No limit for `GET /printer/status`
Expand Down Expand Up @@ -101,7 +101,7 @@ Let's look at what each field means:
* **Status**: `OK`: The server understood the command, yay!
* **Allowed**: `true`: We have enough quota for this operation.
* **Credit remaining**: `999`: We have a bunch of quota left, too.
* **Next reset (seconds)**: `60`: Our quota will be set back to `1000` in 60 seconds.
* **Next reset (seconds)**: `60`: Our quota will be set back to `1000` in 60 seconds.

Let's try a few more and watch our quota decrease:

Expand Down Expand Up @@ -219,6 +219,7 @@ Bucket order is signficant: Quota is determined for a `HIT` request by finding t

The following optional fields are also supported:

* `label`: A short, slug-like name for the rule. If set, Prometheus metrics for hits matching the rule will be labeled with `rule_label` as this value. Labels have no effect on statsd metrics.
* `comment`: A diagnostic comment, printed when running server with `DEBUG=divvy`.
* `actorField`: Described in _"Actors and multi-tenancy"_.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@button/divvy",
"version": "1.3.0",
"version": "1.4.0",
"description": "Redis-backed rate limit service.",
"main": "index.js",
"directories": {
Expand Down
23 changes: 21 additions & 2 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ const Utils = require('./utils');
*/
const REGEX_ESCAPE_CHARACTERS = /[-[\]{}()+?.,\\^$|#]/g;

/**
* Allowed pattern for the "label" field of a rule.
*/
const RULE_LABEL_REGEX = /^[a-zA-Z0-9_-]{1,255}$/;

function isGlobValue(v) {
return v.endsWith('*');
}

class Config {
constructor() {
this.rules = [];
this.ruleLabels = new Set();
}

/**
Expand Down Expand Up @@ -52,6 +58,7 @@ class Config {
rule.creditLimit,
rule.resetSeconds,
rule.actorField,
rule.label,
rule.comment);
});

Expand Down Expand Up @@ -87,8 +94,9 @@ class Config {
// Optional fields.
const actorField = rulegroupConfig.actorField || '';
const comment = rulegroupConfig.comment || '';
const label = rulegroupConfig.label || '';

config.addRule(operation, creditLimit, resetSeconds, actorField, comment);
config.addRule(operation, creditLimit, resetSeconds, actorField, label, comment);
}

return config;
Expand All @@ -115,9 +123,10 @@ class Config {
* @param {number} creditLimit Number of operations to permit every `resetSeconds`
* @param {number} resetSeconds Credit renewal interval.
* @param {string} actorField Name of the actor field (optional).
* @param {string} label Optional name for this rule.
* @param {string} comment Optional diagnostic name for this rule.
*/
addRule(operation, creditLimit, resetSeconds, actorField, comment) {
addRule(operation, creditLimit, resetSeconds, actorField, label, comment) {
Copy link
Contributor

@i i May 9, 2019

Choose a reason for hiding this comment

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

since label is an optional argument, why position it second to last?
there's a bunch of places in tests/config-test.js that will need to be updated to reflect this positional change.

alternatively, since the cardinality of this function is getting big, what are your thoughts on having it accept an arguments object instead of positional arguments?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

since label is an optional argument, why position it second to last?

Since both label and comment are optional, am guessing I somewhat arbitrarily decided labels will be more common than comments.

alternatively, since the cardinality of this function is getting big, what are your thoughts on having it accept an arguments object instead of positional arguments?

I like it, that'd definitely make it a bunch more readable! May sheepishly defer this suggestion as am running into limited cycles to iterate further. OK with you to land as is?

const foundRule = this.findRule(operation);

if (foundRule !== null) {
Expand All @@ -133,11 +142,21 @@ class Config {
throw new Error(`Invalid resetSeconds for operation=${operation} (${resetSeconds})`);
}

if (label) {
if (!RULE_LABEL_REGEX.test(label)) {
throw new Error(`Invalid rule label "${label}"; must match ${RULE_LABEL_REGEX}`);
} else if (this.ruleLabels.has(label)) {
throw new Error(`A rule with label "${label}" already exists; labels must be unique.`);
}
this.ruleLabels.add(label);
}

const rule = {
operation,
creditLimit,
resetSeconds,
actorField,
label: label || null,
comment: comment || null,
};
this.rules.push(rule);
Expand Down
7 changes: 4 additions & 3 deletions src/instrumenter.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class Instrumenter {
this.hitCounter = new PrometheusClient.Counter({
name: 'divvy_hits_total',
help: 'Counter of total HITs to Divvy.',
labelNames: ['status', 'type'],
labelNames: ['status', 'type', 'rule_label'],
});

this.errorCounter = new PrometheusClient.Counter({
Expand Down Expand Up @@ -92,11 +92,12 @@ class Instrumenter {
* Record a HIT operation.
* @param {string} status The status of the hit, either "accepted" or "rejected".
* @param {string} type The match type, either "rule", "default", or "none".
* @param {string} label The matching rule label, or null.
*/
countHit(status, type) {
countHit(status, type, label) {
this.statsd.increment(`hit.${status}`);
this.statsd.increment(`hit.${status}.${type}`);
this.hitCounter.labels(status, type).inc();
this.hitCounter.labels(status, type, label || '').inc();
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ class Server extends EventEmitter {
this.instrumenter.timeHit(startDate);
const result = status.isAllowed ? 'accepted' : 'rejected';
const matchType = Server.getMatchType(rule);
this.instrumenter.countHit(result, matchType);
const ruleLabel = (rule && rule.label) ? rule.label : '';
this.instrumenter.countHit(result, matchType, ruleLabel);
}).catch((err) => {
this.sendError(conn, `Server error: ${err}`);
});
Expand Down
28 changes: 26 additions & 2 deletions tests/config-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('src/config', function () {
creditLimit: 100,
resetSeconds: 60,
actorField: 'ip',
label: null,
comment: '100 rpm for /ping for authenticated users, by ip',
}, rule);

Expand All @@ -47,6 +48,7 @@ describe('src/config', function () {
creditLimit: 10,
resetSeconds: 60,
actorField: 'ip',
label: 'get-ping-by-ip',
comment: '10 rpm for /ping for non-authenticated users, by ip',
}, rule);

Expand All @@ -64,6 +66,7 @@ describe('src/config', function () {
creditLimit: 5,
resetSeconds: 60,
actorField: 'ip',
label: 'post-by-ip',
comment: '5 rpm for any POST, by ip',
}, rule);

Expand All @@ -75,6 +78,7 @@ describe('src/config', function () {
creditLimit: 1,
resetSeconds: 60,
actorField: '',
label: null,
Copy link
Contributor

Choose a reason for hiding this comment

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

most configs won't explicitly set label to null.
maybe specify one as undefined and another with no key for label for good measure?

comment: 'Default quota',
}, rule);
});
Expand Down Expand Up @@ -102,6 +106,22 @@ describe('src/config', function () {
}, /Invalid creditLimit/);
});

it('with a rule containing an invalid label', function () {
const config = new Config();
config.addRule({ service: 'myservice', method: 'GET' }, 0, 60, null, 'nice-rule');
assert.throws(() => {
config.addRule({ service: 'myservice', method: 'POST' }, 0, 60, null, 'this is fine');
}, /Invalid rule label "this is fine"/);
});

it('with a rule containing a duplicate label', function () {
const config = new Config();
config.addRule({ service: 'myservice', method: 'GET' }, 0, 60, null, 'nice-rule');
assert.throws(() => {
config.addRule({ service: 'myservice', method: 'POST' }, 0, 60, null, 'nice-rule');
}, /A rule with label "nice-rule" already exists/);
});

it('with a rule where resetSeconds < 1', function () {
const config = new Config();
config.addRule({ service: 'myservice', method: 'GET' }, 20, 1);
Expand All @@ -119,8 +139,8 @@ describe('src/config', function () {
it('handles simple glob keys', function () {
const config = new Config();

config.addRule({ service: 'my*', method: 'GET' }, 100, 60, 'actor', 'a');
config.addRule({ service: 'your*', method: 'GET' }, 200, 40, 'jim', 'b');
config.addRule({ service: 'my*', method: 'GET' }, 100, 60, 'actor', 'rule-a', 'a');
config.addRule({ service: 'your*', method: 'GET' }, 200, 40, 'jim', 'rule-b', 'b');

const rule = config.findRule({ service: 'myget', method: 'GET' });
assert.deepEqual({
Expand All @@ -131,6 +151,7 @@ describe('src/config', function () {
creditLimit: 100,
resetSeconds: 60,
actorField: 'actor',
label: 'rule-a',
comment: 'a',
}, rule);

Expand All @@ -143,6 +164,7 @@ describe('src/config', function () {
creditLimit: 200,
resetSeconds: 40,
actorField: 'jim',
label: 'rule-b',
comment: 'b',
}, other);
});
Expand All @@ -164,6 +186,7 @@ describe('src/config', function () {
creditLimit: 1,
resetSeconds: 60,
actorField: 'ip',
label: null,
comment: '1 rpm for POST /account*, by ip',
}, rule);

Expand All @@ -181,6 +204,7 @@ describe('src/config', function () {
creditLimit: 5,
resetSeconds: 60,
actorField: 'ip',
label: 'post-by-ip',
comment: '5 rpm for any POST, by ip',
}, rule);
});
Expand Down
18 changes: 12 additions & 6 deletions tests/instrumenter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ describe('src/instrumenter', function () {
sinon.assert.notCalled(this.instrumenter.statsd.increment);
assert.deepEqual(this.instrumenter.hitCounter.hashMap, {});

this.instrumenter.countHit('accepted', 'rule');
this.instrumenter.countHit('accepted', 'rule');
this.instrumenter.countHit('accepted', 'rule', 'a');
this.instrumenter.countHit('accepted', 'rule', 'b');
this.instrumenter.countHit('accepted', 'none');
this.instrumenter.countHit('accepted', 'default');
this.instrumenter.countHit('rejected', 'none');
Expand All @@ -76,9 +76,15 @@ describe('src/instrumenter', function () {
sinon.assert.calledWith(this.instrumenter.statsd.increment, 'hit.accepted.default');
sinon.assert.calledWith(this.instrumenter.statsd.increment, 'hit.rejected.none');

assert.equal(this.instrumenter.hitCounter.hashMap['status:accepted,type:rule'].value, 2);
assert.equal(this.instrumenter.hitCounter.hashMap['status:accepted,type:none'].value, 1);
assert.equal(this.instrumenter.hitCounter.hashMap['status:accepted,type:default'].value, 1);
assert.equal(this.instrumenter.hitCounter.hashMap['status:rejected,type:none'].value, 1);
assert.equal(this.instrumenter.hitCounter.hashMap[
'rule_label:a,status:accepted,type:rule'].value, 1);
assert.equal(this.instrumenter.hitCounter.hashMap[
'rule_label:b,status:accepted,type:rule'].value, 1);
assert.equal(this.instrumenter.hitCounter.hashMap[
'rule_label:,status:accepted,type:none'].value, 1);
assert.equal(
this.instrumenter.hitCounter.hashMap['rule_label:,status:accepted,type:default'].value, 1);
assert.equal(this.instrumenter.hitCounter.hashMap[
'rule_label:,status:rejected,type:none'].value, 1);
});
});
6 changes: 3 additions & 3 deletions tests/server-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('src/server', function () {
});

sinon.assert.callCount(instrumenter.countHit, 1);
sinon.assert.calledWith(instrumenter.countHit, 'accepted', 'rule');
sinon.assert.calledWith(instrumenter.countHit, 'accepted', 'rule', '');

sinon.assert.callCount(instrumenter.timeHit, 1);
// Since we've installed sinon's fake timers we can safely compare to new Date()
Expand Down Expand Up @@ -140,7 +140,7 @@ describe('src/server', function () {
});

sinon.assert.callCount(instrumenter.countHit, 1);
sinon.assert.calledWith(instrumenter.countHit, 'accepted', 'rule');
sinon.assert.calledWith(instrumenter.countHit, 'accepted', 'rule', 'get-ping-by-ip');

sinon.assert.callCount(instrumenter.timeHit, 1);
// Since we've installed sinon's fake timers we can safely compare to new Date()
Expand Down Expand Up @@ -175,7 +175,7 @@ describe('src/server', function () {
});

sinon.assert.callCount(instrumenter.countHit, 1);
sinon.assert.calledWith(instrumenter.countHit, 'accepted', 'rule');
sinon.assert.calledWith(instrumenter.countHit, 'accepted', 'rule', '');

sinon.assert.callCount(instrumenter.timeHit, 1);
// Since we've installed sinon's fake timers we can safely compare to new Date()
Expand Down
5 changes: 4 additions & 1 deletion tests/test-config.ini
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ comment = '100 rpm for /ping for authenticated users, by ip'
creditLimit = 10
resetSeconds = 60
actorField = ip
label = 'get-ping-by-ip'
comment = '10 rpm for /ping for non-authenticated users, by ip'

[method=POST ip=* path=/account* isAuthenticated=true]
Expand All @@ -20,9 +21,11 @@ comment = '1 rpm for POST /account*, by ip'
creditLimit = 5
resetSeconds = 60
actorField = ip
label = 'post-by-ip'
comment = '5 rpm for any POST, by ip'

[default]
creditLimit = 1
resetSeconds = 60
comment = 'Default quota'
label = null
comment = 'Default quota'
3 changes: 3 additions & 0 deletions tests/test-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"creditLimit": 10,
"resetSeconds": 60,
"actorField": "ip",
"label": "get-ping-by-ip",
"comment": "10 rpm for /ping for non-authenticated users, by ip"
},
{
Expand All @@ -43,6 +44,7 @@
"creditLimit": 5,
"resetSeconds": 60,
"actorField": "ip",
"label": "post-by-ip",
"comment": "5 rpm for any POST, by ip"
}
],
Expand All @@ -51,6 +53,7 @@
"creditLimit": 1,
"resetSeconds": 60,
"actorField": "",
"label": null,
"comment": "Default quota"
}
}