Palbase
Sign inGet started

iOS SDK

Error Handling

Palbe has two error types you meet in app code: BackendError for anything that goes through your backend, and AuthError for pb.auth.*. Generated endpoint methods go one step further — each throws its own small enum with a case per error your backend declared, so the compiler tells you what can go wrong on that specific call. Both types conform to one protocol with code, statusCode and requestId, so logging and crash reporting never need a switch. This page is the whole model: which call throws what, how an HTTP response becomes a case, what each payload carries, and the two error types that deliberately sit outside the protocol.

Quick example

Suppose your TodosController.update endpoint declares a typed todo_locked error with a { retryAfter } payload (see Responses & Errors for defineError). The generated method throws TodosUpdateError, and you switch on named cases:

do {
    try await pb.todos.update(id: todo.id, .init(completed: true))
} catch {
    // `error` is a TodosUpdateError — typed throws, no casting
    switch error {
    case .todoLocked(let info):
        scheduleRetry(after: info.retryAfter)        // typed payload from the backend
    case .other(let backend):
        showAlert(backend.localizedDescription)      // everything else, uniformly
    }
}

Which call throws what

You calledIt throws
A generated endpoint method (pb.todos.update(…))<Op>Error — a generated enum that wraps BackendError (typed throws)
A generated @Upload method, or pb.call(endpoint:file:)the endpoint's <Op>Error, same as above
pb.call("todos", …)BackendError
pb.call(_ endpoint:) for a hand-built PBEndpointthe endpoint's declared Failure
pb.upload("todos/attach", fileURL:…)BackendError
pb.auth.*AuthError
pb.messaging.* and Chat operationsBackendError — almost always .server, with the codes in the table below

BackendError

Twelve cases, each carrying a single struct payload so the server can add a field without breaking your switch:

public enum BackendError: PalbaseError {
    case notConfigured                              // no usable Palbase-Info.plist for this platform
    case mediaUnavailable                           // a call without PalbeCall linked + PalbeCall.enable()
    case server(ServerFailure)                      // a structured error from the backend, any status
    case validation(ValidationFailure)              // 400 carrying per-field errors
    case rateLimited(RateLimitInfo)                 // 429 — generic rate limit
    case unauthorized(AuthFailure)                  // 401 — after the SDK already tried a refresh
    case appAttestRequired(AuthFailure)             // 401 — attestation demanded, and the retry failed too
    case attestationUnavailable(AttestationFailure) // this device cannot attest; nothing was sent
    case network(TransportFailure)                  // no response: offline, timeout, connection lost
    case transport(TransportFailure)                // any other transport-level failure
    case decode(CodecFailure)                       // the response did not match the expected type
    case encode(CodecFailure)                       // the request body could not be encoded
}

code is stable snake_case: not_configured, media_unavailable, the server's own code for .server, then validation_error, rate_limited, unauthorized, app_attest_required, attestation_unavailable, network_error, transport_error, decoding_error, encoding_error. statusCode is the server's status for .server, 400 for .validation, 429 for .rateLimited, 401 for the two auth cases, and nil for everything else.

Warning: There are no .forbidden or .notFound cases. A 403 or 404 arrives as .server(ServerFailure) with status set and code carrying the backend's own error code (todo_not_found). Match on failure.code, never on imagined cases.

How a response becomes a case

The SDK decodes the standard envelope — { error, error_description, status, request_id, details, data } — and maps it:

ConditionResult
400 whose details is a [{field, message}] array, or whose code is validation_error.validation
401 with code app_attest_required.appAttestRequired
any other 401.unauthorized
429 with code too_many_requests, rate_limited or unknown_error.rateLimited
everything else, including a 429 carrying your own code.server

That last carve-out is deliberate: a defineError'd 429 (say todo_quota_exceeded with a data payload) must reach .server so the generated enum can lift it into its typed case. A 429 that was swallowed into .rateLimited degraded to .other instead, which is the bug the carve-out fixes.

When the envelope is missing, the code falls back to unknown_error and the message to the system's description of the status. When the raw body is no longer in hand — a failure raised at the transport layer — a 400 maps to .validation with an empty fields array.

Warning: Your backend's own boundary validation does not currently produce .validation. When an @Body(Schema) zod parse fails, the runtime answers 400 with code bad_request and puts the per-field list at the envelope's top level (fields), while the SDK looks for it under details. The call therefore throws .server(ServerFailure) with code == "bad_request", and the per-field messages are not reachable from the thrown error at all. Until the two shapes agree: match failure.code == "bad_request" for "the input was rejected", and when you want field-level UI, validate on the client and have the endpoint throw a defineError whose typed data you decode.

Three things do reach .validation today: a client-side upload constraint violation (one FieldError on "file", thrown before anything is sent), a 400 a backend throws with the literal code validation_error, and a Palbase module that emits per-field details — for example Documents schema enforcement, which answers 400 schema_validation_failed with details: [{field, message}].

Payload structs

ServerFailure

The payload of .server — the error envelope, lifted into a struct:

