From 9b58afc169f894a1e09dfb45ca9d09adc71bfdf0 Mon Sep 17 00:00:00 2001 From: sundeep8967 Date: Thu, 24 Sep 2026 01:52:15 +0530 Subject: [PATCH] fix(files_sharing): show clearer message when share creation is rate limited Handle HTTP 429 response when creating shares by displaying a dedicated translated rate-limiting message instead of a generic failure notice. Signed-off-by: sundeep8967 --- .../src/components/NewFileRequestDialog.vue | 15 ++- .../src/components/SharingEntryLink.vue | 7 + .../files_sharing/src/mixins/ShareRequests.js | 16 ++- .../src/mixins/ShareRequests.spec.ts | 120 ++++++++++++++++++ 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 apps/files_sharing/src/mixins/ShareRequests.spec.ts diff --git a/apps/files_sharing/src/components/NewFileRequestDialog.vue b/apps/files_sharing/src/components/NewFileRequestDialog.vue index 24de10b725e9b..e464196b22b0b 100644 --- a/apps/files_sharing/src/components/NewFileRequestDialog.vue +++ b/apps/files_sharing/src/components/NewFileRequestDialog.vue @@ -332,10 +332,17 @@ export default defineComponent({ // Move to the last page this.currentStep = STEP.LAST } catch (error) { - const errorMessage = (error as AxiosError)?.response?.data?.ocs?.meta?.message - showError(errorMessage - ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage }) - : t('files_sharing', 'Error creating the share')) + const axiosError = error as AxiosError + let errorMessage: string + if (axiosError?.response?.status === 429) { + errorMessage = t('files_sharing', 'Share creation is temporarily rate limited. Please wait a few minutes before creating more shares.') + } else { + const message = axiosError?.response?.data?.ocs?.meta?.message + errorMessage = message + ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage: message }) + : t('files_sharing', 'Error creating the share') + } + showError(errorMessage) logger.error('Error while creating share', { error, errorMessage }) throw error } finally { diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue index e2bb0e4e48f72..3d47383b62b06 100644 --- a/apps/files_sharing/src/components/SharingEntryLink.vue +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -779,6 +779,13 @@ export default { } showSuccess(t('files_sharing', 'Link share created')) } catch (data) { + if (data?.cause?.response?.status === 429 || data?.response?.status === 429) { + const rateLimitMessage = t('files_sharing', 'Share creation is temporarily rate limited. Please wait a few minutes before creating more shares.') + showError(rateLimitMessage) + logger.error('Rate limit reached while creating the share', { error: data }) + return + } + const message = data?.response?.data?.ocs?.meta?.message if (!message) { showError(t('files_sharing', 'Error while creating the share')) diff --git a/apps/files_sharing/src/mixins/ShareRequests.js b/apps/files_sharing/src/mixins/ShareRequests.js index 4206a0135ec02..434bed53ccd21 100644 --- a/apps/files_sharing/src/mixins/ShareRequests.js +++ b/apps/files_sharing/src/mixins/ShareRequests.js @@ -6,6 +6,7 @@ import axios, { isAxiosError } from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' import { generateOcsUrl } from '@nextcloud/router' import Share from '../models/Share.ts' import logger from '../services/logger.ts' @@ -101,11 +102,16 @@ export default { * @return {string|undefined} the error message if it could be extracted from the response, otherwise undefined */ function getErrorMessage(error) { - if (isAxiosError(error) && error.response.data?.ocs) { - /** @type {import('@nextcloud/typings/ocs').OCSResponse} */ - const response = error.response.data - if (response.ocs.meta?.message) { - return response.ocs.meta.message + if (isAxiosError(error)) { + if (error.response?.status === 429) { + return t('files_sharing', 'Share creation is temporarily rate limited. Please wait a few minutes before creating more shares.') + } + if (error.response?.data?.ocs) { + /** @type {import('@nextcloud/typings/ocs').OCSResponse} */ + const response = error.response.data + if (response.ocs.meta?.message) { + return response.ocs.meta.message + } } } } diff --git a/apps/files_sharing/src/mixins/ShareRequests.spec.ts b/apps/files_sharing/src/mixins/ShareRequests.spec.ts new file mode 100644 index 0000000000000..e48a51e1492ff --- /dev/null +++ b/apps/files_sharing/src/mixins/ShareRequests.spec.ts @@ -0,0 +1,120 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { AxiosError } from 'axios' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { showError } = vi.hoisted(() => ({ + showError: vi.fn(), +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showError, +})) + +vi.mock(import('@nextcloud/event-bus'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + emit: vi.fn(), + } +}) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn().mockReturnValue('/ocs/v2.php/apps/files_sharing/api/v1/shares'), +})) + +vi.mock('../models/Share.ts', () => ({ + default: vi.fn().mockImplementation(function(data) { + Object.assign(this, data) + this.id = data?.id ?? 1 + }), +})) + +vi.mock('../services/logger.ts', () => ({ + default: { error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})) + +import axios from '@nextcloud/axios' +import ShareRequests from './ShareRequests.js' + +describe('ShareRequests mixin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('createShare', () => { + it('creates a share successfully', async () => { + const shareData = { id: 123, path: '/test.txt' } + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { ocs: { data: shareData } }, + } as never) + + const result = await ShareRequests.methods.createShare({ + path: '/test.txt', + shareType: 3, + }) + + expect(result).toMatchObject(shareData) + expect(showError).not.toHaveBeenCalled() + }) + + it('shows a dedicated rate limit message on HTTP 429 response', async () => { + const axiosError = new AxiosError('Too Many Requests') + axiosError.response = { + status: 429, + statusText: 'Too Many Requests', + headers: {}, + config: {} as never, + data: {}, + } + vi.spyOn(axios, 'post').mockRejectedValueOnce(axiosError) + + await expect(ShareRequests.methods.createShare({ + path: '/test.txt', + shareType: 3, + })).rejects.toThrow('Share creation is temporarily rate limited. Please wait a few minutes before creating more shares.') + + expect(showError).toHaveBeenCalledWith('Share creation is temporarily rate limited. Please wait a few minutes before creating more shares.') + }) + + it('shows the backend error message if provided on non-429 failure', async () => { + const axiosError = new AxiosError('Bad Request') + axiosError.response = { + status: 400, + statusText: 'Bad Request', + headers: {}, + config: {} as never, + data: { + ocs: { + meta: { + message: 'Custom backend validation failed', + }, + }, + }, + } + vi.spyOn(axios, 'post').mockRejectedValueOnce(axiosError) + + await expect(ShareRequests.methods.createShare({ + path: '/test.txt', + shareType: 3, + })).rejects.toThrow('Custom backend validation failed') + + expect(showError).toHaveBeenCalledWith('Custom backend validation failed') + }) + + it('falls back to default error message when no specific message is available', async () => { + const genericError = new Error('Network failure') + vi.spyOn(axios, 'post').mockRejectedValueOnce(genericError) + + await expect(ShareRequests.methods.createShare({ + path: '/test.txt', + shareType: 3, + })).rejects.toThrow('Error creating the share') + + expect(showError).toHaveBeenCalledWith('Error creating the share') + }) + }) +})