Skip to content

Commit

Permalink
Datablock Storage: rate limiting (#58479)
Browse files Browse the repository at this point in the history
* Implement rate limiting for datablock storage at the block level.
* Fixes #55481

---------
Co-authored-by: Cassi Brenci <cassi.brenci@code.org>
  • Loading branch information
snickell committed May 9, 2024
1 parent 03bab57 commit 71d43f4
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 25 deletions.
105 changes: 80 additions & 25 deletions apps/src/applab/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {actions, REDIRECT_RESPONSE} from './redux/applab';
import {getStore} from '../redux';
import $ from 'jquery';
import i18n from '@cdo/applab/locale';
import {rateLimit} from '@cdo/apps/storage/rateLimit';

// For proxying non-https xhr requests
var XHR_PROXY_PATH = '//' + location.host + '/xhr';
Expand Down Expand Up @@ -1760,7 +1761,12 @@ applabCommands.createRecord = function (opts) {
}
var onSuccess = applabCommands.handleCreateRecord.bind(this, opts);
var onError = opts.onError || getAsyncOutputWarning();
Applab.storage.createRecord(opts.table, opts.record, onSuccess, onError);
try {
rateLimit();
Applab.storage.createRecord(opts.table, opts.record, onSuccess, onError);
} catch (e) {
outputError(e.message);
}
};

applabCommands.handleCreateRecord = function (opts, record) {
Expand All @@ -1783,7 +1789,12 @@ applabCommands.getKeyValue = function (opts) {
);
var onSuccess = applabCommands.handleReadValue.bind(this, opts);
var onError = opts.onError || getAsyncOutputWarning();
Applab.storage.getKeyValue(opts.key, onSuccess, onError);
try {
rateLimit();
Applab.storage.getKeyValue(opts.key, onSuccess, onError);
} catch (e) {
outputError(e.message);
}
};

