Skip to content

Conflicts and failures

A failure is either the network's or the server's. On the network's, the library serves the cache or keeps the write queued; on the server's, it hands you supabase-kt's own exception, unchanged. A queued write the server refuses later, on replay, is thrown at no one: it goes to the rejected list, which your app shows. The samples turn every failure a call can throw into their own words in one place, a branch per kind:

From the samples: LibraryFailures.kt · SampleFailure.swift

/**
 * Every failure a library call can throw, in the app's terms: one branch for each kind, each with a
 * sentence of its own on screen. The library's own types come first, then supabase-kt's, which the
 * library passes through unchanged.
 */
internal fun Throwable.toAppFailure(call: LibraryCall): Throwable =
    when {
        this is CancellationException -> this // never a failure
        // Simulate offline is on, and the request needed the server.
        this is NetworkDisabledException -> NetworkSwitchedOffException(this)
        // A cache-only read found nothing saved for its query.
        this is CacheMissException -> NotSavedOnDeviceException(this)
        // A write the queue cannot hold found no connection; nothing was queued.
        this is MutationNotQueueableException ->
            WriteNeedsConnectionException(message.orEmpty(), this)
        // Why a replay stopped, writes still queued: the status and code, never the message.
        this is SyncFailedException ->
            SyncStoppedException(httpStatus, code, isNetworkOrConnectivityFailure(), this)
        // A kind of failure a later release of the library adds.
        this is OfflineException -> UnknownLibraryFailureException(this)
        // The session's token expired and could not be refreshed, here or on the server.
        this is TokenExpiredException || httpStatusOrNull() == HTTP_UNAUTHORIZED ->
            SessionExpiredException(this)
        // PostgREST refused it: a write's refusal names the task, anything else its status.
        this is PostgrestRestException -> serverRefusal(call)
        // Storage refused it, or has no such file.
        this is RestException -> storageRefusal(call)
        // No connection, or no answer in time: the network, not the server.
        isNetworkOrConnectivityFailure() -> NetworkUnavailableException(this)
        // A row that does not decode into the app's type, or a value that does not encode.
        this is SerializationException || this is SupabaseEncodingException ->
            RowFormatException(this)
        // Two top-level filters on one column, or a retry of a write that cannot be retried.
        this is IllegalArgumentException -> InvalidRequestException(message.orEmpty(), this)
        // The client is closed, or was built without a plugin the call needs.
        this is IllegalStateException -> ClientUnavailableException(this)
        else -> this // unexpected: reported as it is
    }
/// A write's failure: the server's refusal of a write sent at once, named by its task. Any
/// other failure stays the library's, for ``sentence(for:)``.
static func write(_ error: any Error, taskTitle: String?) -> any Error {
    guard let offline = error as? OfflineError, offline.code == .server, let serverError = offline.serverError else {
        return error
    }
    return SampleFailure.writeRefused(taskTitle: taskTitle, serverError: serverError)
}

/// The app's sentence for each of the library's failures, by its code.
static func sentence(for error: OfflineError) -> String {
    switch error.code {
    case .networkDisabled:
        return "Simulate offline is on, and that needs the server. Switch it off on the Sync tab."
    case .connectivity:
        return "That needs the network, and the server can’t be reached. Try again when you’re online."
    case .cacheMiss:
        return "Nothing for that is saved on this device yet. Open it once while you’re online."
    case .mutationNotQueueable:
        return "That change can’t wait in the queue, and the server can’t be reached, so nothing was changed. (\(error.message))"
    case .syncFailed:
        return "The last sync stopped early\(Wording.serverAnswer(status: error.httpStatus, code: error.serverError?.code)). The rest waits, and is sent on the next try."
    case .server:
        return error.serverError.map(Wording.refusalReason) ?? "The server refused it."
    case .clientClosed:
        return "The app’s offline data is closed. Restart the app."
    case .accessToken:
        return "Your sign-in couldn’t be read, so nothing was sent. It’s tried again with the next request."
    case .invalidRequest:
        return "The app asked for something that can’t be sent as written. Nothing was sent."
    case .decoding:
        return "The server’s answer couldn’t be read as the app expects."
    case .encoding:
        return "The change couldn’t be written out to send. Nothing was saved."
    case .configuration:
        return "The app’s offline storage couldn’t be opened. Restart the app."
    case .unexpected:
        return "Something went wrong. Try again."
    default:
        // A code a later release of the library adds.
        return "Something went wrong (\(error.code)). Try again."
    }
}

