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 called | It 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 PBEndpoint | the endpoint's declared Failure |
pb.upload("todos/attach", fileURL:…) | BackendError |
pb.auth.* | AuthError |
pb.messaging.* and Chat operations | BackendError — 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
.forbiddenor.notFoundcases. A403or404arrives as.server(ServerFailure)withstatusset andcodecarrying the backend's own error code (todo_not_found). Match onfailure.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:
| Condition | Result |
|---|---|
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 answers400with codebad_requestand puts the per-field list at the envelope's top level (fields), while the SDK looks for it underdetails. The call therefore throws.server(ServerFailure)withcode == "bad_request", and the per-field messages are not reachable from the thrown error at all. Until the two shapes agree: matchfailure.code == "bad_request"for "the input was rejected", and when you want field-level UI, validate on the client and have the endpointthrowadefineErrorwhose typeddatayou 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:
| Property | Type | Meaning |
|---|---|---|
code | String | Stable snake_case error id (todo_locked) — what you switch on |
status | Int | The HTTP status |
message | String | The envelope's error_description |
requestId | String? | The body's request_id, or the X-Request-Id response header |
details | [AnyCodableValue] | The envelope's open-ended details array |
data | AnyCodableValue? | 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-tripsdatathrough the SDK's own coders, so a typed payload gets the same key mapping and date strategy as a successful response. It returnsnilon a mismatch and never throws.detailInt(_ key: String) -> Int?— reads anIntout of the firstdetailsobject carryingkey.
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
}
| Property | Type | Meaning |
|---|---|---|
fields | [FieldError] | One entry per rejected field; can be empty |
requestId | String? | Server correlation id |
case .validation(let v):
for fieldError in v.fields {
form.showError(field: fieldError.field, message: fieldError.message)
}
The remaining payloads
| Struct | Carried by | Properties |
|---|---|---|
RateLimitInfo | .rateLimited | retryAfter: Int? (seconds, from Retry-After), requestId: String? |
AuthFailure | .unauthorized, .appAttestRequired | requestId: String? |
AttestationFailure | .attestationUnavailable | reason: String |
TransportFailure | .network, .transport | message: String |
CodecFailure | .decode, .encode | message: String |
Note:
.attestationUnavailableis 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.appAttestRequiredcan 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
localizedDescriptionin front of a user for.notConfiguredor.mediaUnavailable..notConfiguredreads "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 onerror.codeand 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-.serverfailure, transport and decode problems, generic rate limits. The fullBackendErroris preserved inside, so nothing is lost. - An endpoint with no declared errors still gets an enum, with only
.other, socatchblocks stay uniform across every call. - Payload decoding is tolerant. If the server reshapes a declared payload, the case degrades to
.otherinstead 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
}
| Case | When |
|---|---|
.invalidCredentials | Wrong email or password — and, more generally, any 401 from an auth endpoint (an expired reset token, a rejected OTP, a cancelled Apple sign-in) |
.rateLimited | Too many attempts (429); retryAfter in seconds when the server says |
.http | Any other non-2xx, with its status and the wire code |
.network | Connection-level failure — offline, timeout |
.notConfigured | The 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 }
}
otpFailure | Wire codes it recognises |
|---|---|
.wrongCode | otp_invalid, invalid_token, invalid_code |
.expired | otp_expired, expired_token, verification_expired |
.tooManyAttempts | otp_max_attempts, too_many_attempts |
nil | anything 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):
| Code | Status | Thrown by |
|---|---|---|
messaging_not_configured | 412 | any Chat operation with no backend |
not_signed_in | 401 | enrolling or resolving without a session |
device_not_enrolled | 412 | resolving group state before enrollment |
chat_is_direct | 422 | addMember / removeMember on a direct chat |
chat_is_group | 422 | the no-argument safetyNumber() on a group |
chat_is_draft | 422 | notify-scope and unread operations on a draft |
calls_not_configured | 412 | accept / decline on an incoming call whose coordinator is gone |
no_sibling_to_transfer | 409 | Call.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.
| Path | Fires 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.
Related
- Calling Your Backend — typed throws, retries, and the correlation headers
- Responses & Errors — declaring typed errors on the backend with
defineError - Auth — the full
pb.authsurface - 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