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)); + }); +});