From 82467ed76ad31fe3b3a529a7d1fef8c72807e2ff Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Wed, 13 May 2026 18:25:25 +0200 Subject: [PATCH] feat(frontend-api): show error message to user on rate-limit exceeded Fixes #6472 Signed-off-by: Erik Michelson --- frontend/locales/en.json | 4 ++++ frontend/src/api/common/api-error.ts | 12 +++++++++++ .../api-request-builder.ts | 8 ++++++- frontend/src/api/common/api-response.ts | 8 ------- .../application-loader-error.ts | 10 +++++++-- .../application-loader/application-loader.tsx | 2 +- .../initializers/login-or-register-guest.ts | 5 +++++ .../notes-list/notes-list.tsx | 17 ++++++++++++--- .../local-login/register/register-error.tsx | 21 ++++++++++++------- .../login-page/username-password-login.tsx | 15 +++++++++++-- 10 files changed, 78 insertions(+), 24 deletions(-) diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 39566c97f..2322665e1 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -610,6 +610,10 @@ "noteCreationFailed": { "title": "Note could not be created", "description": "The note could not be created. Maybe you don't have permission to create notes." + }, + "rateLimitExceeded": { + "title": "Rate limit exceeded", + "description": "You have made too many requests in short time. Please wait a moment and try again {{resetIn}}." } }, "notifications": { diff --git a/frontend/src/api/common/api-error.ts b/frontend/src/api/common/api-error.ts index b59b7fdf6..f80f6bd46 100644 --- a/frontend/src/api/common/api-error.ts +++ b/frontend/src/api/common/api-error.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { DateTime } from 'luxon' + export class ApiError extends Error { constructor( public readonly statusCode: number, @@ -13,3 +15,13 @@ export class ApiError extends Error { super() } } + +export class RateLimitError extends Error { + constructor(public readonly retryAfterSeconds: number) { + super() + } + + getResetIn(): string { + return DateTime.local().plus({ seconds: this.retryAfterSeconds }).toRelative() + } +} diff --git a/frontend/src/api/common/api-request-builder/api-request-builder.ts b/frontend/src/api/common/api-request-builder/api-request-builder.ts index 8e7257aea..21100ad10 100644 --- a/frontend/src/api/common/api-request-builder/api-request-builder.ts +++ b/frontend/src/api/common/api-request-builder/api-request-builder.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ApiError } from '../api-error' +import { ApiError, RateLimitError } from '../api-error' import type { ApiErrorResponse } from '../api-error-response' import { ApiResponse } from '../api-response' import { defaultConfig, defaultHeaders } from '../default-config' @@ -70,6 +70,12 @@ export abstract class ApiRequestBuilder { body: this.requestBody }) + if (response.status === 429) { + // Default to 5 minutes if no header response received + const rateLimitResetSeconds = Number.parseInt(response.headers.get('RateLimit-Reset') ?? '300') + throw new RateLimitError(rateLimitResetSeconds) + } + if (response.status >= 400) { const backendError = await this.readApiErrorResponseFromBody(response) throw new ApiError(response.status, backendError?.name, backendError?.message) diff --git a/frontend/src/api/common/api-response.ts b/frontend/src/api/common/api-response.ts index 7652b7aec..59269714a 100644 --- a/frontend/src/api/common/api-response.ts +++ b/frontend/src/api/common/api-response.ts @@ -28,14 +28,6 @@ export class ApiResponse { return this.response } - static isSuccessfulResponse(response: Response): boolean { - return response.status >= 400 - } - - isSuccessful(): boolean { - return ApiResponse.isSuccessfulResponse(this.response) - } - /** * Returns the response as parsed JSON. An error will be thrown if the response is not JSON encoded. * diff --git a/frontend/src/components/application-loader/application-loader-error.ts b/frontend/src/components/application-loader/application-loader-error.ts index 355ebbf6b..a79d019fa 100644 --- a/frontend/src/components/application-loader/application-loader-error.ts +++ b/frontend/src/components/application-loader/application-loader-error.ts @@ -3,11 +3,17 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { RateLimitError } from '../../api/common/api-error' + /** * Custom {@link Error} class for the {@link ApplicationLoader}. */ export class ApplicationLoaderError extends Error { - constructor(taskName: string) { - super(`The task ${taskName} failed`) + constructor(taskName: string, originalError: unknown) { + let message = `The task ${taskName} failed` + if (originalError instanceof RateLimitError) { + message += `: You are being rate limited. Please try again ${originalError.getResetIn()}` + } + super(message) } } diff --git a/frontend/src/components/application-loader/application-loader.tsx b/frontend/src/components/application-loader/application-loader.tsx index aa9161216..1cc6d834a 100644 --- a/frontend/src/components/application-loader/application-loader.tsx +++ b/frontend/src/components/application-loader/application-loader.tsx @@ -28,7 +28,7 @@ export const ApplicationLoader: React.FC = ({ children }) => await task.task() } catch (reason: unknown) { log.error('Error while initialising application', reason) - throw new ApplicationLoaderError(task.name) + throw new ApplicationLoaderError(task.name, reason) } } }, []) diff --git a/frontend/src/components/application-loader/initializers/login-or-register-guest.ts b/frontend/src/components/application-loader/initializers/login-or-register-guest.ts index 34010a2b3..8237fcd5b 100644 --- a/frontend/src/components/application-loader/initializers/login-or-register-guest.ts +++ b/frontend/src/components/application-loader/initializers/login-or-register-guest.ts @@ -8,6 +8,7 @@ import { logInGuest, registerGuest } from '../../../api/auth/guest' import { store } from '../../../redux' import { fetchAndSetUser } from '../../login-page/utils/fetch-and-set-user' import { Logger } from '../../../utils/logger' +import { RateLimitError } from '../../../api/common/api-error' const logger = new Logger('LoginOrRegisterGuest') @@ -19,6 +20,7 @@ const logger = new Logger('LoginOrRegisterGuest') * The uuid is stored in local storage afterward. * * @param ignoreSavedUuid If true, the function will not check for a saved guest uuid in local storage + * @throws RateLimitError if the rate limit is exceeded back to the application-loader */ export const loginOrRegisterGuest = async (ignoreSavedUuid?: boolean): Promise => { const userState = store.getState().user @@ -34,6 +36,9 @@ export const loginOrRegisterGuest = async (ignoreSavedUuid?: boolean): Promise { + if (error instanceof RateLimitError) { + throw error + } logger.error('Error logging in guest user', error) return loginOrRegisterGuest(true) }) diff --git a/frontend/src/components/explore-page/explore-notes-section/notes-list/notes-list.tsx b/frontend/src/components/explore-page/explore-notes-section/notes-list/notes-list.tsx index 0d2531c6c..4c1d23dc0 100644 --- a/frontend/src/components/explore-page/explore-notes-section/notes-list/notes-list.tsx +++ b/frontend/src/components/explore-page/explore-notes-section/notes-list/notes-list.tsx @@ -15,6 +15,7 @@ import { useUiNotifications } from '../../../notifications/ui-notification-bound import { useApplicationState } from '../../../../hooks/common/use-application-state' import equal from 'fast-deep-equal' import styles from './note-entry.module.css' +import { RateLimitError } from '../../../../api/common/api-error' export interface NotesListProps { mode: Mode @@ -33,7 +34,7 @@ export interface NotesListProps { */ export const NotesList: React.FC = ({ mode, sort, searchFilter, typeFilter }) => { const [entries, setEntries] = useState([]) - const { showErrorNotificationBuilder } = useUiNotifications() + const { showErrorNotificationBuilder, dispatchUiNotification } = useUiNotifications() const [moreDataAvailable, setMoreDataAvailable] = useState(true) const lastPage = useRef(0) const lastFilters = useRef({}) @@ -58,9 +59,19 @@ export const NotesList: React.FC = ({ mode, sort, searchFilter, setEntries((prev) => [...prev, ...data]) } }) - .catch(showErrorNotificationBuilder('explore.errorLoadingEntries')) + .catch((error: unknown) => { + if (error instanceof RateLimitError) { + dispatchUiNotification('errors.rateLimitExceeded.title', 'errors.rateLimitExceeded.description', { + contentI18nOptions: { + resetIn: error.getResetIn() + } + }) + return + } + showErrorNotificationBuilder('explore.errorLoadingEntries')(error as Error) + }) }, - [mode, sort, searchFilter, typeFilter, showErrorNotificationBuilder] + [mode, sort, searchFilter, typeFilter, showErrorNotificationBuilder, dispatchUiNotification] ) const updateExplorePage = useCallback(() => { diff --git a/frontend/src/components/login-page/local-login/register/register-error.tsx b/frontend/src/components/login-page/local-login/register/register-error.tsx index 08030befb..a61cebf56 100644 --- a/frontend/src/components/login-page/local-login/register/register-error.tsx +++ b/frontend/src/components/login-page/local-login/register/register-error.tsx @@ -6,29 +6,36 @@ import { ErrorToI18nKeyMapper } from '../../../../api/common/error-to-i18n-key-mapper' import React, { useMemo } from 'react' import { Alert } from 'react-bootstrap' -import { Trans, useTranslation } from 'react-i18next' +import { useTranslation } from 'react-i18next' +import { RateLimitError } from '../../../../api/common/api-error' interface RegisterErrorProps { error?: Error } export const RegisterError: React.FC = ({ error }) => { - useTranslation() + const { t } = useTranslation() - const errorI18nKey = useMemo(() => { + const errorMessage = useMemo(() => { if (!error) { return null } - return new ErrorToI18nKeyMapper(error, 'login.register.error') + if (error instanceof RateLimitError) { + return t('errors.rateLimitExceeded.description', { + resetIn: error.getResetIn() + }) + } + const i18nKey = new ErrorToI18nKeyMapper(error, 'login.register.error') .withHttpCode(409, 'usernameExisting') .withBackendErrorName('FeatureDisabledError', 'registrationDisabled') .withBackendErrorName('PasswordTooWeakError', 'passwordTooWeak') .orFallbackI18nKey('other') - }, [error]) + return t(i18nKey) + }, [error, t]) return ( - - + + {errorMessage} ) } diff --git a/frontend/src/components/login-page/username-password-login.tsx b/frontend/src/components/login-page/username-password-login.tsx index 9019f06a5..069208bcd 100644 --- a/frontend/src/components/login-page/username-password-login.tsx +++ b/frontend/src/components/login-page/username-password-login.tsx @@ -13,6 +13,7 @@ import { UsernameField } from '../common/fields/username-field' import { PasswordField } from './password-field' import { Alert, Card, Form } from 'react-bootstrap' import { ActionButton } from '../common/action-button' +import { RateLimitError } from '../../api/common/api-error' export interface UsernamePasswordLoginProps { authProviderIdentifier?: string @@ -71,11 +72,21 @@ export const UsernamePasswordLogin: React.FC = ({ return fetchAndSetUser() } }) - .catch((error: Error) => setError(errorMapping(error))) + .catch((error: Error) => { + if (error instanceof RateLimitError) { + setError( + t('errors.rateLimitExceeded.description', { + resetIn: error.getResetIn() + }) + ) + } else { + setError(errorMapping(error)) + } + }) .finally(() => setLoading(false)) event.preventDefault() }, - [username, password, authProviderIdentifier, router, doLogin, errorMapping] + [username, password, authProviderIdentifier, router, doLogin, errorMapping, t] ) const onUsernameChange = useOnInputChange(setUsername)