diff --git a/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts b/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts
index 2bde2b5779..57715e8ec0 100644
--- a/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts
+++ b/packages/sync-providers/src/file-based/dropbox/dropbox-api.ts
@@ -528,10 +528,7 @@ export class DropboxApi {
if (!response.ok) {
const bodyStr = await response.text();
- throw new HttpNotOkAPIError(
- new Response(bodyStr, { status: response.status }),
- bodyStr,
- );
+ throw new HttpNotOkAPIError(response, bodyStr);
}
data = (await response.json()) as TokenResponse;
diff --git a/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts b/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts
index 82243aa5bb..5f41b23101 100644
--- a/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts
+++ b/packages/sync-providers/tests/file-based/dropbox/dropbox-api.spec.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AuthFailSPError,
+ HttpNotOkAPIError,
RemoteFileNotFoundAPIError,
TooManyRequestsAPIError,
UploadRevToMatchMismatchAPIError,
@@ -571,6 +572,37 @@ describe('DropboxApi', () => {
expect(body).toContain('grant_type=authorization_code');
});
+ it('preserves response metadata when the token exchange fails', async () => {
+ const headers = new Headers();
+ headers.set('X-Dropbox-Request-Id', 'dbx-request-123');
+ const response = new Response('gateway timeout', {
+ status: 504,
+ statusText: 'Gateway Timeout',
+ headers,
+ });
+ fetchSpy.mockResolvedValue(response);
+
+ let thrown: HttpNotOkAPIError | undefined;
+ try {
+ await dropboxApi.getTokensFromAuthCode(
+ 'test-auth-code',
+ 'test-code-verifier',
+ null,
+ );
+ } catch (error) {
+ if (error instanceof HttpNotOkAPIError) {
+ thrown = error;
+ }
+ }
+
+ expect(thrown).toBeDefined();
+ expect(thrown?.response).toBe(response);
+ expect(thrown?.message).toBe('HTTP 504 Gateway Timeout');
+ expect(thrown?.response.headers.get('X-Dropbox-Request-Id')).toBe(
+ 'dbx-request-123',
+ );
+ });
+
it('should throw error for invalid token response', async () => {
fetchSpy.mockResolvedValue({
ok: true,
diff --git a/src/app/imex/sync/sync-wrapper.service.spec.ts b/src/app/imex/sync/sync-wrapper.service.spec.ts
index 823757b1b8..fac9800aee 100644
--- a/src/app/imex/sync/sync-wrapper.service.spec.ts
+++ b/src/app/imex/sync/sync-wrapper.service.spec.ts
@@ -40,10 +40,14 @@ import {
EncryptNoPasswordError,
ForceUploadFailedError,
ForceUploadPendingOpsError,
+ HttpNotOkAPIError,
IncompleteRemoteOperationsError,
} from '../../op-log/core/errors/sync-errors';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
+import type { SyncProviderBase } from '../../op-log/sync-providers/provider.interface';
+import type { MatDialogRef } from '@angular/material/dialog';
+import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
describe('SyncWrapperService', () => {
let service: SyncWrapperService;
@@ -426,6 +430,79 @@ describe('SyncWrapperService', () => {
});
});
+ describe('configuredAuthForSyncProviderIfNecessary()', () => {
+ it('shows a temporary service error with the Dropbox request ID for HTTP 504', async () => {
+ const headers = new Headers();
+ headers.set('X-Dropbox-Request-Id', 'dbx-request-123&"');
+ const response = new Response('gateway timeout', {
+ status: 504,
+ statusText: 'Gateway Timeout',
+ headers,
+ });
+ const provider = {
+ id: SyncProviderId.Dropbox,
+ isReady: jasmine.createSpy('isReady').and.resolveTo(false),
+ getAuthHelper: jasmine.createSpy('getAuthHelper').and.resolveTo({
+ authUrl: 'https://www.dropbox.com/oauth2/authorize',
+ codeVerifier: 'code-verifier',
+ verifyCodeChallenge: jasmine
+ .createSpy('verifyCodeChallenge')
+ .and.rejectWith(new HttpNotOkAPIError(response, 'gateway timeout')),
+ }),
+ } as unknown as SyncProviderBase;
+ mockProviderManager.getProviderById.and.resolveTo(provider);
+ mockMatDialog.open.and.returnValue({
+ afterClosed: () => of('auth-code'),
+ } as unknown as MatDialogRef);
+
+ const result = await service.configuredAuthForSyncProviderIfNecessary(
+ SyncProviderId.Dropbox,
+ );
+
+ expect(result).toEqual({ wasConfigured: false });
+ expect(mockSnackService.open).toHaveBeenCalledWith({
+ msg: T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE,
+ translateParams: {
+ status: '504',
+ reference: '<b>dbx-request-123</b>&"',
+ },
+ type: 'ERROR',
+ config: { duration: 0 },
+ });
+ });
+
+ it('shows a temporary service error without a reference when none is returned', async () => {
+ const response = new Response('internal server error', {
+ status: 500,
+ statusText: 'Internal Server Error',
+ });
+ const provider = {
+ id: SyncProviderId.Dropbox,
+ isReady: jasmine.createSpy('isReady').and.resolveTo(false),
+ getAuthHelper: jasmine.createSpy('getAuthHelper').and.resolveTo({
+ authUrl: 'https://www.dropbox.com/oauth2/authorize',
+ codeVerifier: 'code-verifier',
+ verifyCodeChallenge: jasmine
+ .createSpy('verifyCodeChallenge')
+ .and.rejectWith(new HttpNotOkAPIError(response, 'internal server error')),
+ }),
+ } as unknown as SyncProviderBase;
+ mockProviderManager.getProviderById.and.resolveTo(provider);
+ mockMatDialog.open.and.returnValue({
+ afterClosed: () => of('auth-code'),
+ } as unknown as MatDialogRef);
+
+ await service.configuredAuthForSyncProviderIfNecessary(SyncProviderId.Dropbox);
+
+ expect(mockSnackService.open).toHaveBeenCalledWith({
+ msg: T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE,
+ translateParams: { status: '500' },
+ type: 'ERROR',
+ config: { duration: 0 },
+ });
+ });
+ });
+
describe('websocket integration', () => {
it('should disconnect websocket when provider changes away from SuperSync', async () => {
configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV));
diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts
index 514f87b3c9..34205dbb76 100644
--- a/src/app/imex/sync/sync-wrapper.service.ts
+++ b/src/app/imex/sync/sync-wrapper.service.ts
@@ -53,6 +53,7 @@ import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { T } from '../../t.const';
import { getSyncErrorStr } from './get-sync-error-str';
import { getErrorTxt } from '../../util/get-error-text';
+import { escapeHtml } from '../../util/escape-html';
import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
import { DialogConflictResolutionResult } from './sync.model';
import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component';
@@ -1151,7 +1152,16 @@ export class SyncWrapperService {
} catch (error) {
SyncLog.err(`Failed to configure auth for provider ${providerId}:`, error);
const httpErr = error instanceof HttpNotOkAPIError ? error : null;
- const isTokenExchangeError = httpErr?.response?.status === 400;
+ const httpStatus = httpErr?.response.status;
+ const isTokenExchangeError = httpStatus === 400;
+ const isAuthServiceUnavailable =
+ httpStatus !== undefined && httpStatus >= 500 && httpStatus < 600;
+ // Dropbox recommends retaining this response ID for support investigations.
+ // https://developers.dropbox.com/error-handling-guide#logging
+ const providerRequestId =
+ providerId === SyncProviderId.Dropbox
+ ? httpErr?.response.headers.get('X-Dropbox-Request-Id')
+ : null;
// A OneDrive token-exchange 400 is almost always a misconfigured
// Microsoft Entra app registration (typically "Allow public client
// flows" disabled), not a mistyped/expired code — the authorize step
@@ -1165,6 +1175,14 @@ export class SyncWrapperService {
translateParams = { error: httpErr?.detail || getErrorTxt(error) };
} else if (isTokenExchangeError) {
msg = T.F.SYNC.S.INVALID_AUTH_CODE;
+ } else if (isAuthServiceUnavailable) {
+ translateParams = { status: String(httpStatus) };
+ if (providerRequestId) {
+ msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE;
+ translateParams.reference = escapeHtml(providerRequestId);
+ } else {
+ msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE;
+ }
} else {
msg = T.F.SYNC.S.AUTH_SETUP_FAILED;
}
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index 5226110a50..833ac64e03 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -1636,6 +1636,9 @@ const T = {
S: {
ALREADY_IN_SYNC: 'F.SYNC.S.ALREADY_IN_SYNC',
ARCHIVE_OPERATION_FAILED: 'F.SYNC.S.ARCHIVE_OPERATION_FAILED',
+ AUTH_SERVICE_UNAVAILABLE: 'F.SYNC.S.AUTH_SERVICE_UNAVAILABLE',
+ AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE:
+ 'F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE',
AUTH_SETUP_FAILED: 'F.SYNC.S.AUTH_SETUP_FAILED',
AUTH_TOKEN_REJECTED: 'F.SYNC.S.AUTH_TOKEN_REJECTED',
BTN_CONFIGURE: 'F.SYNC.S.BTN_CONFIGURE',
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 49b2b03fe2..5402dd9acd 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -1589,7 +1589,9 @@
"S": {
"ALREADY_IN_SYNC": "Already in sync",
"ARCHIVE_OPERATION_FAILED": "Archive operation failed. Some cleanup may not have completed.",
- "AUTH_SETUP_FAILED": "Failed to set up authentication. Please try again. If you're using the web app, make sure you're accessing it over HTTPS. Otherwise, please report this as a bug if it keeps happening.",
+ "AUTH_SERVICE_UNAVAILABLE": "The authentication service is temporarily unavailable (HTTP {{status}}). Please try again.",
+ "AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE": "The authentication service is temporarily unavailable (HTTP {{status}}). Please try again. Reference: {{reference}}.",
+ "AUTH_SETUP_FAILED": "Failed to set up authentication. Please try again. If it keeps happening, check your connection and report the error details.",
"AUTH_TOKEN_REJECTED": "Sync token was rejected by the server: {{reason}}. Please get a new token.",
"BTN_CONFIGURE": "Configure",
"BTN_FORCE_OVERWRITE": "Force Overwrite",