feat: 🎸 Handle rate limiting (#3257)

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12035
This commit is contained in:
Zhihe Li authored and GitHub committed 2026-05-14 15:21:07 -04:00
1 parent de907284ff
commit 5048fd478a
13 files changed
+290 -9

No files matched your search

+36 -4
View File
@@ -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;
+15 -2
View File
@@ -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:
+5 -1
View File
@@ -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'),
],
},
});
/*
+5
View File
@@ -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'],
+1
View File
@@ -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.
+4 -1
View File
@@ -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
+81
View File
@@ -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;
});
}
+10
View File
@@ -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));
});
});