Skip to content

Latest commit

 

History

History
153 lines (129 loc) · 9.03 KB

error-handling.md

File metadata and controls

153 lines (129 loc) · 9.03 KB

Fatal errors and developer-managed dead letters

In the Express backend, every fatal error defaults to backend handling except UNAUTHORIZED, which stays queued in your app so a denied write is not lost. Replace the methods on fatalErrorHandler in backend/standalone/src/fatal-error-handler.ts to choose which errors need client intervention and deliver backend-directed failures to your own storage or service. Authorization failures reach this handler before persistence starts. If a database transaction has started, its adapter attempts rollback before the failure reaches this handler.

The Supabase Edge Function reports UNAUTHORIZED from an RLS policy or missing grant as retryable_error, so the write stays queued and is retried. Your app can decide to dead-letter it after a number of retries. Change permissionErrorsRetryable in its rls.ts to alter this.

Postgres connection acquisition failures are classified before routing. Known transient Node transport failures, such as ECONNREFUSED and ECONNRESET, return retryable_error so the connector retains queued transactions during outages. Credential failures, constraints and unknown non-transient codes remain fatal. If acquisition fails, there is no client to roll back or release; transaction failures still attempt rollback and release the acquired client.

The following replaces fatalErrorHandler in backend/standalone/src/fatal-error-handler.ts. developerOwnedStorage is a placeholder for your storage integration; supply its import and implementation. The file already declares FatalErrorHandler.

export const fatalErrorHandler: FatalErrorHandler = {
  requiresClientHandling(error, context) {
    // Choose which rejected writes need a client decision, such as asking the user
    // to confirm a change or correct invalid input. Return true to keep the write
    // queued for client handling; return false to route it to onDeadLetter.
    return error.errorCode === 'USER_CONFIRMATION_REQUIRED';
  },
  async onDeadLetter(entry) {
    // Called for rejected transactions when requiresClientHandling returns false.
    // Store the entry for investigation or notify your support team, for example.
    // Replace this insert with your own integration; delivery is best effort.
    // The Express backend does not await the returned promise (see delivery behavior below).
    await developerOwnedStorage.insert(entry);
  }
};

Import FatalOperationError from backend/shared/src/errors.ts using the relative path for your file, then throw an application error from authorization, a mapper, or custom persistence logic:

throw new FatalOperationError(
  'USER_CONFIRMATION_REQUIRED',
  'Confirm the change before retrying.',
  { record_id: '123' }
);

Codes can be any application string. details can be any JSON value; the write API imposes no application-specific schema. Adapters attach the zero-based original operation index when a particular operation fails. Transaction-level failures, including commit failures, have no inferred operation index. Only include details that the authenticated client should receive.

A DeadLetterEntry includes a fresh occurrence ID, ISO timestamp, full original transaction, verified subject, code, message, optional details and operation index. The whole transaction is included because the write API rejected it as a unit. A connection failure during commit can leave the database outcome uncertain; investigate that outcome before replaying writes. The occurrence ID identifies this notification attempt, not a stable transaction deduplication key.

Delivery is best effort. The Express backend invokes onDeadLetter without awaiting its promise; synchronous exceptions and rejected promises are logged, and an unresolved promise does not block the response. The Supabase Edge Function instead awaits onDeadLetter before responding, because its worker may stop once the response is sent; a slow handler delays the response there. Edit its handler in backend/supabase/functions/powersync-write/fatal-error-handler.ts. A handler doing synchronous work can still delay the event loop. Delivery may fail or be lost at shutdown. Repeated uploads can produce duplicate notifications. There is no built-in persistence, delivery retry, replay API, or deduplication. Implement these in your own service if required. The default handler logs the entry and points to this guide. If classification throws or returns an invalid decision, the result is retryable and the batch stops without sending a dead-letter notification.

Authorization-failure defaults

These defaults differ by deployment and by where authorization fails:

Failure API outcome Client queue behavior
Express backend UNAUTHORIZED failure Client-directed fatal_error Retained; blocks later uploads until resolved
Supabase Edge Function RLS or missing-grant error during persistence retryable_error Retained and retried; blocks later uploads
Supabase Edge Function optional custom authorizer returns false Backend-directed fatal_error Removed from the queue; default dead-letter handler logs it

The Supabase permission-error wrapper catches errors inside persistence only. A custom authorizer denial happens before persistence and reaches the function's fatal-error handler, which defaults to backend handling. Customize that handler if these denials should require client intervention. Neither deployment provides durable dead-letter storage by default.

A PATCH or DELETE targeting a row hidden by RLS can succeed without changing a row. That is not a permission error; see Supabase database connection.

Response and queue behavior

Fatal results require requires_client_handling at transaction-result level and failed_operation containing required error_code and optional message, details, and operation_index. Success, retryable and not-attempted results do not carry the routing flag.

Result Backend batch Client queue
Success Continue Complete
Fatal, flag false Honor backend BATCH_ON_FATAL_ERROR Complete
Fatal, flag true Always stop, even with skip Await explicit client decision
Retryable Stop Retain and retry
Not attempted No execution Retain

For the Express backend, set BATCH_ON_FATAL_ERROR=stop (default) or skip, then restart it (recreate the container when using Docker Compose). For the Supabase Edge Function, edit batchOnFatalError in backend/supabase/functions/powersync-write/config.ts and redeploy. This policy applies only to backend-directed fatal failures; client-directed and retryable failures always stop the batch. The client connector cannot override it.

Both client connectors expose the same hook. Override it in a subclass or edit your copied connector. CrudTransaction comes from the PowerSync SDK; ClientHandledFatalResult is exported by the modular WriteAPIClient.ts and declared locally in the single-file connector:

protected async onFatalTransaction(
  transaction: CrudTransaction,
  result: ClientHandledFatalResult
): Promise<'retain' | 'complete'> {
  if (result.failedOperation.error_code === 'USER_CONFIRMATION_REQUIRED') {
    const details = result.failedOperation.details;
    // Validate your application's details shape, then record/display the pending decision.
    // Deduplicate notifications in your application: this hook can run again on every retry.
    console.info('Confirmation required', details);
  }
  return 'retain';
}

The hook runs only for client-directed errors. The default logs the error and retains the transaction. Returning 'complete' explicitly releases/discards the failed transaction; it does not mean the source write succeeded. Returning 'retain', an invalid value, or throwing keeps it queued and throws from the upload attempt so PowerSync can retry. A malformed fatal response, including a missing flag, also retains the transaction.

Only consecutive transactions from the start of the batch that may leave the queue are completed. This includes successful writes, backend-directed fatal failures, and client-directed failures released with complete. Earlier accepted transactions are completed even if a later callback fails. PowerSync transaction completion also completes earlier transactions from the iterator, so a later success must never complete across a retained transaction.

A retained transaction blocks later uploads. A corrective write queued behind it cannot unblock it by itself. For a replacement workflow, capture the user's intended correction in application-owned state, get their decision, explicitly release the failed transaction, and then submit the replacement. Account for crashes between release and replacement in your application if that intent must survive them. Your application supplies the UI, avoids duplicate notifications, and decides when to release or replace a rejected transaction. Returning complete removes that write from the queue.