Add 413 error message handler to browser extension (#1786)

This commit is contained in:
Leendert de Borst
2026-04-24 20:59:30 +02:00
committed by Leendert de Borst
parent 38b0c866a6
commit 01bf953d9c
9 changed files with 158 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ import { getItemWithFallback } from '@/utils/StorageUtility';
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
import { AppErrorCode, formatErrorWithCode } from '@/utils/types/errors/AppErrorCodes';
import { NetworkError } from '@/utils/types/errors/NetworkError';
import { PayloadTooLargeError } from '@/utils/types/errors/PayloadTooLargeError';
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
import type { DuplicateCheckResponse } from '@/utils/types/messaging/DuplicateCheckResponse';
@@ -682,13 +683,22 @@ export async function handleUploadVault(
} catch (error) {
console.error('Failed to upload vault:', error);
// Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault
const errorMessage = error instanceof Error ? error.message : '';
// Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault
if (errorMessage.includes('E-802')) {
// Return status 2 (Outdated) so caller can handle merge
return { success: false, status: 2, error: errorMessage };
}
/*
* Pass through any error already tagged with an E-XXX code (e.g. E-804 for HTTP 413).
* Stripping the targeted code and replacing it with E-801 would lose the actionable message.
*/
if (/E-\d{3}/.test(errorMessage)) {
return { success: false, error: errorMessage };
}
// E-801: Upload failed for other reasons
return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.UPLOAD_FAILED) };
}
@@ -810,7 +820,15 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
};
const webApi = new WebApiService();
const response = await webApi.post<Vault, VaultPostResponse>('Vault', newVault);
let response: VaultPostResponse;
try {
response = await webApi.post<Vault, VaultPostResponse>('Vault', newVault);
} catch (err) {
if (err instanceof PayloadTooLargeError) {
throw new Error(formatErrorWithCode(await t('common.errors.vaultTooLarge'), AppErrorCode.UPLOAD_TOO_LARGE));
}
throw err;
}
// Check if response is successful (.status === 0)
if (response.status === 0) {
@@ -1111,11 +1129,47 @@ export async function handleCheckSyncStatus(): Promise<SyncStatusCheckResult> {
}
}
/**
* Persists a sync error message to local storage so the popup can surface it
* even when the failing sync was triggered from the background (e.g. follow-up
* syncs after pending mutations). Cleared on the next successful sync.
*
* Skips errors that already have dedicated UX:
* - requiresLogout: handled by the forced re-login flow
* - wasOffline: handled by the offline indicator
*/
async function persistSyncErrorState(result: FullVaultSyncResult): Promise<void> {
if (result.requiresLogout || result.wasOffline) {
return;
}
const errorMessage = result.errorKey
? await t('common.errors.' + result.errorKey)
: result.error;
if (errorMessage) {
await storage.setItem('local:lastSyncError', errorMessage);
} else if (result.success) {
await storage.removeItem('local:lastSyncError');
}
}
/**
* Full vault sync orchestration that runs entirely in background context.
* This ensures sync completes even if popup closes mid-operation.
* Wraps the internal implementation with sync-error persistence so the popup
* can show a targeted alert for failures even if it wasn't open at the time.
*/
export async function handleFullVaultSync(): Promise<FullVaultSyncResult> {
const result = await handleFullVaultSyncInternal();
await persistSyncErrorState(result);
return result;
}
/**
* Internal implementation of the full vault sync. Wrapped by handleFullVaultSync
* so the result can be persisted to local storage for the popup to surface.
*/
async function handleFullVaultSyncInternal(): Promise<FullVaultSyncResult> {
// Check if sync is already in progress
if (isSyncInProgress) {
// Mark that we need to sync again after current sync completes

View File

@@ -1,9 +1,12 @@
import React, { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Routes, Route, useLocation } from 'react-router-dom';
import { ClipboardCountdownBar } from '@/entrypoints/popup/components/ClipboardCountdownBar';
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
import Header from '@/entrypoints/popup/components/Layout/Header';
import { useDb } from '@/entrypoints/popup/context/DbContext';
/**
* Route configuration type.
@@ -32,6 +35,8 @@ type DefaultLayoutProps = {
const DefaultLayout: React.FC<DefaultLayoutProps> = ({ routes, headerButtons, message, children }) => {
const mainRef = useRef<HTMLElement>(null);
const location = useLocation();
const { t } = useTranslation();
const { syncError, clearSyncError } = useDb();
// Reset scroll position when route changes
useEffect(() => {
@@ -76,6 +81,16 @@ const DefaultLayout: React.FC<DefaultLayoutProps> = ({ routes, headerButtons, me
</main>
<BottomNav />
<Modal
isOpen={syncError !== null}
onClose={clearSyncError}
onConfirm={clearSyncError}
title={t('sync.syncErrorTitle')}
message={syncError ?? ''}
confirmText={t('sync.syncErrorDismiss')}
variant="danger"
/>
</div>
);
};

View File

@@ -76,6 +76,16 @@ type DbContextType = {
*/
refreshSyncState: () => Promise<void>;
hasPendingMigrations: () => Promise<boolean>;
/**
* Last sync error message persisted by the background sync. Surfaced as a popup
* alert. Null when no error is pending. Updated reactively via storage.watch so
* background-initiated sync failures show up immediately while popup is open.
*/
syncError: string | null;
/**
* Dismiss the current sync error (clears both React state and persisted storage).
*/
clearSyncError: () => Promise<void>;
}
const DbContext = createContext<DbContextType | undefined>(undefined);
@@ -126,6 +136,12 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
*/
const [serverRevision, setServerRevision] = useState(0);
/**
* Last sync error written by the background sync. Driven by storage so background-only
* syncs (e.g. follow-up syncs after pending mutations) reach the user.
*/
const [syncError, setSyncError] = useState<string | null>(null);
/**
* Check if email errors should be suppressed.
* Errors are suppressed when vault has local changes not yet synced,
@@ -153,19 +169,42 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
* Load the offline mode and sync state from local storage.
*/
const loadSyncState = async (): Promise<void> => {
const [offlineMode, dirty, revision] = await Promise.all([
const [offlineMode, dirty, revision, lastError] = await Promise.all([
storage.getItem('local:isOfflineMode') as Promise<boolean | null>,
storage.getItem('local:isDirty') as Promise<boolean | null>,
storage.getItem('local:serverRevision') as Promise<number | null>
storage.getItem('local:serverRevision') as Promise<number | null>,
storage.getItem('local:lastSyncError') as Promise<string | null>
]);
isOfflineRef.current = offlineMode ?? false;
setIsOfflineState(offlineMode ?? false);
setIsDirty(dirty ?? false);
setServerRevision(revision ?? 0);
setSyncError(lastError ?? null);
};
loadSyncState();
}, []);
/**
* Subscribe to background-driven sync error updates so a popup alert appears
* even when the failing sync wasn't triggered by anything in the popup itself.
*/
useEffect(() => {
const unwatch = storage.watch<string | null>('local:lastSyncError', (newValue) => {
setSyncError(newValue ?? null);
});
return (): void => {
unwatch();
};
}, []);
/**
* Dismiss the current sync error from both React state and persisted storage.
*/
const clearSyncError = useCallback(async (): Promise<void> => {
setSyncError(null);
await storage.removeItem('local:lastSyncError');
}, []);
/**
* Load a decrypted vault into memory (SQLite client).
*/
@@ -325,7 +364,9 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
getVaultMetadata,
refreshSyncState,
hasPendingMigrations,
}), [sqliteClient, dbInitialized, dbAvailable, isOffline, getIsOffline, isDirty, isSyncing, isUploading, serverRevision, setIsOffline, shouldSuppressEmailErrors, loadDatabase, loadStoredDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, refreshSyncState, hasPendingMigrations]);
syncError,
clearSyncError,
}), [sqliteClient, dbInitialized, dbAvailable, isOffline, getIsOffline, isDirty, isSyncing, isUploading, serverRevision, setIsOffline, shouldSuppressEmailErrors, loadDatabase, loadStoredDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, refreshSyncState, hasPendingMigrations, syncError, clearSyncError]);
return (
<DbContext.Provider value={contextValue}>

View File

@@ -127,6 +127,18 @@ export function useVaultMutate(): {
*/
void sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async (result) => {
const syncResult = result as FullVaultSyncResult;
if (!syncResult.success && (syncResult.error || syncResult.errorKey)) {
/*
* Permanent failure (e.g. HTTP 413 vault too large). Stop polling and clear the upload
* spinner.
*/
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
dbContext.setIsUploading(false);
return;
}
if (syncResult.hasNewVault) {
await dbContext.loadStoredDatabase();
}

View File

@@ -122,8 +122,11 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro
onError?.(errorMessage);
return false;
} finally {
// Always clear syncing/uploading states when done
/*
* Always clear syncing/uploading states when done including on failure.
*/
dbContext.setIsSyncing(false);
dbContext.setIsUploading(false);
}
}, [app, dbContext, t]);

