Skip to content

Commit

Permalink
Add strict option (#27)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
schinkowitch and sindresorhus authored Feb 21, 2021
1 parent cdcde4e commit 242d410
Showing 5 changed files with 156 additions and 16 deletions.
11 changes: 9 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -30,12 +30,19 @@ declare namespace pThrottle {
/**
Maximum number of calls within an `interval`.
*/
limit: number;
readonly limit: number;

/**
Timespan for `limit` in milliseconds.
*/
interval: number;
readonly interval: number;

/**
Use a strict, more resource intensive, throttling algorithm. The default algorithm uses a windowed approach that will work correctly in most cases, limiting the total number of calls at the specified limit per interval window. The strict algorithm throttles each call individually, ensuring the limit is not exceeded for any interval.
@default false
*/
readonly strict?: boolean;
}

type AbortError = AbortErrorClass;
59 changes: 45 additions & 14 deletions index.js
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ class AbortError extends Error {
}
}

const pThrottle = ({limit, interval}) => {
const pThrottle = ({limit, interval, strict}) => {
if (!Number.isFinite(limit)) {
throw new TypeError('Expected `limit` to be a finite number');
}
@@ -21,6 +21,48 @@ const pThrottle = ({limit, interval}) => {
let currentTick = 0;
let activeCount = 0;

function windowedDelay() {
const now = Date.now();

if ((now - currentTick) > interval) {
activeCount = 1;
currentTick = now;
return 0;
}

if (activeCount < limit) {
activeCount++;
} else {
currentTick += interval;
activeCount = 1;
}

return currentTick - now;
}

const strictTicks = [];

function strictDelay() {
const now = Date.now();

if (strictTicks.length < limit) {
strictTicks.push(now);
return 0;
}

const earliestTime = strictTicks.shift() + interval;

if (now >= earliestTime) {
strictTicks.push(now);
return 0;
}

strictTicks.push(earliestTime);
return earliestTime - now;
}

const getDelay = strict ? strictDelay : windowedDelay;

return function_ => {
const throttled = function (...args) {
if (!throttled.isEnabled) {
@@ -34,19 +76,7 @@ const pThrottle = ({limit, interval}) => {
queue.delete(timeout);
};

const now = Date.now();

if ((now - currentTick) > interval) {
activeCount = 1;
currentTick = now;
} else if (activeCount < limit) {
activeCount++;
} else {
currentTick += interval;
activeCount = 1;
}

timeout = setTimeout(execute, currentTick - now);
timeout = setTimeout(execute, getDelay());

queue.set(timeout, reject);
});
@@ -59,6 +89,7 @@ const pThrottle = ({limit, interval}) => {
}

queue.clear();
strictTicks.splice(0, strictTicks.length);
};

throttled.isEnabled = true;
16 changes: 16 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
@@ -12,12 +12,28 @@ const throttledLazyUnicorn = pThrottle({
interval: 1000
})(async (index: string) => '🦄');

const strictThrottledUnicorn = pThrottle({
limit: 1,
interval: 1000,
strict: true
})((index: string) => '🦄');

const strictThrottledLazyUnicorn = pThrottle({
limit: 1,
interval: 1000,
strict: true
})(async (index: string) => '🦄');

expectType<AbortError>(new AbortError());

expectType<ThrottledFunction<string, string>>(throttledUnicorn);
expectType<ThrottledFunction<string, Promise<string>>>(throttledLazyUnicorn);
expectType<ThrottledFunction<string, string>>(strictThrottledUnicorn);
expectType<ThrottledFunction<string, Promise<string>>>(strictThrottledLazyUnicorn);

throttledUnicorn.abort();
throttledLazyUnicorn.abort();
strictThrottledUnicorn.abort();
strictThrottledLazyUnicorn.abort();

expectType<boolean>(throttledUnicorn.isEnabled);
7 changes: 7 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
@@ -68,6 +68,13 @@ Type: `number`

Timespan for `limit` in milliseconds.

#### strict

Type: `boolean`\
Default: `false`

Use a strict, more resource intensive, throttling algorithm. The default algorithm uses a windowed approach that will work correctly in most cases, limiting the total number of calls at the specified limit per interval window. The strict algorithm throttles each call individually, ensuring the limit is not exceeded for any interval.

### throttle(function_)

#### function_
79 changes: 79 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import test from 'ava';
import inRange from 'in-range';
import timeSpan from 'time-span';
import delay from 'delay';
import pThrottle from './index.js';

const fixture = Symbol('fixture');
@@ -21,6 +22,84 @@ test('main', async t => {
}));
});

test('strict mode', async t => {
const totalRuns = 100;
const limit = 5;
const interval = 100;
const strict = true;
const end = timeSpan();
const throttled = pThrottle({limit, interval, strict})(async () => {});

await Promise.all(new Array(totalRuns).fill(0).map(x => throttled(x)));

const totalTime = (totalRuns * interval) / limit;
t.true(inRange(end(), {
start: totalTime - 200,
end: totalTime + 200
}));
});

test('limits after pause in strict mode', async t => {
const limit = 10;
const interval = 100;
const strict = true;
const throttled = pThrottle({limit, interval, strict})(() => Date.now());
const pause = 40;
const promises = [];
const start = Date.now();

await throttled();

await delay(pause);

for (let i = 0; i < limit + 1; i++) {
promises.push(throttled());
}

const results = await Promise.all(promises);

for (const [index, executed] of results.entries()) {
const elapsed = executed - start;
if (index < limit - 1) {
t.true(inRange(elapsed, {start: pause, end: pause + 15}), 'Executed immediately after the pause');
} else if (index === limit - 1) {
t.true(inRange(elapsed, {start: interval, end: interval + 15}), 'Executed after the interval');
} else {
const difference = executed - results[index - limit];
t.true(inRange(difference, {start: interval - 10, end: interval + 15}), 'Waited the interval');
}
}
});

test('limits after pause in windowed mode', async t => {
const limit = 10;
const interval = 100;
const strict = false;
const throttled = pThrottle({limit, interval, strict})(() => Date.now());
const pause = 40;
const promises = [];
const start = Date.now();

await throttled();

await delay(pause);

for (let i = 0; i < limit + 1; i++) {
promises.push(throttled());
}

const results = await Promise.all(promises);

for (const [index, executed] of results.entries()) {
const elapsed = executed - start;
if (index < limit - 1) {
t.true(inRange(elapsed, {start: pause, end: pause + 10}), 'Executed immediately after the pause');
} else {
t.true(inRange(elapsed, {start: interval - 10, end: interval + 10}), 'Executed immediately after the interval');
}
}
});

test('passes arguments through', async t => {
const throttled = pThrottle({limit: 1, interval: 100})(async x => x);
t.is(await throttled(fixture), fixture);

0 comments on commit 242d410

Please sign in to comment.