feat(frontend-api): show error message to user on rate-limit exceeded

Fixes #6472

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-05-13 18:25:25 +02:00
committed by Philip Molares
parent cdf66b00c5
commit 82467ed76a
10 changed files with 78 additions and 24 deletions
+4
View File
@@ -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": {
+12
View File
@@ -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()
}
}
@@ -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<ResponseType> {
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)
-8
View File
@@ -28,14 +28,6 @@ export class ApiResponse<ResponseType> {
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.
*
@@ -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)
}
}
@@ -28,7 +28,7 @@ export const ApplicationLoader: React.FC<PropsWithChildren> = ({ 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)
}
}
}, [])
@@ -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<void> => {
const userState = store.getState().user
@@ -34,6 +36,9 @@ export const loginOrRegisterGuest = async (ignoreSavedUuid?: boolean): Promise<v
logInGuest(guestUuid)
.then(fetchAndSetUser)
.catch((error: unknown) => {
if (error instanceof RateLimitError) {
throw error
}
logger.error('Error logging in guest user', error)
return loginOrRegisterGuest(true)
})
@@ -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<NotesListProps> = ({ mode, sort, searchFilter, typeFilter }) => {
const [entries, setEntries] = useState<NoteExploreEntryInterface[]>([])
const { showErrorNotificationBuilder } = useUiNotifications()
const { showErrorNotificationBuilder, dispatchUiNotification } = useUiNotifications()
const [moreDataAvailable, setMoreDataAvailable] = useState(true)
const lastPage = useRef<number>(0)
const lastFilters = useRef({})
@@ -58,9 +59,19 @@ export const NotesList: React.FC<NotesListProps> = ({ 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(() => {
@@ -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<RegisterErrorProps> = ({ 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 (
<Alert className='small' show={!!errorI18nKey} variant='danger'>
<Trans i18nKey={errorI18nKey ?? ''} />
<Alert className='small' show={!!errorMessage} variant='danger'>
{errorMessage}
</Alert>
)
}
@@ -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<UsernamePasswordLoginProps> = ({
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)