Errors
RepoError's four variants and when each is right, ClientError<E> on the client, and the blessed pattern for a custom service error enum.
architect keeps two error layers deliberately separate: what the
service failed with (a typed app error that travels the wire) and
what the infrastructure failed with (connect/transport problems the
client wraps around any call). A page can match on
App(RepoError::NotFound) while every connectivity problem gets one
shared “reconnecting…” treatment.
RepoError — the repo wire error#
Every architect-generated repo method returns
Result<_, architect::RepoError>. The enum is tight on purpose —
new variants are a wire-format change for every existing <Entity>Repo
trait:
#[derive(Debug, Clone, PartialEq, Eq, facet::Facet, thiserror::Error)]
#[repr(u8)]
pub enum RepoError {
#[error("entity not found")]
NotFound,
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("internal error: {0}")]
Internal(String),
}
When each is right:
| Variant | Use it when | Examples |
|---|---|---|
NotFound | The id resolved to nothing. The client’s cache-first hooks render this as the typed “doesn’t exist” state. | get/update/delete on a missing row |
InvalidInput(msg) | The request itself is malformed — the caller can fix it and retry. | unknown sort field, unparseable id, validation failure |
Conflict(msg) | The request was well-formed but lost to current state. | duplicate caller-supplied id (a String-pk slug that already exists), optimistic-lock failure |
Internal(msg) | The backend broke; nothing the caller did. Storage layers fold their native errors here (map_err(|e| RepoError::Internal(e.to_string()))). | DB connection lost, codec failure |
ClientError<E> — the client envelope#
On the client, a data hook can fail in more ways than the handler can:
the connection may never come up, or the call may die in flight. So the
hooks (and everything the derive emits) fail with
architect::ClientError<E>, where E is the service’s typed error:
App(E)— the handler ran and returned the service’s typed error (vox’sVoxError::User). This is the arm feature pages branch on:App(RepoError::NotFound)renders the “not found” state.Connect(String)— establishing the connection failed (after the transport’s retry policy gave up).Transport { detail, retryable }— the call failed in flight (connection closed, payload mismatch);retryableis vox’s verdict on whether a fresh connection could succeed.
is_infrastructure() is the “show the reconnecting banner” test;
app() extracts the typed error. The derive emits a per-entity alias —
type ExampleClientError = ClientError<RepoError>; — and a
From<vox::VoxError<E>> impl folds vox call errors into the envelope,
so ?/map_err(ClientError::from) is all a loader needs.
Custom service errors — the blessed pattern#
CRUD rides RepoError, but a hand-written #[vox::service] usually has
domain failures RepoError shouldn’t absorb (remember: extending
RepoError changes the wire for every repo). The pattern: one
dedicated error enum per service, facet::Facet so it travels the
vox wire, thiserror::Error for ergonomic ? on the server, kept
separate from RepoError. A full example (examples/greeter/src/lib.rs
has the minimal one, GreetError):
//! Error type for the custom [`ExampleService`](crate::ExampleService).
//!
//! A `facet::Facet` enum so it travels over the vox wire, and
//! `thiserror::Error` for ergonomic `?` on the server. `#[repr(u8)]` keeps
//! the wire tag compact. Add variants here as the service grows.
#[derive(Debug, Clone, PartialEq, Eq, facet::Facet, thiserror::Error)]
#[repr(u8)]
pub enum ExampleServiceError {
#[error("example not found")]
NotFound,
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("internal error: {0}")]
Internal(String),
}
and the service methods return it directly:
#[vox::service]
pub trait ExampleService {
async fn search(&self, query: String, limit: u32)
-> Result<Vec<Example>, ExampleServiceError>;
}
The checklist for your own service error:
facet::Facet+#[repr(u8)]— it’s a wire type; the compact tag matters.thiserror::Error—Displaydrives both server logs and the client’sNotificationstoast on rollback.Clone + PartialEq— client hooks store and compare errors across renders.- Don’t reuse or extend
RepoError. A service that touches a repo internally maps at the boundary (repo.get(id).await.map_err(|e| match e { RepoError::NotFound => ExampleServiceError::NotFound, other => ExampleServiceError::Internal(other.to_string()) })?) so the repo’s wire contract and the service’s stay independently evolvable.
#[architect::error] (crates/architect-derive/src/lib.rs error)
writes most of that for you:
- the derives
Facet,Clone,Debug,PartialEq,thiserror::Errorand#[repr(u8)](addEqyourself with a#[derive(Eq)]under the attribute, as the examples do); - an
architect::http::HttpErrorimpl. Its code is the variant name insnake_case. Its status follows the variant name:NotFound404;Unauthenticated,InvalidCredentialsandSessionExpired401;ForbiddenandPermissionDenied403;Conflict409;Internal500; anything else 400.#[architect(http_status = N, code = "…")]overrides either; - a transport variant with
From<architect::TransportError>. It is an addedTransport(String)(503) unless you mark one of your own single-Stringvariants#[architect(transport)], in which case yours is used and there is noTransportvariant.
The transport variant is what lets a generated client implement the
service trait and
architect::vox_into_app fold a VoxError<E>. HttpError needs no
cargo feature, so the macro works in a crate that enables only
architect/vox; the crate depends on thiserror itself.
On the client the same envelope applies — the hook fails with
ClientError<ExampleServiceError>, and pages branch on
App(ExampleServiceError::NotFound) exactly as they do for repos.
Linked from
- Build a feature, end to endDefine an entity and a service, write a backend, serve it, consume it — the whole architect flow.
- The contractEntities, errors and services: what you write, what each attribute generates, and how to choose wire types.
- ClientsThe generated vox and HTTP clients, connecting and the connection lifecycle, the offline queue and optimistic state, forms.
- ErrorsOne typed error per service, the same on every wire: #[architect::error], ClientError, the HTTP envelope.