diff --git a/addons/api/addon/adapters/application.js b/addons/api/addon/adapters/application.js
index af28c3bdb..6b57ed09a 100644
--- a/addons/api/addon/adapters/application.js
+++ b/addons/api/addon/adapters/application.js
@@ -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;
diff --git a/addons/api/addon/handlers/sqlite-handler.js b/addons/api/addon/handlers/sqlite-handler.js
index 0ab773491..1e976b334 100644
--- a/addons/api/addon/handlers/sqlite-handler.js
+++ b/addons/api/addon/handlers/sqlite-handler.js
@@ -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:
diff --git a/addons/api/ember-cli-build.js b/addons/api/ember-cli-build.js
index 993a8178d..b7150a965 100644
--- a/addons/api/ember-cli-build.js
+++ b/addons/api/ember-cli-build.js
@@ -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'),
+ ],
+ },
});
/*
diff --git a/addons/api/index.js b/addons/api/index.js
index d846a5b6c..b60adeddd 100644
--- a/addons/api/index.js
+++ b/addons/api/index.js
@@ -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'],
diff --git a/addons/api/package.json b/addons/api/package.json
index 03910020a..3612f1cab 100644
--- a/addons/api/package.json
+++ b/addons/api/package.json
@@ -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",
diff --git a/addons/api/tests/unit/adapters/application-test.js b/addons/api/tests/unit/adapters/application-test.js
index f313cbfd9..cd417067d 100644
--- a/addons/api/tests/unit/adapters/application-test.js
+++ b/addons/api/tests/unit/adapters/application-test.js
@@ -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 = {
diff --git a/addons/core/addon/components/error/message/index.js b/addons/core/addon/components/error/message/index.js
index 7c47fc7a5..2c27c5502 100644
--- a/addons/core/addon/components/error/message/index.js
+++ b/addons/core/addon/components/error/message/index.js
@@ -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
diff --git a/addons/core/tests/integration/components/error/message-test.js b/addons/core/tests/integration/components/error/message-test.js
index 43a8b2921..34fb7e837 100644
--- a/addons/core/tests/integration/components/error/message-test.js
+++ b/addons/core/tests/integration/components/error/message-test.js
@@ -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``);
+
+ 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) {
diff --git a/addons/core/translations/errors/en-us.yaml b/addons/core/translations/errors/en-us.yaml
index b58930be2..896f44771 100644
--- a/addons/core/translations/errors/en-us.yaml
+++ b/addons/core/translations/errors/en-us.yaml
@@ -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.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 59b462806..365906839 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/ui/admin/app/services/store.js b/ui/admin/app/services/store.js
new file mode 100644
index 000000000..17b4b87c2
--- /dev/null
+++ b/ui/admin/app/services/store.js
@@ -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;
+ });
+}
diff --git a/ui/admin/app/templates/application.hbs b/ui/admin/app/templates/application.hbs
index 460fed495..a93d7bb8e 100644
--- a/ui/admin/app/templates/application.hbs
+++ b/ui/admin/app/templates/application.hbs
@@ -185,6 +185,16 @@
>
{{flash.title}}
{{flash.message}}
+ {{#if flash.button}}
+
+ {{/if}}
{{/each}}
diff --git a/ui/admin/tests/unit/services/store-test.js b/ui/admin/tests/unit/services/store-test.js
new file mode 100644
index 000000000..717715217
--- /dev/null
+++ b/ui/admin/tests/unit/services/store-test.js
@@ -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));
+ });
+});