feat: 🎸 Handle rate limiting (#3257)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12035
This commit is contained in:
1 parent
de907284ff
commit
5048fd478a
13 files changed
+290
-9
No files matched your search
@@ -131,11 +131,34 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
// which indicates that there are more items in the list
|
||||
// or the batch size has not been reached
|
||||
do {
|
||||
result = await super.query(store, schema, queryObject);
|
||||
//add the result items to a data array
|
||||
try {
|
||||
result = await super.query(store, schema, queryObject);
|
||||
} catch (e) {
|
||||
// If we hit a rate limit mid-pagination, return whatever
|
||||
// data has been accumulated so far rather than throwing and leaving
|
||||
// the user stuck with nothing to show.
|
||||
// Without a list_token this is the very first request with nothing to
|
||||
// show, so rethrow and let normal error handling apply.
|
||||
if (e?.errors?.[0]?.isRateLimited && queryObject.list_token) {
|
||||
result ??= {};
|
||||
result.items = data;
|
||||
result.response_type = 'complete';
|
||||
result.meta = {
|
||||
...result.meta,
|
||||
rateLimitWarning: { retryAfter: e.errors[0].retryAfter },
|
||||
};
|
||||
return prenormalizeArrayResponse(result);
|
||||
}
|
||||
|
||||
// For all other errors (or a 429 on the very first request with
|
||||
// no data yet), rethrow so normal error handling applies.
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Add the result items to a data array
|
||||
if (result && result.items) {
|
||||
data.push(...result.items);
|
||||
//pass in the list token for subsequent calls to fetch the remaining list items
|
||||
// Pass in the list token for subsequent calls to fetch the remaining list items
|
||||
queryObject.list_token = result.list_token;
|
||||
}
|
||||
} while (
|
||||
@@ -237,6 +260,7 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
* - `403 isForbidden`: the session is authenticated but does not have
|
||||
* permission to perform the requested action
|
||||
* - `404 isNotFound`: the requested resource could not be found
|
||||
* - `429 isRateLimited`: the client has exceeded the rate limit
|
||||
* - `500 isServer`: an internal server error occurred
|
||||
* - `isUnknown`: an error occurred, but we don't know which or
|
||||
* we don't distinguish it yet
|
||||
@@ -248,7 +272,7 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
* @param {Object} payload
|
||||
* @return {Array} errors payload
|
||||
*/
|
||||
normalizeErrorResponse(/*status, headers, payload*/) {
|
||||
normalizeErrorResponse(status, headers /*, payload*/) {
|
||||
const errors = super.normalizeErrorResponse(...arguments);
|
||||
if (isArray(errors)) {
|
||||
errors.forEach((error) => {
|
||||
@@ -262,6 +286,14 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
|
||||
case 404:
|
||||
error.isNotFound = true;
|
||||
break;
|
||||
case 429:
|
||||
error.isRateLimited = true;
|
||||
// Attach rate-limit metadata from response headers so downstream
|
||||
// error handlers can inform the user when they may retry.
|
||||
error.retryAfter = Number(
|
||||
headers['Retry-After'] ?? headers['retry-after'],
|
||||
);
|
||||
break;
|
||||
case 500:
|
||||
error.isServer = true;
|
||||
break;
|
||||
|
||||
@@ -59,7 +59,8 @@ export default class SqliteHandler {
|
||||
let payload,
|
||||
listToken,
|
||||
writeToDbPromise,
|
||||
totalInsert = 0;
|
||||
totalInsert = 0,
|
||||
rateLimitWarning;
|
||||
|
||||
if (!peekDb) {
|
||||
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||
@@ -86,6 +87,11 @@ export default class SqliteHandler {
|
||||
});
|
||||
totalInsert += payload.items?.length ?? 0;
|
||||
|
||||
// Propagate any rate-limit warning the adapter attached
|
||||
if (payload.meta?.rateLimitWarning) {
|
||||
rateLimitWarning = payload.meta.rateLimitWarning;
|
||||
}
|
||||
|
||||
// await the previous writeToDbPromise before writing to db again
|
||||
if (writeToDbPromise) {
|
||||
await writeToDbPromise;
|
||||
@@ -163,7 +169,14 @@ export default class SqliteHandler {
|
||||
// This isn't conventional but is better than returning an ArrayProxy
|
||||
// or EmberArray since the ember store query method asserts it has to be an array
|
||||
// so we can't just return an object.
|
||||
records.meta = { totalItems: count[0].total };
|
||||
records.meta = {
|
||||
totalItems: count[0].total,
|
||||
};
|
||||
|
||||
if (rateLimitWarning) {
|
||||
records.meta.rateLimitWarning = rateLimitWarning;
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -9,7 +9,11 @@ const EmberAddon = require('ember-cli/lib/broccoli/ember-addon');
|
||||
|
||||
module.exports = function (defaults) {
|
||||
const app = new EmberAddon(defaults, {
|
||||
// Add options here
|
||||
babel: {
|
||||
plugins: [
|
||||
require.resolve('ember-concurrency/async-arrow-task-transform'),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -18,6 +18,11 @@ module.exports = {
|
||||
name: require('./package').name,
|
||||
|
||||
options: {
|
||||
babel: {
|
||||
plugins: [
|
||||
require.resolve('ember-concurrency/async-arrow-task-transform'),
|
||||
],
|
||||
},
|
||||
// Don't automatically import sqlite, we need to manually control how it gets used
|
||||
autoImport: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm'],
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"ember-can": "^8.0.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-concurrency": "^4.0.3",
|
||||
"ember-data": "~5.3.12",
|
||||
"ember-get-config": "^2.1.1",
|
||||
"ember-inflector": "^6.0.0",
|
||||
|
||||
@@ -175,11 +175,46 @@ module('Unit | Adapter | application', function (hooks) {
|
||||
assert.ok(getResponse(401).errors[0].isUnauthenticated);
|
||||
assert.ok(getResponse(403).errors[0].isForbidden);
|
||||
assert.ok(getResponse(404).errors[0].isNotFound);
|
||||
assert.ok(getResponse(429).errors[0].isRateLimited);
|
||||
assert.ok(getResponse(500).errors[0].isServer);
|
||||
assert.ok(getResponse(0).errors[0].isUnknown);
|
||||
assert.ok(getResponse(false).errors[0].isUnknown);
|
||||
});
|
||||
|
||||
test('it attaches retryAfter from Retry-After header on 429 responses', function (assert) {
|
||||
const adapter = this.owner.lookup('adapter:application');
|
||||
const headers = { 'Retry-After': '30' };
|
||||
const response = adapter.handleResponse(429, headers, { status: 429 });
|
||||
assert.ok(response.errors[0].isRateLimited);
|
||||
assert.strictEqual(response.errors[0].retryAfter, 30);
|
||||
});
|
||||
|
||||
test('it returns partial data when rate limited mid-pagination', async function (assert) {
|
||||
const adapter = this.owner.lookup('adapter:application');
|
||||
const store = this.owner.lookup('service:store');
|
||||
let requestCount = 0;
|
||||
this.server.get('/groups', () => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
return {
|
||||
items: [{ id: 'g_1', scope_id: 'p_1' }],
|
||||
response_type: 'delta',
|
||||
list_token: 'token123',
|
||||
};
|
||||
}
|
||||
// Second request returns a 429
|
||||
return new Response(429, { 'Retry-After': '10' }, { status: 429 });
|
||||
});
|
||||
|
||||
const result = await adapter.query(
|
||||
store,
|
||||
{ modelName: 'group' },
|
||||
{ scope_id: 'p_1' },
|
||||
);
|
||||
assert.strictEqual(result.items.length, 1);
|
||||
assert.strictEqual(result.meta.rateLimitWarning.retryAfter, 10);
|
||||
});
|
||||
|
||||
test('it returns field-level errors in InvalidError from handleResponse', function (assert) {
|
||||
const adapter = this.owner.lookup('adapter:application');
|
||||
const payload = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import Component from '@glimmer/component';
|
||||
* - 403 - `error.isForbidden`: the session is authenticated but does not have
|
||||
* permission to perform the requested action
|
||||
* - 404 - `error.isNotFound`: the requested resource could not be found
|
||||
* - 429 - `error.isRateLimited`: the client has exceeded the rate limit
|
||||
* - 500 - `error.isServer`: an internal server error occurred
|
||||
*
|
||||
* For an unknown error state, i.e error state not matching to the above defined list:
|
||||
@@ -19,7 +20,7 @@ import Component from '@glimmer/component';
|
||||
* we don't distinguish it yet
|
||||
*/
|
||||
|
||||
const statuses = ['401', '403', '404', '500'];
|
||||
const statuses = ['401', '403', '404', '429', '500'];
|
||||
|
||||
export default class ErrorMessageComponent extends Component {
|
||||
// =methods
|
||||
|
||||
@@ -52,6 +52,16 @@ module('Integration | Component | error/message', function (hooks) {
|
||||
.hasText(
|
||||
'We could not find the requested resource. You can ask your administrator or try again later.',
|
||||
);
|
||||
|
||||
await render(hbs`<Error::Message @status='429' />`);
|
||||
|
||||
assert.dom(ERROR_MSG_TITLE).hasText('Rate limit exceeded');
|
||||
assert.dom(ERROR_MSG_CODE).hasText('ERROR 429');
|
||||
assert
|
||||
.dom(ERROR_MSG_BODY)
|
||||
.hasText(
|
||||
"You've exceeded the rate limit. Please wait a moment and try again.",
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders default error for unknown error status', async function (assert) {
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
500:
|
||||
title: Server error
|
||||
description: We ran into a problem and could not continue. You can ask your administrator or try again later.
|
||||
429:
|
||||
title: Rate limit exceeded
|
||||
description: You've exceeded the rate limit. Please wait a moment and try again.
|
||||
description-partial: Currently showing partial results. You can reload for more in {seconds} {seconds, plural, one {second} other {seconds}}.
|
||||
description-ready: Currently showing partial results. You can reload now.
|
||||
unknown:
|
||||
title: Something went wrong
|
||||
description: We're not sure what happened. Please contact your administrator or try again later.
|
||||
|
||||
Generated
+4
-1
@@ -85,6 +85,9 @@ importers:
|
||||
ember-cli-htmlbars:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
ember-concurrency:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.4(@babel/core@7.27.1)(@glint/template@1.5.2)
|
||||
ember-data:
|
||||
specifier: ~5.3.12
|
||||
version: 5.3.13(@ember/string@4.0.1)(@ember/test-helpers@5.2.2(@babel/core@7.27.1)(@glint/template@1.5.2))(@ember/test-waiters@4.1.0(@glint/template@1.5.2))(@glint/template@1.5.2)(ember-inflector@6.0.0(@babel/core@7.27.1))(ember-source@5.12.0(@glimmer/component@2.0.0)(@glint/template@1.5.2)(rsvp@4.8.5)(webpack@5.105.4(esbuild@0.25.9)))(qunit@2.24.1)
|
||||
@@ -14306,7 +14309,7 @@ snapshots:
|
||||
'@babel/helper-module-imports': 7.27.1
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
'@babel/types': 7.27.1
|
||||
'@embroider/addon-shim': 1.10.0
|
||||
'@embroider/addon-shim': 1.10.2
|
||||
decorator-transforms: 1.2.1(@babel/core@7.27.1)
|
||||
optionalDependencies:
|
||||
'@glint/template': 1.5.2
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright IBM Corp. 2021, 2026
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Store from 'api/services/store';
|
||||
import { service } from '@ember/service';
|
||||
import { TrackedObject } from 'tracked-built-ins';
|
||||
import { dropTask, timeout } from 'ember-concurrency';
|
||||
|
||||
export default class extends Store {
|
||||
@service flashMessages;
|
||||
@service intl;
|
||||
@service router;
|
||||
|
||||
/**
|
||||
* Overrides store.query to detect a rate-limit warning and surface a
|
||||
* flash notification to the user. The partial result is still returned
|
||||
* normally so the UI renders whatever data was fetched.
|
||||
* @override
|
||||
*/
|
||||
async query(type, query, options) {
|
||||
const result = await super.query(type, query, options);
|
||||
|
||||
if (result?.meta?.rateLimitWarning) {
|
||||
const { retryAfter } = result.meta.rateLimitWarning;
|
||||
this._countdownTask.perform(retryAfter);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sticky flash notification and counts down each second until the
|
||||
* rate-limit window expires and drops any new ones that come in.
|
||||
*/
|
||||
_countdownTask = dropTask(async (retryAfter) => {
|
||||
// Use a TrackedObject for `button` so that mutations to `button.disabled`
|
||||
// are tracked and the template re-renders when the countdown finishes.
|
||||
const button = new TrackedObject({
|
||||
label: this.intl.t('actions.retry'),
|
||||
disabled: true,
|
||||
action: (flash) => {
|
||||
flash.destroyMessage();
|
||||
this.router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const flash = this.flashMessages
|
||||
.warning(
|
||||
this.intl.t('errors.429.description-partial', { seconds: retryAfter }),
|
||||
{
|
||||
color: 'warning',
|
||||
title: this.intl.t('errors.429.title'),
|
||||
sticky: true,
|
||||
dismiss: (flash) => {
|
||||
this._countdownTask.cancelAll();
|
||||
flash.destroyMessage();
|
||||
},
|
||||
button,
|
||||
},
|
||||
)
|
||||
.getFlashObject();
|
||||
|
||||
let remaining = retryAfter;
|
||||
|
||||
while (remaining > 0) {
|
||||
await timeout(1000);
|
||||
remaining -= 1;
|
||||
|
||||
flash.message =
|
||||
remaining > 0
|
||||
? this.intl.t('errors.429.description-partial', {
|
||||
seconds: remaining,
|
||||
})
|
||||
: this.intl.t('errors.429.description-ready');
|
||||
}
|
||||
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
@@ -185,6 +185,16 @@
|
||||
>
|
||||
<T.Title>{{flash.title}}</T.Title>
|
||||
<T.Description>{{flash.message}}</T.Description>
|
||||
{{#if flash.button}}
|
||||
<T.Button
|
||||
@text={{flash.button.label}}
|
||||
@icon={{flash.button.icon}}
|
||||
@color='secondary'
|
||||
@size='small'
|
||||
disabled={{flash.button.disabled}}
|
||||
{{on 'click' (fn flash.button.action flash)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</Hds::Toast>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright IBM Corp. 2021, 2026
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'admin/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
import Store from 'ember-data/store';
|
||||
|
||||
module('Unit | Service | store', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register(
|
||||
'service:flash-messages',
|
||||
class extends Service {
|
||||
warning() {
|
||||
return { getFlashObject: () => ({ message: null }) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.owner.register(
|
||||
'service:intl',
|
||||
class extends Service {
|
||||
t(key) {
|
||||
return key;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.owner.register(
|
||||
'service:router',
|
||||
class extends Service {
|
||||
refresh() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
test('it exists', function (assert) {
|
||||
const service = this.owner.lookup('service:store');
|
||||
assert.ok(service);
|
||||
});
|
||||
|
||||
test('query returns result and does not trigger countdown when there is no rate-limit warning', async function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const fakeResult = { items: [], meta: {} };
|
||||
|
||||
sinon.stub(Store.prototype, 'query').resolves(fakeResult);
|
||||
const performSpy = sinon.spy(store._countdownTask, 'perform');
|
||||
|
||||
const result = await store.query('group', {});
|
||||
|
||||
assert.deepEqual(result, fakeResult);
|
||||
assert.ok(performSpy.notCalled);
|
||||
});
|
||||
|
||||
test('query triggers _countdownTask with retryAfter when result has rateLimitWarning', async function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const fakeResult = {
|
||||
items: [{ id: 'g_1' }],
|
||||
meta: { rateLimitWarning: { retryAfter: 30 } },
|
||||
};
|
||||
|
||||
sinon.stub(Store.prototype, 'query').resolves(fakeResult);
|
||||
const performStub = sinon
|
||||
.stub(store._countdownTask, 'perform')
|
||||
.returns(Promise.resolve());
|
||||
|
||||
const result = await store.query('group', {});
|
||||
|
||||
assert.deepEqual(result, fakeResult);
|
||||
assert.ok(performStub.calledOnceWith(30));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user