Skip to content
VoteFirst Docs
Dashboard

Swift

Errors

Everything the Swift package can throw, what causes it, and what to do about it.

One error type

VoteFirstError
public enum VoteFirstError: Error, Sendable {
    case secretCredentialRefused
    case notStarted
    case api(APIFailure)
    case network(String)
    case decoding(String)
    case invalidResponse
}
CaseWhen
secretCredentialRefusedstart or a client initialiser was given a key without the vf_pk_ prefix
notStartedA call was made before start
apiThe server answered, and said no. The payload is below
networkThe request never got an answer. The string is a sentence a person can read
decodingThe server answered something this version cannot read
invalidResponseThe answer was not HTTP at all

It conforms to LocalizedError, so errorDescription is a sentence for every case.

In your app
catch let error as VoteFirstError {
    show(error.errorDescription ?? "Something went wrong.")
}

What the server said

APIFailure
public struct APIFailure: Error, Sendable, Equatable {
    public let code: ErrorCode
    public let rawCode: String
    public let message: String
    public let field: String?
    public let status: Int
    public let requestID: String?
    public let retryAfter: TimeInterval?
}
FieldWhat it is
codeThe typed code, from the closed set below
rawCodeWhat the server actually wrote, which matters when it is a code this version does not know
messageOne sentence, written to be read by a person
fieldWhich field a validation failure was about, such as description or parent_id
statusThe HTTP status
requestIDThe correlation id, echoed in X-Request-Id. Quote it in a support email
retryAfterSeconds the server asked for, when it asked

Two shortcuts, so the common case is not a switch.

In your app
error.failure?.status   // the APIFailure, or nil for a network or decoding error
error.code              // the ErrorCode, or nil
error.isRetryable       // whether asking again could plausibly answer differently

Every code

ErrorCode
public enum ErrorCode: String, Sendable, Equatable {
    case notFound = "not_found"
    case missingKey = "missing_key"
    case invalidKey = "invalid_key"
    case keyWrongProject = "key_wrong_project"
    case identityRequired = "identity_required"
    case identityInvalid = "identity_invalid"
    case notAllowed = "not_allowed"
    case voterBanned = "voter_banned"
    case validationFailed = "validation_failed"
    case alreadyExists = "already_exists"
    case idempotencyInProgress = "idempotency_in_progress"
    case idempotencyMismatch = "idempotency_mismatch"
    case rateLimited = "rate_limited"
    case tooLarge = "too_large"
    case originNotAllowed = "origin_not_allowed"
    case serverFault = "internal"
    case unrecognised = ""
}
CodeStatusWhat happenedWhat to do
notFound404No feature, comment, release or project with that idNothing to retry. The thing is gone, hidden, unapproved, or belongs to another project
notFound409You withdrew a vote that was not thereNothing. The package settles this one and hands you the state you asked for
missingKey401No key on the requestYou reached the API without start. This should not happen through the package
invalidKey401The key is not this project's, or it was rotatedShip an update with the new key. Nothing in the app can fix it
keyWrongProject403A secret key aimed at a project it does not ownOnly reachable with a secret key, which the package refuses to carry
identityRequired401A write with no voter on itThe package mints one for you, so this means the mint itself failed
identityInvalid401The voter token is not valid for this project, or the SSO token is refusedforget() and let a new one be minted. For SSO, check the secret, the plan and the exp claim
notAllowed403Voting, commenting or suggesting is off, or that column takes no votes, or the comment is not yoursAsk the board first. See Ask before you draw the button
voterBanned403This project has stopped this voter writingSay so. They can still read, vote and like
validationFailed422Something in the body is wrong, and field says whatShow the message beside that field. The limits are in the calls
alreadyExists409You voted for something you had already voted forNothing. The package settles this one too
idempotencyInProgress409An earlier copy of this same write is still runningNothing. The package retries it
idempotencyMismatch409This idempotency key was already used for a different routeUse a new key. The package mints one per write, so an app that lets it is never shown this
rateLimited429Your project is over one of its budgetsWait what retryAfter says. See Budgets
tooLarge413The request body was over 1MBOnly reachable by sending something very long. The text limits are well under it
originNotAllowed403A browser called from an origin the project does not allowNever an app. An app sends no origin
serverFault500Something broke at our endIt is retried three times before you see it. Quote the request id
unrecognisedanyA code this version of the package does not knowRead rawCode and message

What a person should read

A network error already carries a sentence, because a URLError describes itself for a developer and one raised in code carries nothing but a domain and a number.

What happenedWhat it says
No connection, roaming off, a call in progressThere is no connection right now.
The request timed outThe server took too long to answer.
The host could not be found or reachedCould not reach the server.
The certificate was refusedThe connection to the server is not secure.
Anything elseWhatever URLError says about itself

An api error's message is written the same way, so showing errorDescription is reasonable for every case. The one to reword is secretCredentialRefused, which is a message for you and not for your users. If it reaches them, you shipped the wrong key.

Handling one call

In your app
do {
    let filed = try await VoteFirst.suggest(heading, details: details)
    confirm(filed)
} catch let error as VoteFirstError {
    switch error.code {
    case .validationFailed:
        highlight(field: error.failure?.field, message: error.failure?.message)
    case .voterBanned:
        show("This board has stopped you writing.")
    case .notAllowed:
        show("This board is not taking suggestions right now.")
    default:
        show(error.errorDescription ?? "Something went wrong.")
    }
}

Handling all of them in one place

Most screens want three outcomes: it worked, it is waiting for a connection, or say something.

In your app
@MainActor
func run(_ work: () async throws -> Void) async {
    do {
        try await work()
    } catch let error as VoteFirstError {
        if case .network = error {
            note("Waiting to send. \(await VoteFirst.waiting) change(s) queued.")
        } else {
            show(error.errorDescription ?? "Something went wrong.")
        }
    } catch {
        // cancellation, and nothing else reaches here
    }
}

Cancelling is not an error

A cancelled request throws CancellationError, not a VoteFirstError. Let it fall through the way the example above does, so a view that disappeared mid load draws nothing about it.

Version skew

The server can add a value to any of its vocabularies in a release your app has not adopted. A new column status, a new board kind, a new identity kind. None of them fails a decode: they are strings with known values on them rather than enums, so an unknown value keeps its raw string and the rest of the board draws.

In your app
if feature.status == .inProgress { }
feature.status.rawValue          // whatever the server said, known or not

A decoding error therefore means something structural, not a new value. It is worth reporting rather than handling.

Getting help with one

Every response carries a request id, and the package keeps it on the failure.

In your app
error.failure?.requestID   // "req_349c5953def1"

Quoting it in a support email finds the exact request in the logs. It is worth putting in your own crash or log line for anything unexpected.

Next