mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
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:
committed by
Philip Molares
parent
cdf66b00c5
commit
82467ed76a
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
+14
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user