Fix upload failures on iOS Safari (load failed)

iOS Safari kills fetch requests when the app goes to background or
the connection drops during large uploads, producing 'load failed'.
The sync engine was burning through all 5 retries instantly with no
delay, so a transient failure became permanent.

Changes:
- Add AbortController timeout (120s) on upload fetch
- Add exponential backoff between retries (2s, 5s, 10s...60s)
- Increase max retries from 5 to 15 for flaky mobile networks
- Remove 10MB resize step that was re-compressing photos already
  sized at capture time, avoiding extra memory pressure on iOS
- Log photo size with each upload attempt for easier debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 15:31:54 -06:00
parent 0eef9bf2f3
commit 4491531acb

View File

@@ -7,8 +7,11 @@ const Sync = {
currentUsername: null,
isOnline: navigator.onLine,
isSyncing: false,
MAX_RETRIES: 5,
MAX_RETRIES: 15,
VERIFY_DELAY_MS: 800,
UPLOAD_TIMEOUT_MS: 120000,
// Backoff delays in ms per retry attempt
BACKOFF_DELAYS: [0, 2000, 5000, 10000, 15000, 30000, 30000, 60000, 60000, 60000, 60000, 60000, 60000, 60000, 60000],
init(username) {
this.currentUsername = username;
@@ -96,6 +99,19 @@ const Sync = {
continue;
}
// Backoff delay before retry attempts
if (retryCount > 0) {
const backoff = this.BACKOFF_DELAYS[Math.min(retryCount, this.BACKOFF_DELAYS.length - 1)];
console.log('[SYNC] Waiting', backoff + 'ms before retry', retryCount + 1);
await this.delay(backoff);
// Re-check connectivity after waiting
if (!this.isOnline) {
console.log('[SYNC] Lost connection during backoff, stopping');
break;
}
}
await this.uploadPhoto(photo);
}
},
@@ -104,7 +120,9 @@ const Sync = {
const retryCount = photo.retryCount || 0;
try {
console.log('[SYNC] Uploading:', photo.filename, '(attempt', retryCount + 1, 'of', this.MAX_RETRIES + ')');
console.log('[SYNC] Uploading:', photo.filename,
'(' + (photo.blob ? (photo.blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')',
'attempt', retryCount + 1, 'of', this.MAX_RETRIES);
// Validate blob before attempting upload
if (!photo.blob || !(photo.blob instanceof Blob) || photo.blob.size === 0) {
@@ -116,7 +134,7 @@ const Sync = {
// Prevent page navigation during upload
this._setUploading(true);
// Only check for duplicates on retries (skip on first attempt to reduce latency)
// Check for duplicates on retries (skip on first attempt to reduce latency)
if (retryCount > 0) {
const fullPath = photo.targetPath.replace(/\/$/, '') + '/' + photo.filename;
const alreadyExists = await this.checkFileExists(fullPath);
@@ -134,23 +152,32 @@ const Sync = {
}
}
// Resize if over 10MB
let uploadBlob = photo.blob;
if (uploadBlob.size > 10 * 1024 * 1024) {
console.log('[SYNC] Photo too large (' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB), resizing...');
uploadBlob = await this.resizeImage(uploadBlob, 10 * 1024 * 1024);
console.log('[SYNC] Resized to ' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB');
}
const uploadBlob = photo.blob;
const formData = new FormData();
formData.append('file', uploadBlob, photo.filename);
const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath);
const uploadResponse = await fetch(uploadUrl, {
// Upload with timeout via AbortController
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.UPLOAD_TIMEOUT_MS);
let uploadResponse;
try {
uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData
body: formData,
signal: controller.signal
});
} catch (fetchErr) {
clearTimeout(timeoutId);
if (fetchErr.name === 'AbortError') {
throw new Error('Upload timed out (' + (this.UPLOAD_TIMEOUT_MS / 1000) + 's)');
}
throw fetchErr;
}
clearTimeout(timeoutId);
if (!uploadResponse.ok) {
let errorMsg = 'Upload failed';
@@ -203,7 +230,7 @@ const Sync = {
} catch (error) {
this._setUploading(false);
console.error('[SYNC] Upload failed:', error, '(attempt', retryCount + 1 + ')');
console.error('[SYNC] Upload failed:', error.message, '(attempt', retryCount + 1 + ')');
await Storage.updatePhoto(photo.id, {
status: 'pending',
retryCount: retryCount + 1,
@@ -213,55 +240,6 @@ const Sync = {
}
},
async resizeImage(blob, maxBytes) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
URL.revokeObjectURL(url);
let quality = 0.85;
let scale = 1.0;
const attempt = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((result) => {
if (!result) {
reject(new Error('Failed to resize image'));
return;
}
if (result.size <= maxBytes || (quality <= 0.4 && scale <= 0.3)) {
resolve(result);
} else {
// Reduce quality first, then scale down
if (quality > 0.5) {
quality -= 0.1;
} else {
scale *= 0.8;
quality = 0.7;
}
attempt();
}
}, 'image/jpeg', quality);
};
attempt();
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image for resize'));
};
img.src = url;
});
},
async pruneHistory() {
// Keep only the last 20 completed (verified/failed) entries per user
const completed = await Storage.db.photos