Swift
Errors
Everything the Swift package can throw, what causes it, and what to do about it.
One error type
public enum VoteFirstError: Error, Sendable {
case secretCredentialRefused
case notStarted
case api(APIFailure)
case network(String)
case decoding(String)
case invalidResponse
}
| Case | When |
|---|---|
secretCredentialRefused | start or a client initialiser was given a key without the vf_pk_ prefix |
notStarted | A call was made before start |
api | The server answered, and said no. The payload is below |
network | The request never got an answer. The string is a sentence a person can read |
decoding | The server answered something this version cannot read |
invalidResponse | The answer was not HTTP at all |
It conforms to LocalizedError, so errorDescription is a sentence for every case.
catch let error as VoteFirstError {
show(error.errorDescription ?? "Something went wrong.")
}
What the server said
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?
}
| Field | What it is |
|---|---|
code | The typed code, from the closed set below |
rawCode | What the server actually wrote, which matters when it is a code this version does not know |
message | One sentence, written to be read by a person |
field | Which field a validation failure was about, such as description or parent_id |
status | The HTTP status |
requestID | The correlation id, echoed in X-Request-Id. Quote it in a support email |
retryAfter | Seconds the server asked for, when it asked |
Two shortcuts, so the common case is not a switch.
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
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 = ""
}
| Code | Status | What happened | What to do |
|---|---|---|---|
notFound | 404 | No feature, comment, release or project with that id | Nothing to retry. The thing is gone, hidden, unapproved, or belongs to another project |
notFound | 409 | You withdrew a vote that was not there | Nothing. The package settles this one and hands you the state you asked for |
missingKey | 401 | No key on the request | You reached the API without start. This should not happen through the package |
invalidKey | 401 | The key is not this project's, or it was rotated | Ship an update with the new key. Nothing in the app can fix it |
keyWrongProject | 403 | A secret key aimed at a project it does not own | Only reachable with a secret key, which the package refuses to carry |
identityRequired | 401 | A write with no voter on it | The package mints one for you, so this means the mint itself failed |
identityInvalid | 401 | The voter token is not valid for this project, or the SSO token is refused | forget() and let a new one be minted. For SSO, check the secret, the plan and the exp claim |
notAllowed | 403 | Voting, commenting or suggesting is off, or that column takes no votes, or the comment is not yours | Ask the board first. See Ask before you draw the button |
voterBanned | 403 | This project has stopped this voter writing | Say so. They can still read, vote and like |
validationFailed | 422 | Something in the body is wrong, and field says what | Show the message beside that field. The limits are in the calls |
alreadyExists | 409 | You voted for something you had already voted for | Nothing. The package settles this one too |
idempotencyInProgress | 409 | An earlier copy of this same write is still running | Nothing. The package retries it |
idempotencyMismatch | 409 | This idempotency key was already used for a different route | Use a new key. The package mints one per write, so an app that lets it is never shown this |
rateLimited | 429 | Your project is over one of its budgets | Wait what retryAfter says. See Budgets |
tooLarge | 413 | The request body was over 1MB | Only reachable by sending something very long. The text limits are well under it |
originNotAllowed | 403 | A browser called from an origin the project does not allow | Never an app. An app sends no origin |
serverFault | 500 | Something broke at our end | It is retried three times before you see it. Quote the request id |
unrecognised | any | A code this version of the package does not know | Read 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 happened | What it says |
|---|---|
| No connection, roaming off, a call in progress | There is no connection right now. |
| The request timed out | The server took too long to answer. |
| The host could not be found or reached | Could not reach the server. |
| The certificate was refused | The connection to the server is not secure. |
| Anything else | Whatever 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
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.
@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.
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.
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.