View File

@@ -108,6 +108,7 @@
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons.",
"syncConflictMaxRetries": "Could not sync vault after multiple attempts. Please try again later.",
"mergeFailed": "Failed to merge vault changes. Please try again.",
"vaultTooLarge": "The vault is too large for the server to accept. Try to remove some items or attachments to reduce the size and try again.",
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"wrongPassword": "Incorrect password. Please try again.",
@@ -546,6 +547,8 @@
},
"sync": {
"offline": "Offline",
"tapToRetry": "Tap to retry sync"
"tapToRetry": "Tap to retry sync",
"syncErrorTitle": "Sync Failed",
"syncErrorDismiss": "Dismiss"
}
}

View File

@@ -5,6 +5,7 @@ import { logoutEventEmitter } from '@/events/LogoutEventEmitter';
import { AppInfo } from "./AppInfo";
import { ApiAuthError } from './types/errors/ApiAuthError';
import { NetworkError } from './types/errors/NetworkError';
import { PayloadTooLargeError } from './types/errors/PayloadTooLargeError';
import { storage } from '#imports';
@@ -89,6 +90,10 @@ export class WebApiService {
}
}
if (response.status === 413 && throwOnError) {
throw new PayloadTooLargeError(`Request rejected with HTTP 413: payload exceeds server limit`);
}
if (!response.ok && throwOnError) {
throw new Error(`HTTP error! status: ${response.status}`);
}