PropertyTypeMeaning
codeStringStable snake_case error id (todo_locked) — what you switch on
statusIntThe HTTP status
messageStringThe envelope's error_description
requestIdString?The body's request_id, or the X-Request-Id response header
details[AnyCodableValue]The envelope's open-ended details array
dataAnyCodableValue?The structured payload, when the error declared one

details is kept open rather than typed as [FieldError] on purpose: it also carries shapes like the messaging 409's [{current_epoch}], and a strictly-typed decode would fail — and because the whole envelope decode is tolerant, a failure there would have silently lost even the error code.

Two helpers make the open parts typed:

  • decodeData<T: Decodable>(_ type: T.Type) -> T? — round-trips data through the SDK's own coders, so a typed payload gets the same key mapping and date strategy as a successful response. It returns nil on a mismatch and never throws.
  • detailInt(_ key: String) -> Int? — reads an Int out of the first details object carrying key.
struct TodoLockedData: Decodable { let retryAfter: Int }

if case .server(let failure) = backendError, failure.code == "todo_locked" {
    if let info = failure.decodeData(TodoLockedData.self) {
        scheduleRetry(after: info.retryAfter)
    }
}

With generated methods you rarely do this by hand — the generated enum decodes the payload for you, as in the quick example.

ValidationFailure

public struct FieldError: Sendable, Equatable, Decodable {
    public let field: String
    public let message: String
}
PropertyTypeMeaning
fields[FieldError]One entry per rejected field; can be empty
requestIdString?Server correlation id
case .validation(let v):
    for fieldError in v.fields {
        form.showError(field: fieldError.field, message: fieldError.message)
    }

The remaining payloads

StructCarried byProperties
RateLimitInfo.rateLimitedretryAfter: Int? (seconds, from Retry-After), requestId: String?
AuthFailure.unauthorized, .appAttestRequiredrequestId: String?
AttestationFailure.attestationUnavailablereason: String
TransportFailure.network, .transportmessage: String
CodecFailure.decode, .encodemessage: String

Note: .attestationUnavailable is what a call would throw on a device that cannot attest — the Simulator, or macOS — once App Attest enforcement exists. No Palbase backend demands attestation today, so neither it nor .appAttestRequired can occur against Palbase. Handle them anyway so your app is correct when enforcement ships. See App Attest.

Uniform accessors: the PalbaseError protocol

BackendError and AuthError conform to one protocol, so shared logging never needs a switch:

public protocol PalbaseError: Error, Sendable, LocalizedError {
    var code: String { get }        // stable snake_case id, e.g. "todo_locked", "rate_limited"
    var statusCode: Int? { get }    // the HTTP status, when one applies
    var requestId: String? { get }  // the server's correlation id, when one is known
}
func report(_ error: any PalbaseError) {
    logger.error("call failed code=\(error.code) status=\(error.statusCode ?? 0) request=\(error.requestId ?? "-")")
}

Both also conform to LocalizedError, so error.localizedDescription always yields something printable — with two exceptions below. For the correlation id of the most recent request including successes, see PalbeCorrelation.shared.lastRequestID in Calling Your Backend.

Warning: Do not put localizedDescription in front of a user for .notConfigured or .mediaUnavailable. .notConfigured reads "Not configured. Call pb.configure(apiKey:) first." — naming an API that is internal and that no app can call — and .mediaUnavailable's message is untranslated Turkish. Both are shipped-stale strings. Switch on error.code and write your own copy.

Generated per-endpoint errors

Every generated method uses typed throws with its own enum. The protocol has two spellings for one type, and generated code writes the short one:

public protocol GeneratedFailure: Error, Sendable {
    init(_ backend: BackendError)
}
public typealias PBError = GeneratedFailure   // the preferred spelling — what codegen emits

Real emitted output for an endpoint declaring not_found and a room_locked carrying a payload:

public nonisolated enum RoomsCreateError: PBError {
    public nonisolated struct RoomLockedData: Codable, Sendable {
        public let retryAfter: Double
    }
    case notFound
    case roomLocked(RoomLockedData)
    case other(BackendError)
    public nonisolated init(_ backend: BackendError) {
        guard case .server(let f) = backend else { self = .other(backend); return }
        switch f.code {
        case "not_found": self = .notFound
        case "room_locked":
            if let data = f.decodeData(RoomLockedData.self) { self = .roomLocked(data) } else { self = .other(backend) }
        default: self = .other(backend)
        }
    }
}

The rules that make these safe to exhaust:

  • Every enum has .other(BackendError) — the landing spot for anything the endpoint did not declare: unknown codes, any non-.server failure, transport and decode problems, generic rate limits. The full BackendError is preserved inside, so nothing is lost.
  • An endpoint with no declared errors still gets an enum, with only .other, so catch blocks stay uniform across every call.
  • Payload decoding is tolerant. If the server reshapes a declared payload, the case degrades to .other instead of trapping.
  • Cases are named by camelCasing the error code: todo_locked.todoLocked.

Declare errors on the backend with defineError so they arrive as typed cases here — see Responses & Errors.

AuthError

pb.auth.* throws a smaller enum tuned to sign-in:

public enum AuthError: PalbaseError {
    case invalidCredentials(message: String)
    case rateLimited(retryAfter: Int?)
    case http(status: Int, code: String, message: String, requestId: String?)
    case network(message: String)
    case notConfigured
}
CaseWhen
.invalidCredentialsWrong email or password — and, more generally, any 401 from an auth endpoint (an expired reset token, a rejected OTP, a cancelled Apple sign-in)
.rateLimitedToo many attempts (429); retryAfter in seconds when the server says
.httpAny other non-2xx, with its status and the wire code
.networkConnection-level failure — offline, timeout
.notConfiguredThe SDK has no usable configuration
do {
    try await pb.auth.signIn(email: email, password: password)
} catch {
    switch error {
    case .invalidCredentials:
        showError("Wrong email or password.")
    case .rateLimited(let retryAfter):
        showError("Too many attempts. Try again in \(retryAfter ?? 60)s.")
    case .network:
        showError("You appear to be offline.")
    case .http, .notConfigured:
        showError(error.localizedDescription)
    }
}

OTP failures

Phone OTP needs three outcomes that all arrive as .invalidCredentials, so the SDK adds a classifier rather than new cases — new cases would break existing exhaustive switches:

extension AuthError {
    public enum OTPFailure: Sendable, Equatable { case wrongCode, expired, tooManyAttempts }
    public var otpFailure: OTPFailure? { get }
}
otpFailureWire codes it recognises
.wrongCodeotp_invalid, invalid_token, invalid_code
.expiredotp_expired, expired_token, verification_expired
.tooManyAttemptsotp_max_attempts, too_many_attempts
nilanything else — fall back to the case itself

Cancellation is not a failure

A user who dismisses the Apple, Google or passkey sheet produces .invalidCredentials too. pb.auth.wasCanceled(_:) recognises exactly the three cancellation messages those flows produce, so you can dismiss your spinner without showing an error:

catch {
    guard !pb.auth.wasCanceled(error) else { return }
    showError(error.localizedDescription)
}

Errors that are not PalbaseError

Two public error types deliberately sit outside the protocol — they have no code, statusCode or requestId, so generic reporting code cannot take them:

AppAttestError.unsupported, .attestationFailed, .enrollmentFailed, .challengeUnavailable, .malformedAssertion, each carrying a reason: String. You normally never see it: what reaches a caller is BackendError.attestationUnavailable(AttestationFailure(reason:)), whose reason is this type's message.

AppConfigError.missingConfig, .missingAPIKey, with a public message. It is what a misconfigured build dies on: auto-configuration turns it into fatalError("Palbe: \(error.message)") rather than limping on, so you meet it as a crash at launch, not as a catch. There is no .unknownEnvironment case — with one plist per environment and the build choosing which one enters the bundle, there is no environment name left to be unknown at runtime. See How configuration works.

Messaging error codes

Chat and call operations throw BackendError.server(ServerFailure) with these codes (Messaging, Voice & Video Calls):

CodeStatusThrown by
messaging_not_configured412any Chat operation with no backend
not_signed_in401enrolling or resolving without a session
device_not_enrolled412resolving group state before enrollment
chat_is_direct422addMember / removeMember on a direct chat
chat_is_group422the no-argument safetyNumber() on a group
chat_is_draft422notify-scope and unread operations on a draft
calls_not_configured412accept / decline on an incoming call whose coordinator is gone
no_sibling_to_transfer409Call.transfer() with no sibling device in the room

media_unavailable is the exception: it is BackendError.mediaUnavailable, a distinct case rather than a .server code, and it means PalbeCall is not linked or PalbeCall.enable() was never called.

Note: device_not_enrolled's server message still reads "call pb.messaging.enroll() before using groups". pb.messaging.enroll() does not exist — every chat operation self-enrolls. Ignore that instruction.

The central error hook

One of the SDK's two public configuration calls installs a cross-cutting failure observer:

pb.configure { error in
    Telemetry.record(code: error.code, requestId: error.requestId)
}
public func configure(onError: (@Sendable (BackendError) -> Void)? = nil)

It fires once for every BackendError, before the error is thrown — so a call site using try? is still observed centrally. Calling configure again replaces the hook; passing nil removes it. The other overload, configure(messagingAppGroup:), is unrelated; see Overview.

PathFires the hook?
Generated endpoint methods, pb.call(...), pb.call(_ endpoint:)Yes
A typed @Upload call (pb.call(endpoint:file:onProgress:))Yes
pb.upload(_:fileURL:) / pb.upload(_:fileData:)No — handle those errors at the call site
pb.auth.* (AuthError)No — the hook only receives BackendError

Generated wrappers never re-fire it: they lift a BackendError the hook has already seen.

  • Calling Your Backend — typed throws, retries, and the correlation headers
  • Responses & Errors — declaring typed errors on the backend with defineError
  • Auth — the full pb.auth surface
  • Uploads — client-side constraints, and the errors they throw
  • App Attest — the two attestation cases, and why neither fires today
  • Overview — configuration, and the errors a misconfigured build dies on