diff --git a/packages/sync-providers/src/http/native-http-retry.ts b/packages/sync-providers/src/http/native-http-retry.ts index 76b0d8ae64..22c0671254 100644 --- a/packages/sync-providers/src/http/native-http-retry.ts +++ b/packages/sync-providers/src/http/native-http-retry.ts @@ -6,9 +6,14 @@ import { NOOP_SYNC_LOGGER, toSyncLogError, type SyncLogger } from '@sp/sync-core * Retries are safe for Dropbox uploads: conditional writes (mode: 'update' with revToMatch * or mode: 'add') ensure duplicates fail with UploadRevToMatchMismatchAPIError. * Retries are safe for SuperSync: the server uses idempotent operations. + * + * Budget: 2 retries with 1.5s/3s linear backoff (~4.5s total). The earlier + * 1s/2s backoff (~3s) was sometimes too short to recover from Android Doze + * DNS staleness after a long background period. * @see https://developer.apple.com/library/archive/qa/qa1941/_index.html */ const MAX_RETRIES = 2; +const RETRY_BACKOFF_MS = 1500; const DEFAULT_CONNECT_TIMEOUT_MS = 30_000; const DEFAULT_READ_TIMEOUT_MS = 120_000; @@ -85,7 +90,7 @@ export const executeNativeRequestWithRetry = async ( }); } catch (retryErr) { if (attempt < maxRetries && isTransientNetworkError(retryErr)) { - const delayMs = 1000 * (attempt + 1); + const delayMs = RETRY_BACKOFF_MS * (attempt + 1); // toSyncLogError drops .code on Error instances, so we read it directly // off the raw error to preserve platform-specific transient codes // (NSURLErrorDomain, SocketTimeoutException, …) in the retry log. @@ -159,6 +164,11 @@ export const isTransientNetworkError = (e: unknown): boolean => { message.includes('cannot find host') || message.includes('hostname could not be found') || message.includes('cannot connect to host') || - message.includes('could not connect to the server') + message.includes('could not connect to the server') || + // Android UnknownHostException message ("Unable to resolve host \"…\": No + // address associated with hostname"), and the custom WebDavHttp plugin's + // wrapped form ("Network error: Unable to resolve host") — defensive + // fallback in case the platform-specific code-based check doesn't fire. + message.includes('unable to resolve host') ); }; diff --git a/packages/sync-providers/src/http/retryable-upload-error.ts b/packages/sync-providers/src/http/retryable-upload-error.ts index 4b40b42244..18a8a5c523 100644 --- a/packages/sync-providers/src/http/retryable-upload-error.ts +++ b/packages/sync-providers/src/http/retryable-upload-error.ts @@ -20,6 +20,10 @@ const RETRYABLE_UPLOAD_ERROR_PATTERNS: RegExp[] = [ /\bfailed to fetch\b/, /\bnetwork\s*(error|request|failure)?\b/, /\bunable to connect\b/, + // Android UnknownHostException: "Unable to resolve host \"…\": No address + // associated with hostname". Routes the error through NetworkUnavailableSPError + // so the user sees the friendly translated warning instead of the raw message. + /\bunable to resolve\b/, /\btimeout\b/, /\beconnrefused\b/, /\benotfound\b/, diff --git a/packages/sync-providers/tests/native-http-retry.spec.ts b/packages/sync-providers/tests/native-http-retry.spec.ts index 1f270f88f2..5a8638aa40 100644 --- a/packages/sync-providers/tests/native-http-retry.spec.ts +++ b/packages/sync-providers/tests/native-http-retry.spec.ts @@ -127,6 +127,22 @@ describe('isTransientNetworkError', () => { expect(isTransientNetworkError(new Error('cannot connect to host'))).toBe(true); }); + it('returns true for Android "Unable to resolve host" message', () => { + expect( + isTransientNetworkError( + new Error( + 'Unable to resolve host "sync.example.com": No address associated with hostname', + ), + ), + ).toBe(true); + }); + + it('returns true for custom WebDavHttp "Network error: Unable to resolve host"', () => { + expect( + isTransientNetworkError(new Error('Network error: Unable to resolve host')), + ).toBe(true); + }); + it('is case-insensitive', () => { expect(isTransientNetworkError(new Error('THE NETWORK CONNECTION WAS LOST'))).toBe( true, @@ -223,7 +239,7 @@ describe('executeNativeRequestWithRetry', () => { expect(result).toBe(successResponse); expect(executor).toHaveBeenCalledTimes(2); - expect(delays).toEqual([1000]); + expect(delays).toEqual([1500]); }); it('retries twice and succeeds on third attempt', async () => { @@ -245,7 +261,7 @@ describe('executeNativeRequestWithRetry', () => { expect(result).toBe(successResponse); expect(executor).toHaveBeenCalledTimes(3); - expect(delays).toEqual([1000, 2000]); + expect(delays).toEqual([1500, 3000]); }); it('throws after exhausting all retries for transient errors', async () => { @@ -259,7 +275,7 @@ describe('executeNativeRequestWithRetry', () => { executeNativeRequestWithRetry(baseConfig, { executor, label: 'Test', delay }), ).rejects.toBe(transientError); expect(executor).toHaveBeenCalledTimes(3); - expect(delays).toEqual([1000, 2000]); + expect(delays).toEqual([1500, 3000]); }); it('immediately throws non-transient errors without retrying', async () => { @@ -340,7 +356,7 @@ describe('executeNativeRequestWithRetry', () => { url: baseConfig.url, attempt: 1, maxRetries: 2, - delayMs: 1000, + delayMs: 1500, errorName: 'Error', errorCode: 'NSURLErrorDomain', }); diff --git a/packages/sync-providers/tests/retryable-upload-error.spec.ts b/packages/sync-providers/tests/retryable-upload-error.spec.ts index cd0c9555c8..352b5fd351 100644 --- a/packages/sync-providers/tests/retryable-upload-error.spec.ts +++ b/packages/sync-providers/tests/retryable-upload-error.spec.ts @@ -89,6 +89,15 @@ describe('isRetryableUploadError', () => { expect(isRetryableUploadError('dns resolution failed')).toBe(true); }); + it('Android "unable to resolve host" (UnknownHostException)', () => { + expect( + isRetryableUploadError( + 'Unable to resolve host "sync.example.com": No address associated with hostname', + ), + ).toBe(true); + expect(isRetryableUploadError('Network error: Unable to resolve host')).toBe(true); + }); + it('AbortError from Error object', () => { const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError';