mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
001e9847b4
commit
058f92e972
4 changed files with 45 additions and 6 deletions
|
|
@ -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')
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue