Errors and testing

Handle the errors a Swift app meets, test it against a fake working copy, and report its errors to a PostHog project.

Every call on a working copy, Folders, a Server or Client throws a MarfaError. Its message is fit to show a person, and code() gives a stable word for the case, such as not_found. A sign-in throws a SignInError instead.

Handle errors

Four properties sort an error into what the app does next, so it needs no list of cases of its own:

PropertyTrue whenWhat the app does
isOfflineThe server couldn't be reached, or its answer didn't arrive: network or noConnection. A write may still have reached the server.Show that the app is offline. Queued writes wait.
isKeyRefusalThe key or sign-in no longer works: unauthorized, signedOut, or copyExpired or streamIncomplete with the reason credential_ended.Ask the person to sign in again, or for a new key. Asking again can't clear it.
isTransientAsking again later can clear it: every offline error, io, storageFull, rateLimited, unnamed, keyChanging, and server with status 408 or 500 and above.Try again later.
isHydrationNeededThe copy must be hydrated before it can catch up or follow: copyExpired (except with the reason credential_ended), instanceChanged, noCursor, hydrationIncomplete and firstSyncWaiting.Hydrate the copy. Asking again can't clear it.

An error is at most one of a key refusal, transient or a need to hydrate. forbidden is none of them: the key works but lacks a permission.

Refusals

CaseMeaning
notFound, unauthorized, forbidden, serverA refusal, from the server or from the copy before anything was sent. Each carries a code: Errors lists the server's.
validation(code:message:details:)The same for values the server or the copy refuses. details holds the instance's error.details for an edge the copy refuses itself, such as constraint "duplicate" or "cardinality" (Edges). It's nil for every other refusal, a server's answer to a direct call included.
rateLimitedThe server asked for a wait. retryAfterSeconds says how long, when it said.
versionConflictThe write was based on a version that is no longer current. Read the row again and decide.
unknownTypeThe copy's catalog holds no type of that name.
invalidA value given to the call can't be used.

The connection and the server

CaseMeaning
network, noConnectionThe server couldn't be reached, or the device is offline. A queued write stays queued.
unnamedSomething in front of the server answered without its contract, such as a proxy.
redirectedThe server answered with a redirect. It isn't followed, so the key goes nowhere else.
contractMismatchThe server speaks a contract this build wasn't made for. A write it answered stays queued, and goes again once the app speaks the server's contract.
wrongServerThe copy was given a new address, and the instance there isn't the one the store was made against. The copy sends it no credential or data: wrong_server says what to do.
instanceChangedAnother instance answers at the server's address, so the copy needs a fill from it. The queue is kept.
signedOut, noKeychainThe sign-in ended, or the keychain can't be used.
noServerThe call needs a server, and the copy was opened without one.

The copy's state

CaseMeaning
hydrationIncompleteThe read or write needs a whole copy. While the copy fills lists each.
copyExpiredThe copy needs a fill, for the reason it names. The queue is kept.
streamIncompleteThe server ended the event stream early, for the reason it names.
noCursor, noCatalogThe copy holds no position in the instance's events, or no type catalog. Hydrate first.
wrongSchema, unsentDiffersThe store was made by another build, or holds another count of unsent writes than the app passed. Working copy explains both.
readingHandleAnother process, or another open copy, writes the store, so this copy only reads.
keyChangingThe call came while useKey(_:) replaced the key. Ask again once it's done.
closedThe copy was closed. Open the store again.
firstSyncWaitingA folder's first sync waits for the person to confirm it.

Files and the device

CaseMeaning
bytesAbsentA file's bytes aren't on the device, and can't be fetched.
noRoomThe volume has less room than a fetch still has to receive.
io, store, storageFull, decodingA local file, the store or an answer couldn't be read or written, or the device is full.
canceledThe caller canceled the call.

Keychain throws Keychain.Failure, and Server.fromEnvironment() throws Server.EnvironmentError.

Test against a fake copy

WorkingCopy.fake(items:edges:itemTypes:edgeTypes:views:displays:linkCandidates:) from the MarfaTesting product returns a FakeCopy. Its copy is a real WorkingCopy over an engine that keeps items, edges and types in memory, so its argument checks, change feed and value conversion are the ones your app ships. Hand it to the code under test or to a preview:

#if DEBUG
import MarfaTesting

let fake = WorkingCopy.fake(items: [note], itemTypes: [noteType])
let model = NotesModel(copy: fake.copy)
#endif

The product compiles empty in a release build. MemoryKeyStore is a KeyStore in memory, and makeUnreadable(_:) makes a key the store holds but can't read.

The fake answers these calls:

  • Reads: items.list(_:sort:) without a filter, items.get, the edge reads, the catalog reads, declaredTypes() and status().
  • Search: search(_:filters:limit:) and search(_:inView:limit:), through the same full-text index the core uses, so the hits, their order and their excerpts are the core's.
  • Writes: item writes, without a sourceId on a create, and edge, tag, metadata and extension writes, and declareTypes(_:).
  • Views: creating, changing, deleting, listing and grouping views, over the views: you pass and those it makes. A view with a filter is stored, and listing it throws.
  • The queue and changes: queue.all(), drain(), withdraw(_:), discard(_:), forgetAnswered(), takeAnswered() and changes().

A call or input the fake can't match exactly throws FakeCopyError.unsupported(call:) rather than answer differently. That covers a filter, body links, changes(since:most:), hydration, files and folders, and the two calls that need the core's rules until you script them: items.displays and items.linkCandidates. The fake knows only the types you give it and the ones your app declares, not Marfa's built-in catalog.

To drive the rest of the app, set these on the FakeCopy, or pass the first two to fake(...):

  • displays scripts the name and preview that items.displays(for:) and displays(of:) answer, one closure call for each input, since the fake doesn't work a name out from a type's display hints.
  • linkCandidates scripts what items.linkCandidates(of:in:) answers for each target, given the item it was asked in. The answer's target is replaced by the one asked.
  • verdict chooses the verdict a drain gives each write. The default accepts every write, and nil leaves a write unanswered, as when the server is lost.
  • writes lists every write the copy received, in order, with its verdict now, including those cleared from the queue.
  • send(_:) tells every changes() stream a change, as if the copy had made it.
  • sendUpdate(_:) delivers a CopyUpdate through the copy's own updates(), to every sequence held, so a test drives the app's handling of a connection such as .offline or .stopped. Call it after copy.updates(): the fake has no server, so it reports .local and changes nothing by itself.

Report errors and events

The MarfaTelemetry product sends an app's errors, and a short list of events it chooses, to a PostHog project. It does nothing until the app gives it a destination and turns it on. It carries no destination or token of its own. What apps send says what Marfa's own apps send and how a person turns it off.

import MarfaTelemetry

Telemetry.shared.configure(
    Telemetry.Configuration(
        destination: try Telemetry.Destination(
            host: URL(string: "https://eu.i.posthog.com")!,
            projectToken: projectToken),
        serviceName: "notes",
        serviceVersion: appVersion))
Telemetry.shared.isEnabled = reportingIsOn

Telemetry.shared.report(error, during: "drain")
Telemetry.shared.report(
    type: "editor_ended",
    sentence: "The editor's web process ended.",
    during: "editing")
Telemetry.shared.log("drain_finished", attributes: ["pending": 3, "complete": true])
await Telemetry.shared.flush()  // when the app quits
  • Destination throws ConfigurationError for a host other than an https one or, for a test, an http loopback address, for a host that carries a user name or password, and for an empty project token, or one with a blank character in it.
  • Read the project token from a build setting, and leave it out of public source, so a copy built from the source reports nowhere. No report carries the instance's ID, name or address.
  • isEnabled starts off. Set it whenever the person changes the choice. Turning it off drops every report waiting, stops a send under way and sends nothing more.
  • Each call returns at once. A report that can't be sent is lost, with no retry. flush(deadline:) waits up to 2 seconds by default.

An error becomes one PostHog exception. A MarfaError is described only by text fixed in the SDK: its kind, one sentence and its code when that is a short lowercase word. No message, and no other value the error carries, reaches a report. A failure that repeats is reported at most once a minute for each kind and operation, and 30 times an hour in all, and each exception counts those held back since the one before.

An event becomes an OTLP log record at a LogLevel, .info by default. Records go out in batches of 50, after 30 seconds, or on flush. At most 500 wait; beyond that, the oldest is dropped and the next batch says how many were.

Pass only what is safe to send. An operation, an event's name and its attribute names are StaticStrings, and an attribute's value is a number, a boolean or a StaticString, so a String read at run time doesn't compile. Never get around this with a note's text, a title, a tag, an item ID, a key, the instance's address, a file path, a search or an error's message.

On this page