applabCommands.handleReadValue = function (opts, value) {
Expand All @@ -1796,7 +1807,12 @@ applabCommands.getKeyValueSync = function (opts) {
apiValidateType(opts, 'getKeyValueSync', 'key', opts.key, 'string');
var onSuccess = handleGetKeyValueSync.bind(this, opts);
var onError = handleGetKeyValueSyncError.bind(this, opts);
Applab.storage.getKeyValue(opts.key, onSuccess, onError);
try {
rateLimit();
Applab.storage.getKeyValue(opts.key, onSuccess, onError);
} catch (e) {
outputError(e.message);
}
};

var handleGetKeyValueSync = function (opts, value) {
Expand Down Expand Up @@ -1831,7 +1847,12 @@ applabCommands.setKeyValue = function (opts) {
);
var onSuccess = applabCommands.handleSetKeyValue.bind(this, opts);
var onError = opts.onError || getAsyncOutputWarning();
Applab.storage.setKeyValue(opts.key, opts.value, onSuccess, onError);
try {
rateLimit();
Applab.storage.setKeyValue(opts.key, opts.value, onSuccess, onError);
} catch (e) {
outputError(e.message);
}
};

applabCommands.handleSetKeyValue = function (opts) {
Expand All @@ -1845,7 +1866,12 @@ applabCommands.setKeyValueSync = function (opts) {
apiValidateType(opts, 'setKeyValueSync', 'value', opts.value, 'primitive');
var onSuccess = handleSetKeyValueSync.bind(this, opts);
var onError = handleSetKeyValueSyncError.bind(this, opts);
Applab.storage.setKeyValue(opts.key, opts.value, onSuccess, onError);
try {
rateLimit();
Applab.storage.setKeyValue(opts.key, opts.value, onSuccess, onError);
} catch (e) {
outputError(e.message);
}
};

var handleSetKeyValueSync = function (opts) {
Expand All @@ -1862,13 +1888,17 @@ var handleSetKeyValueSyncError = function (opts, message) {
applabCommands.getColumn = function (opts) {
apiValidateType(opts, 'getColumn', 'table', opts.table, 'string');
apiValidateType(opts, 'getColumn', 'column', opts.column, 'string');

Applab.storage.getColumn(
opts.table,
opts.column,
handleGetColumn.bind(this, opts),
handleGetColumnError.bind(this, opts)
);
try {
rateLimit();
Applab.storage.getColumn(
opts.table,
opts.column,
handleGetColumn.bind(this, opts),
handleGetColumnError.bind(this, opts)
);
} catch (e) {
outputError(e.message);
}
};

var handleGetColumn = function (opts, columnValues) {
Expand Down Expand Up @@ -1917,7 +1947,17 @@ applabCommands.readRecords = function (opts) {
}
var onSuccess = applabCommands.handleReadRecords.bind(this, opts);
var onError = opts.onError || getAsyncOutputWarning();
Applab.storage.readRecords(opts.table, opts.searchParams, onSuccess, onError);
try {
rateLimit();
Applab.storage.readRecords(
opts.table,
opts.searchParams,
onSuccess,
onError
);
} catch (e) {
outputError(e.message);
}
};

applabCommands.handleReadRecords = function (opts, records) {
Expand Down Expand Up @@ -1979,7 +2019,12 @@ applabCommands.updateRecord = function (opts) {
}
var onComplete = applabCommands.handleUpdateRecord.bind(this, opts);
var onError = opts.onError || getAsyncOutputWarning();
Applab.storage.updateRecord(opts.table, opts.record, onComplete, onError);
try {
rateLimit();
Applab.storage.updateRecord(opts.table, opts.record, onComplete, onError);
} catch (e) {
outputError(e.message);
}
};

applabCommands.handleUpdateRecord = function (opts, record, success) {
Expand Down Expand Up @@ -2037,7 +2082,12 @@ applabCommands.deleteRecord = function (opts) {
}
var onComplete = applabCommands.handleDeleteRecord.bind(this, opts);
var onError = opts.onError || getAsyncOutputWarning();
Applab.storage.deleteRecord(opts.table, opts.record, onComplete, onError);
try {
rateLimit();
Applab.storage.deleteRecord(opts.table, opts.record, onComplete, onError);
} catch (e) {
outputError(e.message);
}
};

applabCommands.handleDeleteRecord = function (opts, success) {
Expand Down Expand Up @@ -2229,16 +2279,21 @@ applabCommands.drawChartFromRecords = function (opts) {
outputError(error.message);
};

startLoadingSpinnerFor(opts.chartId);
chartApi
.drawChartFromRecords(
opts.chartId,
opts.chartType,
opts.tableName,
opts.columns,
opts.options
)
.then(onSuccess, onError);
try {
rateLimit();
startLoadingSpinnerFor(opts.chartId);
chartApi
.drawChartFromRecords(
opts.chartId,
opts.chartType,
opts.tableName,
opts.columns,
opts.options
)
.then(onSuccess, onError);
} catch (e) {
outputError(e.message);
}
};

/**
Expand Down
35 changes: 35 additions & 0 deletions apps/src/storage/rateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// rateLimit() throws an error if called more than RATE_LIMIT times per RATE_LIMIT_INTERVAL_MS
// we use this in Applab commands.js to rate limit access to Datablock Storage from student code
let rateLimitAccessLog = [];
export const RATE_LIMIT = 600;
export const RATE_LIMIT_INTERVAL_MS = 60000;
export function rateLimit(now = Date.now()) {
const timeSinceEarliestLog = () => now - rateLimitAccessLog[0];

// Drop log entries older than RATE_LIMIT_INTERVAL_MS
while (
rateLimitAccessLog.length > 0 &&
timeSinceEarliestLog() >= RATE_LIMIT_INTERVAL_MS
) {
rateLimitAccessLog.shift();
}

// If we're over the rate limit, throw an error
if (rateLimitAccessLog.length >= RATE_LIMIT) {
const waitTime = Math.ceil(
(RATE_LIMIT_INTERVAL_MS - timeSinceEarliestLog()) / 1000
);
throw new Error(
`Data access rate limit exceeded; Please wait ${waitTime} seconds before retrying. ` +
'The app is reading/writing data too many times per second. ' +
"If you were trying to write data, it wasn't written."
);
} else {
// Log the current access
rateLimitAccessLog.push(now);
}
}

export function resetRateLimit() {
rateLimitAccessLog = [];
}
57 changes: 57 additions & 0 deletions apps/test/unit/storage/DatablockStorageTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {expect} from '../../util/reconfiguredChai';
import {
rateLimit,
resetRateLimit,
RATE_LIMIT,
RATE_LIMIT_INTERVAL_MS,
} from '../../../src/storage/rateLimit';

describe('DatablockStorage', () => {
beforeEach(() => {
resetRateLimit();
});
afterEach(() => {
resetRateLimit();
});

describe('rate limiting', () => {
it('succeeds if calling less times than the rate limit', done => {
const now = Date.now();

for (let i = 0; i < RATE_LIMIT; i++) {
const time = now + i;
rateLimit(time);
}

done();
});
it('fails if called one more time than the rate limit', done => {
const now = Date.now();

for (let i = 0; i < RATE_LIMIT; i++) {
const time = now + i;
rateLimit(time);
}

// This should be over the rate limit
expect(() => rateLimit(now + RATE_LIMIT)).to.throw(Error);

done();
});
it('it succeeds if called more than the rate limit, but after waiting rate limit interval', done => {
let now = Date.now();

for (let i = 0; i < RATE_LIMIT; i++) {
const time = now + i;
rateLimit(time);
}

now += RATE_LIMIT_INTERVAL_MS;
for (let i = 0; i < RATE_LIMIT; i++) {
const time = now + i;
rateLimit(time);
}
done();
});
});
});

0 comments on commit 71d43f4

Please sign in to comment.