View File

@@ -66,6 +66,7 @@ export enum AppErrorCode {
UPLOAD_FAILED = 'E-801',
UPLOAD_OUTDATED = 'E-802', // Server has newer version
UPLOAD_ENCRYPT_FAILED = 'E-803',
UPLOAD_TOO_LARGE = 'E-804', // Server rejected upload with HTTP 413 (vault exceeds MAX_UPLOAD_SIZE_MB)
// Migration/version errors (E-9xx)
MIGRATION_CHECK_FAILED = 'E-901',
@@ -159,6 +160,7 @@ export function getErrorTranslationKey(code: AppErrorCode): string {
[AppErrorCode.UPLOAD_FAILED]: 'common.errors.unknownErrorTryAgain',
[AppErrorCode.UPLOAD_OUTDATED]: 'common.errors.unknownErrorTryAgain',
[AppErrorCode.UPLOAD_ENCRYPT_FAILED]: 'common.errors.unknownErrorTryAgain',
[AppErrorCode.UPLOAD_TOO_LARGE]: 'common.errors.vaultTooLarge',
// Migration/version errors (E-9xx)
[AppErrorCode.MIGRATION_CHECK_FAILED]: 'common.errors.unknownErrorTryAgain',

View File

@@ -0,0 +1,15 @@
/**
* Thrown when the server rejects a request with HTTP 413 (Request Entity Too Large).
* For vault uploads this signals the encrypted vault exceeds the server's MAX_UPLOAD_SIZE_MB limit.
*/
export class PayloadTooLargeError extends Error {
/**
* Creates a new instance of PayloadTooLargeError.
*
* @param message - The error message.
*/
public constructor(message: string) {
super(message);
this.name = 'PayloadTooLargeError';
}
}