Failure Modes
Retry Policy (from source)
// RetryPolicy.ts
const RETRY_CONFIG = {
'chunk-upload': { maxRetries: 3, baseDelayMs: 500 },
'presigned-url': { maxRetries: 2, baseDelayMs: 500 },
'complete-upload': { maxRetries: 2, baseDelayMs: 1000 },
'completion-handler': { maxRetries: 1, baseDelayMs: 0 },
};
Backoff: Linear — delay = baseDelayMs × attempt
Retryable errors (isRetryableError()):
TypeError— network/fetch-level failures- HTTP 500+ — server errors
- HTTP 408 — request timeout
- HTTP 429 — rate limited
Non-retryable: HTTP 400, 403, 404, and any error not matching the above.
Failure Flow Diagram

Failure Mode: Chunk Upload Failure
Stage: 'upload' | Max retries: 3 | Backoff: 500ms linear
What happens in UploadWorker.uploadChunk():
file.slice(startByte, endByte)→ PUT to presigned S3 URL- On failure: check
isRetryableError() && canRetry('chunk-upload', attempt) - If retryable:
session.markRetrying()→ delay →session.startUploading()→ retry - If exhausted: error propagates to
run()catch block
In run() catch block:
session.fail('upload', error.message); // → needs-attention
const persisted = toPersistedStateAdapter(session.getSnapshot());
if (persisted) UploadPersistence.save(persisted);
session.notifyError(error); // calls onError callback
Recovery: User calls resume() → worker resumes from getContiguousCompletedPart() + 1
Failure Mode: Presigned URL Fetch Failure
Stage: 'upload' | Max retries: 2 | Backoff: 500ms linear
What happens in UploadWorker.uploadWindow():
UploadService.getBatchedPresignedUrls()fails- If retryable && retries left: delay → retry
- If non-retryable or exhausted: throws — caught by
run()
Distinctive behavior: Non-retryable URL errors (e.g., 403) fail the upload immediately without exhausting chunk retries.
Failure Mode: Multipart Completion Failure
Stage: 'completion' | Max retries: 2 | Backoff: 1000ms linear
What happens in UploadWorker.completeMultipart():
session.startFinalizing()→uploading→finalizingUploadService.completeMultipartUpload()sends sorted parts- On failure: if retryable && retries left → delay → retry
- If exhausted:
session.fail('completion', error.message); // → needs-attention
const persisted = toPersistedStateAdapter(session.getSnapshot());
if (persisted) UploadPersistence.save(persisted);
return; // does NOT throw — run() continues to finally
Key: Completion failure is handled locally (returns, doesn't throw). All chunks are uploaded — only the final S3 multipart complete call failed.
Failure Mode: Completion Handler Failure
Stage: 'completion' | Max retries: 1 | Backoff: none
What happens in UploadWorker.runCompletionHandler():
- Calls
onComplete({ fileUrl, file, context }) - On failure: 1 retry (no delay)
- If still fails:
session.fail('completion', error.message); // → needs-attention
UploadPersistence.save(toPersistedStateAdapter(...));
session.notifyError(error);
return;
:::warning
Handler failure does not invalidate the upload. The file is already atfileUrl. Theneeds-attentionstate withstage: 'completion'signals that domain-side processing failed, not the upload itself.
:::
Failure Mode: Duplicate Objects in Storage
Observable: Multiple S3 objects for the same logical file; orphaned partial uploads.
Why it happens:
- Retries may call
initMultipartUpload()again (new multipart ID) - Abort stops client work but doesn't clean S3
- Resume may overlap with previous multipart session
Why it's acceptable:
- Only one
fileUrlis ever referenced (engine'sprefixUrl + key) - Storage is not the source of truth — domain records are
- Backend lifecycle rules handle cleanup asynchronously
Failure Mode: Lost UI Callbacks
Observable: onComplete / onError callbacks never fire.
Why it happens:
- Callbacks are closures tied to the React component that called
start() - If the component unmounts, callbacks are garbage collected
Why it's acceptable:
- Callbacks are best-effort — documented as advisory
- Completion is durable state (
UPLOAD_COMPLETEDevent + snapshot) - Domain correctness must be driven by Signal events, not callbacks
Failure Mode: Missing Progress Events
Observable: Progress jumps (e.g., 0 → 20 → 60 → 100).
Why it happens:
- Progress is emitted per chunk, not per byte
- With 5 MB chunks on a 25 MB file, only 5 progress events total
- Parallel mode may complete multiple chunks before the next render
Why it's acceptable:
- Progress is monotonic (enforced by
updateProgress()) - Snapshots (
get()) always reflect current progress - UI should poll snapshots for display, not rely on event frequency
Failure Mode: Rehydrated Upload Can't Resume Without User Action
Observable: Rehydrated uploads sit in paused or needs-attention state.
Why it happens:
Fileobject is not serializable — cannot be persisted- Rehydrated sessions have
uploadServiceDataandcompletedPartsbut noFile - Worker needs the
Fileto slice chunks
Why it's acceptable:
- Rehydrated uploads appear in
list()— user can see them - User can
resume()(if they provide the file again) orabort() - This is a conscious trade-off: persistence preserves progress metadata, not binary data
What Is NOT an Acceptable Failure
These indicate real bugs and must be treated as correctness failures:
| Symptom | What's Broken |
|---|---|
Upload completes without UPLOAD_COMPLETED event |
Event emission path is broken |
| Upload resumes implicitly after abort | Terminal state enforcement failed |
| State transition skips a phase | assertTransition() is bypassed |
| Domain action invalidates successful upload | Boundary violation |
needs-attention uploads disappear on reload |
Persistence is broken |
| Concurrent uploads interfere with each other | Shared mutable state leak |