fix(sync): widen transient-error detection and lengthen retry backoff

Android UnknownHostException after Doze-mode resume produced a raw
"Unable to resolve host" snackbar because the post-retry error message
didn't match isRetryableUploadError's patterns, so it skipped the
NetworkUnavailableSPError wrap. Bump the native HTTP retry backoff from
1s/2s to 1.5s/3s (~4.5s budget) to better tolerate post-Doze DNS
staleness, and add the Android phrasing to both transient-error checks
so the error now surfaces as the translated network-warning snackbar.
This commit is contained in:
Johannes Millan 2026-05-14 14:49:34 +02:00
parent 001e9847b4
commit 058f92e972
4 changed files with 45 additions and 6 deletions

View file

@ -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')
);
};

View file

@ -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/,

View file

@ -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',
});

View file

@ -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';