Failure Modes

Prev Next

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-Modes-08-26-2026_01_40_PM.png

Failure Mode: Chunk Upload Failure

Stage: 'upload' | Max retries: 3 | Backoff: 500ms linear

What happens in UploadWorker.uploadChunk():

  1. file.slice(startByte, endByte) → PUT to presigned S3 URL
  2. On failure: check isRetryableError() && canRetry('chunk-upload', attempt)
  3. If retryable: session.markRetrying() → delay → session.startUploading() → retry
  4. 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():

  1. UploadService.getBatchedPresignedUrls() fails
  2. If retryable && retries left: delay → retry
  3. 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():

  1. session.startFinalizing()uploadingfinalizing
  2. UploadService.completeMultipartUpload() sends sorted parts
  3. On failure: if retryable && retries left → delay → retry
  4. 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():

  1. Calls onComplete({ fileUrl, file, context })
  2. On failure: 1 retry (no delay)
  3. 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 at fileUrl. The needs-attention state with stage: '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 fileUrl is ever referenced (engine's prefixUrl + 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_COMPLETED event + 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:

  • File object is not serializable — cannot be persisted
  • Rehydrated sessions have uploadServiceData and completedParts but no File
  • Worker needs the File to slice chunks

Why it's acceptable:

  • Rehydrated uploads appear in list() — user can see them
  • User can resume() (if they provide the file again) or abort()
  • 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