How a failure is classified

  • isNetworkOrConnectivityFailure(), which the library uses itself, is true for a timeout or an I/O failure, here or in a cause, such as supabase-kt's HttpRequestException; never for a cancellation, and never for anything carrying an HTTP status. A read falls back to the cache on such a failure only, and a reactive read reports a failing refresh in its snapshot instead of throwing (Refresh lifecycle).
  • httpStatusOrNull() is the HTTP status the server answered with, read from supabase-kt's RestException (PostgREST's and Storage's errors), a Ktor ResponseException or a SyncFailedException, here or in a cause.

The library's own failures subclass OfflineException. The hierarchy is open, so match the ones you handle and keep a branch for the rest; everything else is supabase-kt's or the server's (PostgrestRestException, RestException, HttpRequestException, TokenExpiredException, …):

Exception When
CacheMissException A one-shot CacheOnly read found nothing it may serve, or any CacheOnly read asked for a shape the cache never holds. Nothing was sent. A live CacheOnly read of a query that is not cached emits null rows, or only the queued writes it admits, instead
MutationNotQueueableException A write the queue cannot hold could not be sent for want of a connection. Its cause is that failure, so it counts as a connectivity failure
NetworkDisabledException A request that must reach the server was made after disableNetwork(). It counts as a connectivity failure
SyncFailedException Why a sync pass stopped with writes still queued, as syncStatus.value.lastSyncError says. It is never thrown; it carries httpStatus and code only

Writes

Failure When
supabase-kt's PostgrestRestException The write was sent at once and the server refused it, with any answer but those that keep it queued (below). It does not stay queued
MutationNotQueueableException A write the queue cannot hold (a dry run, a filter it cannot record, another schema, an unknown user) could not be sent for want of a connection. Nothing was queued
The connectivity failure itself An OnlineOnly write could not be sent
NetworkDisabledException An OnlineOnly write was made while network access is off
IllegalArgumentException An UPDATE or DELETE named two top-level filters on one column, or a pattern list the server would misread; nothing was sent or queued

A QueueIfOffline write that may not have run is not thrown: it returns Queued (What a write returns). When you show a refusal, show PostgREST's own error and code, or your own words keyed on the code: the exception's message carries the request's URL with its filter values, so keep it out of the screen and out of crash reports (Security and privacy).

Replay failures

Which replay failures stop the pass and which move a write to the rejected list is on What happens to each write. A write the server keeps failing with a 5xx holds every later write of its user behind it: when it will never go through, discardPendingMutation(id) removes it, unsent, and the writes behind it go.

The rejected list

It is kept on the device across restarts until each entry is dismissed or retried. Each entry is a RejectedMutation: the table, the operation, the rows' keys, the body, the server's answer as a ServerError (status, code, message, details, hint), and whether it can be retried.

From the samples: OfflineTaskRepository.kt · LibraryMappings.kt · TaskRepository.swift · QueueWording.swift

/** The refused writes, newest first (the library lists the oldest first), until dismissed. */
override fun rejectedMutations(): Flow<List<RejectedTaskMutation>> =
    offline.rejectedMutations().map { list ->
        list.asReversed().map { it.toRejectedTaskMutation() }
    }


/** A refused write as the Sync tab shows it: when it was made and refused, and why. */
internal fun RejectedMutation.toRejectedTaskMutation(): RejectedTaskMutation =
    RejectedTaskMutation(
        id = id,
        table = table,
        operation = operation.toTaskMutationOperation(),
        rowKeys = rowKeys.mapNotNull { it.idOrNull() },
        taskTitle = body?.titleOrNull(),
        // Null when the library refused it itself: it could no longer be replayed as made.
        serverError = serverError?.toTaskServerError(),
        message = message,
        rejectedAt = rejectedAt,
        createdAt = createdAt,
        isRetryable = isRetryable, // false when its body was too long to keep
    )

/** The server's answer to a refused write, kept as data so it survives a restart. */
internal fun ServerError.toTaskServerError(): TaskServerError =
    TaskServerError(status = status, code = code, message = message, details = details, hint = hint)


/** Dismiss: the refused write is forgotten for good. */
override suspend fun dismissRejectedMutation(id: Long) {
    offline.dismissRejectedMutation(id)
}

/** Retry: queued again at the back, and sent by a pass; a second refusal returns here. */
override suspend fun retryRejectedMutation(id: Long) {
    try {
        offline.retryRejectedMutation(id)
    } catch (notRetryable: IllegalArgumentException) {
        throw notRetryable.toAppFailure(LibraryCall.Sync) // its body was too long to keep
    }
}
/// The writes the server refused on replay, oldest first, until each is dismissed or retried.
func rejectedMutations() -> RejectedMutationSequence {
    client.rejectedMutations()
}

/// The refused writes as they are now, read once: the ones the app found at launch.
func rejectedMutationsNow() async throws -> [RejectedMutation] {
    try await client.rejectedMutations().first()
}

/// What a refused write did, when it was made and refused, and the rows it was for: "Added
/// “Buy milk” · made 10:41 · refused 10:42 · row 3f2a…".
static func refusedChange(_ rejection: RejectedMutation) -> String {
    let what = change(rejection.operation, table: rejection.table, body: rejection.body)
    let made = rejection.createdAt.formatted(date: .omitted, time: .shortened)
    let refused = rejection.rejectedAt.formatted(date: .omitted, time: .shortened)
    return "\(what) · made \(made) · refused \(refused) · \(rowKeys(rejection.rowKeys))"
}

/// Forgets a refused write.
func dismissRejectedMutation(id: Int64) async throws {
    try await client.dismissRejectedMutation(id: id)
}

/// Queues a refused write again, at the back of the queue; a second refusal comes back on the
/// rejected list, never as an error.
func retryRejectedMutation(id: Int64) async throws {
    try await client.retryRejectedMutation(id: id)
}

Retry a write once its cause is fixed (a missing parent row, a policy): it rejoins the queue at the end under a new id. Postgres puts the offending values in details (a duplicate key's value, for one), so show your own words rather than the raw fields.

Its bounds. The newest 100 entries of every user together, within 512 K characters. A body longer than 16 K characters is not kept, so that entry cannot be retried (isRetryable is false); the server's message, details and hint are kept to 4 K characters each. When the list is full the oldest entries go, and their writes are lost: nothing reports the loss yet.

Conflicts

The last write wins: the library does not merge or resolve conflicting writes.

  • A replayed UPSERT, UPDATE or DELETE sends what it was given; if its first send reached the server but the answer was lost, the replay overwrites what another writer changed in between.
  • A value several devices change (a counter, a running total) belongs in a server-side computation: a queued write replays exactly the value it set, so write the facts that feed it.
  • A policy change under row-level security can turn writes already queued into refusals.
  • A parent row deleted elsewhere makes a queued child's insert fail on its foreign key (23503), and it lands on the rejected list. Your own child queued after its parent is sent after it.
  • The same id twice. A genuine duplicate id is refused (23505) and lands on the rejected list (Upserts and ids).
  • On iOS, a request that fails as the network path drops can read as never sent, and its replay may be refused as a duplicate of itself: a false rejection, never a lost write.

A missing Storage object

Storage answers a request for a missing object with HTTP 400 and a 404 in its body, which supabase-kt raises as NotFoundRestException, so httpStatusOrNull() reads 400: test for the type. The offline bucket drops its cached copy when the server says so (Storage caching).