fix: leaner error handling by boxing large variants

This commit is contained in:
Per Stark
2026-06-06 07:59:57 +02:00
parent 4e20da538d
commit ac0d34bfbd
12 changed files with 238 additions and 83 deletions
+56 -6
View File
@@ -9,7 +9,7 @@ use crate::storage::types::file_info::FileError;
#[derive(Error, Debug)]
pub enum EmbeddingError {
#[error("openai error: {0}")]
OpenAI(#[from] OpenAIError),
OpenAI(Box<OpenAIError>),
#[error("fastembed error: {0}")]
FastEmbed(String),
#[error("task join error: {0}")]
@@ -24,6 +24,12 @@ pub enum EmbeddingError {
UnknownModel(String),
}
impl From<OpenAIError> for EmbeddingError {
fn from(err: OpenAIError) -> Self {
Self::OpenAI(Box::new(err))
}
}
impl EmbeddingError {
pub(crate) fn fastembed(err: impl std::fmt::Display) -> Self {
Self::FastEmbed(err.to_string())
@@ -39,9 +45,9 @@ impl EmbeddingError {
#[derive(Error, Debug)]
pub enum AppError {
#[error("database error: {0}")]
Database(#[from] surrealdb::Error),
Database(Box<surrealdb::Error>),
#[error("openai error: {0}")]
OpenAI(#[from] OpenAIError),
OpenAI(Box<OpenAIError>),
#[error("embedding error: {0}")]
Embedding(#[from] EmbeddingError),
#[error("file error: {0}")]
@@ -61,17 +67,47 @@ pub enum AppError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
Reqwest(Box<reqwest::Error>),
#[error("storage error: {0}")]
Storage(#[from] object_store::Error),
Storage(Box<object_store::Error>),
#[error("ingestion processing error: {0}")]
Processing(String),
#[error("dom smoothie error: {0}")]
DomSmoothie(#[from] dom_smoothie::ReadabilityError),
DomSmoothie(Box<dom_smoothie::ReadabilityError>),
#[error("internal service error: {0}")]
InternalError(String),
}
impl From<surrealdb::Error> for AppError {
fn from(err: surrealdb::Error) -> Self {
Self::Database(Box::new(err))
}
}
impl From<OpenAIError> for AppError {
fn from(err: OpenAIError) -> Self {
Self::OpenAI(Box::new(err))
}
}
impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
Self::Reqwest(Box::new(err))
}
}
impl From<object_store::Error> for AppError {
fn from(err: object_store::Error) -> Self {
Self::Storage(Box::new(err))
}
}
impl From<dom_smoothie::ReadabilityError> for AppError {
fn from(err: dom_smoothie::ReadabilityError) -> Self {
Self::DomSmoothie(Box::new(err))
}
}
impl AppError {
/// Builds an [`AppError::InternalError`] from a displayable message.
#[must_use]
@@ -79,3 +115,17 @@ impl AppError {
Self::InternalError(msg.to_string())
}
}
#[cfg(test)]
mod tests {
use super::AppError;
#[test]
fn app_error_is_reasonably_sized() {
assert!(
std::mem::size_of::<AppError>() <= 64,
"AppError is {} bytes",
std::mem::size_of::<AppError>()
);
}
}
+18 -6
View File
@@ -36,7 +36,7 @@ pub enum FileError {
/// Database operation on the file record failed.
#[error("surrealdb error: {0}")]
SurrealError(#[from] surrealdb::Error),
SurrealError(Box<surrealdb::Error>),
/// Failed to persist the temporary file to its final location.
#[error("failed to persist file: {0}")]
@@ -48,7 +48,19 @@ pub enum FileError {
/// The underlying object store operation failed.
#[error("object store error: {0}")]
ObjectStore(#[from] ObjectStoreError),
ObjectStore(Box<ObjectStoreError>),
}
impl From<surrealdb::Error> for FileError {
fn from(err: surrealdb::Error) -> Self {
Self::SurrealError(Box::new(err))
}
}
impl From<ObjectStoreError> for FileError {
fn from(err: ObjectStoreError) -> Self {
Self::ObjectStore(Box::new(err))
}
}
stored_object!(FileInfo, "file", {
@@ -163,7 +175,7 @@ impl FileInfo {
match db_client.get_item::<FileInfo>(id).await {
Ok(Some(file_info)) => Ok(file_info),
Ok(None) => Err(FileError::FileNotFound(id.to_string())),
Err(e) => Err(FileError::SurrealError(e)),
Err(e) => Err(FileError::from(e)),
}
}
@@ -233,7 +245,7 @@ impl FileInfo {
if let Ok(existing) = Self::get_by_sha(&sha256, user_id, db_client).await {
return Ok(existing);
}
Err(FileError::SurrealError(e))
Err(FileError::from(e))
}
}
}
@@ -263,7 +275,7 @@ impl FileInfo {
storage
.delete_prefix(&parent_prefix)
.await
.map_err(AppError::Storage)?;
.map_err(AppError::from)?;
info!(
"Removed object prefix {} and its contents via StorageManager",
parent_prefix
@@ -286,7 +298,7 @@ impl FileInfo {
&self,
storage: &StorageManager,
) -> Result<bytes::Bytes, AppError> {
storage.get(&self.path).await.map_err(AppError::Storage)
storage.get(&self.path).await.map_err(AppError::from)
}
/// Persist bytes to storage using StorageManager.
+18 -18
View File
@@ -183,9 +183,9 @@ impl KnowledgeEntity {
.bind(("sources", source_ids.to_vec()))
.bind(("user_id", user_id.to_owned()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.take(0)
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(entities)
}
@@ -203,9 +203,9 @@ impl KnowledgeEntity {
.bind(("table", Self::table_name()))
.bind(("source_id", source_id.to_owned()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -248,9 +248,9 @@ impl KnowledgeEntity {
.bind(("entity", entity))
.bind(("emb", emb))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -289,11 +289,11 @@ impl KnowledgeEntity {
.bind(("embedding", query_embedding))
.bind(("user_id", user_id.to_string()))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
response = response.check().map_err(AppError::Database)?;
response = response.check().map_err(AppError::from)?;
let rows: Vec<Row> = response.take::<Vec<Row>>(0).map_err(AppError::Database)?;
let rows: Vec<Row> = response.take::<Vec<Row>>(0).map_err(AppError::from)?;
Ok(rows
.into_iter()
@@ -321,7 +321,7 @@ impl KnowledgeEntity {
let entity: KnowledgeEntity = db_client
.get_item(id)
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.ok_or_else(|| AppError::NotFound(format!("entity {id} not found")))?;
let settings = SystemSettings::get_current(db_client).await?;
@@ -355,9 +355,9 @@ impl KnowledgeEntity {
.bind(("emb", emb))
.bind(("description", description.to_string()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -453,9 +453,9 @@ impl KnowledgeEntity {
KnowledgeEntityEmbedding::table_name()
))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
db.client
.query(format!(
@@ -463,9 +463,9 @@ impl KnowledgeEntity {
KnowledgeEntityEmbedding::table_name()
))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
// Perform DB updates in a single transaction
info!("Applying embedding updates in a transaction...");
@@ -504,9 +504,9 @@ impl KnowledgeEntity {
db.client
.query(transaction_query)
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
info!("Re-embedding process for knowledge entities completed successfully.");
Ok(())
@@ -29,8 +29,8 @@ impl KnowledgeEntityEmbedding {
dimension,
);
let res = db.client.query(query).await.map_err(AppError::Database)?;
res.check().map_err(AppError::Database)?;
let res = db.client.query(query).await.map_err(AppError::from)?;
res.check().map_err(AppError::from)?;
Ok(())
}
@@ -78,8 +78,8 @@ impl KnowledgeEntityEmbedding {
.query(query)
.bind(("entity_id", entity_id.clone()))
.await
.map_err(AppError::Database)?;
let embeddings: Vec<Self> = result.take(0).map_err(AppError::Database)?;
.map_err(AppError::from)?;
let embeddings: Vec<Self> = result.take(0).map_err(AppError::from)?;
Ok(embeddings.into_iter().next())
}
@@ -101,8 +101,8 @@ impl KnowledgeEntityEmbedding {
.query(query)
.bind(("entity_ids", entity_ids.to_vec()))
.await
.map_err(AppError::Database)?;
let embeddings: Vec<Self> = result.take(0).map_err(AppError::Database)?;
.map_err(AppError::from)?;
let embeddings: Vec<Self> = result.take(0).map_err(AppError::from)?;
Ok(embeddings
.into_iter()
@@ -123,9 +123,9 @@ impl KnowledgeEntityEmbedding {
.query(query)
.bind(("entity_id", entity_id.clone()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -142,9 +142,9 @@ impl KnowledgeEntityEmbedding {
.query(query)
.bind(("source_id", source_id.to_owned()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
}
@@ -69,9 +69,9 @@ impl KnowledgeRelationship {
.bind(("source_id", self.metadata.source_id.clone()))
.bind(("relationship_type", self.metadata.relationship_type.clone()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -89,9 +89,9 @@ impl KnowledgeRelationship {
.bind(("source_id", source_id.to_owned()))
.bind(("user_id", user_id.to_owned()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -109,9 +109,9 @@ impl KnowledgeRelationship {
.bind(("id", id.to_owned()))
.bind(("user_id", user_id.to_owned()))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
let deleted: Vec<KnowledgeRelationship> =
delete_result.take(0).map_err(AppError::Database)?;
delete_result.take(0).map_err(AppError::from)?;
if !deleted.is_empty() {
return Ok(());
@@ -122,9 +122,9 @@ impl KnowledgeRelationship {
.query("SELECT * FROM type::thing('relates_to', $id)")
.bind(("id", id.to_owned()))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
let existing: Option<KnowledgeRelationship> =
exists_result.take(0).map_err(AppError::Database)?;
exists_result.take(0).map_err(AppError::from)?;
if existing.is_some() {
Err(AppError::Auth(
+16 -16
View File
@@ -55,9 +55,9 @@ impl TextChunk {
.bind(("source_id", source_id.to_owned()))
.bind(("table", Self::table_name()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -97,9 +97,9 @@ impl TextChunk {
.bind(("chunk", chunk))
.bind(("emb", emb))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -140,11 +140,11 @@ impl TextChunk {
.bind(("embedding", query_embedding))
.bind(("user_id", user_id.to_string()))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
response = response.check().map_err(AppError::Database)?;
response = response.check().map_err(AppError::from)?;
let rows: Vec<Row> = response.take::<Vec<Row>>(0).map_err(AppError::Database)?;
let rows: Vec<Row> = response.take::<Vec<Row>>(0).map_err(AppError::from)?;
Ok(rows
.into_iter()
@@ -208,11 +208,11 @@ impl TextChunk {
.bind(("user_id", user_id.to_owned()))
.bind(("limit", limit))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
response = response.check().map_err(AppError::Database)?;
response = response.check().map_err(AppError::from)?;
let rows: Vec<Row> = response.take::<Vec<Row>>(0).map_err(AppError::Database)?;
let rows: Vec<Row> = response.take::<Vec<Row>>(0).map_err(AppError::from)?;
Ok(rows
.into_iter()
@@ -314,16 +314,16 @@ impl TextChunk {
TextChunkEmbedding::table_name()
))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
db.client
.query(format!("DELETE FROM {};", TextChunkEmbedding::table_name()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
// Perform DB updates in a single transaction against the embedding table
info!("Applying embedding updates in a transaction...");
@@ -366,9 +366,9 @@ impl TextChunk {
db.client
.query(transaction_query)
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
info!("Re-embedding process for text chunks completed successfully.");
Ok(())
@@ -33,8 +33,8 @@ impl TextChunkEmbedding {
dimension,
);
let res = db.client.query(query).await.map_err(AppError::Database)?;
res.check().map_err(AppError::Database)?;
let res = db.client.query(query).await.map_err(AppError::from)?;
res.check().map_err(AppError::from)?;
Ok(())
}
@@ -85,9 +85,9 @@ impl TextChunkEmbedding {
.query(query)
.bind(("chunk_id", chunk_id.clone()))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
let embeddings: Vec<Self> = result.take(0).map_err(AppError::Database)?;
let embeddings: Vec<Self> = result.take(0).map_err(AppError::from)?;
Ok(embeddings.into_iter().next())
}
@@ -106,9 +106,9 @@ impl TextChunkEmbedding {
.query(query)
.bind(("chunk_id", chunk_id.clone()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
@@ -129,9 +129,9 @@ impl TextChunkEmbedding {
.query(query)
.bind(("source_id", source_id.to_owned()))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.check()
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
+7 -7
View File
@@ -115,7 +115,7 @@ impl TextContent {
surrealdb::Datetime::from(now),
))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
if updated.is_none() {
return Err(AppError::NotFound(format!("text content {id} not found")));
@@ -138,10 +138,10 @@ impl TextContent {
.bind(("file_id", file_id.to_owned()))
.bind(("exclude_id", exclude_id.to_owned()))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
let existing: Option<surrealdb::sql::Thing> =
response.take(0).map_err(AppError::Database)?;
response.take(0).map_err(AppError::from)?;
Ok(existing.is_some())
}
@@ -193,9 +193,9 @@ impl TextContent {
.bind(("user_id", user_id.to_owned()))
.bind(("limit", limit))
.await
.map_err(AppError::Database)?
.map_err(AppError::from)?
.take(0)
.map_err(AppError::Database)
.map_err(AppError::from)
}
/// Builds a fallback display label for a source id when no matching content row exists.
@@ -239,9 +239,9 @@ impl TextContent {
.bind(("user_id", user_id.to_owned()))
.bind(("record_ids", record_ids))
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
let contents: Vec<SourceLabelRow> = response.take(0).map_err(AppError::Database)?;
let contents: Vec<SourceLabelRow> = response.take(0).map_err(AppError::from)?;
tracing::debug!(
source_id_count = source_ids.len(),
+1 -1
View File
@@ -688,7 +688,7 @@ impl User {
db.delete_item::<IngestionTask>(id)
.await
.map_err(AppError::Database)?;
.map_err(AppError::from)?;
Ok(())
}
+94
View File
@@ -44,6 +44,7 @@
--leading-snug: 1.375;
--leading-relaxed: 1.625;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -284,6 +285,37 @@
}
}
}
.drawer-open {
> .drawer-side {
overflow-y: auto;
}
> .drawer-toggle {
display: none;
& ~ .drawer-side {
pointer-events: auto;
visibility: visible;
position: sticky;
display: block;
width: auto;
overscroll-behavior: auto;
opacity: 100%;
& > .drawer-overlay {
cursor: default;
background-color: transparent;
}
& > *:not(.drawer-overlay) {
translate: 0%;
[dir="rtl"] & {
translate: 0%;
}
}
}
&:checked ~ .drawer-side {
pointer-events: auto;
visibility: visible;
}
}
}
.drawer-toggle {
position: fixed;
height: calc(0.25rem * 0);
@@ -1042,6 +1074,22 @@
grid-row-start: 1;
min-width: calc(0.25rem * 0);
}
.chat-image {
grid-row: span 2 / span 2;
align-self: flex-end;
}
.chat-footer {
grid-row-start: 3;
display: flex;
gap: calc(0.25rem * 1);
font-size: 0.6875rem;
}
.chat-header {
grid-row-start: 1;
display: flex;
gap: calc(0.25rem * 1);
font-size: 0.6875rem;
}
.container {
width: 100%;
@media (width >= 40rem) {
@@ -1748,6 +1796,9 @@
.w-10 {
width: calc(var(--spacing) * 10);
}
.w-11 {
width: calc(var(--spacing) * 11);
}
.w-11\/12 {
width: calc(11/12 * 100%);
}
@@ -1811,6 +1862,9 @@
.flex-none {
flex: none;
}
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
@@ -1823,6 +1877,13 @@
.grow {
flex-grow: 1;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1895,6 +1956,9 @@
.justify-start {
justify-content: flex-start;
}
.gap-0 {
gap: calc(var(--spacing) * 0);
}
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
@@ -2027,6 +2091,9 @@
.border-base-200 {
border-color: var(--color-base-200);
}
.border-base-content {
border-color: var(--color-base-content);
}
.border-base-content\/10 {
border-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
@@ -2063,6 +2130,9 @@
.bg-transparent {
background-color: transparent;
}
.bg-warning {
background-color: var(--color-warning);
}
.bg-warning\/10 {
background-color: var(--color-warning);
@supports (color: color-mix(in lab, red, red)) {
@@ -2081,6 +2151,9 @@
.loading-spinner {
mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
}
.mask-repeat {
mask-repeat: repeat;
}
.fill-current {
fill: currentcolor;
}
@@ -2111,6 +2184,9 @@
.p-8 {
padding: calc(var(--spacing) * 8);
}
.px-1 {
padding-inline: calc(var(--spacing) * 1);
}
.px-1\.5 {
padding-inline: calc(var(--spacing) * 1.5);
}
@@ -2265,6 +2341,9 @@
--tw-tracking: var(--tracking-widest);
letter-spacing: var(--tracking-widest);
}
.text-wrap {
text-wrap: wrap;
}
.break-words {
overflow-wrap: break-word;
}
@@ -2331,6 +2410,17 @@
.italic {
font-style: italic;
}
.underline {
text-decoration-line: underline;
}
.swap-active {
.swap-off {
opacity: 0%;
}
.swap-on {
opacity: 100%;
}
}
.opacity-0 {
opacity: 0%;
}
@@ -2424,6 +2514,10 @@
--tw-duration: 300ms;
transition-duration: 300ms;
}
.ease-in-out {
--tw-ease: var(--ease-in-out);
transition-timing-function: var(--ease-in-out);
}
.ease-out {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
+1 -2
View File
@@ -233,8 +233,7 @@ fn plan_embedding_settings_update(
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| current.embedding_model.clone());
.map_or_else(|| current.embedding_model.clone(), ToOwned::to_owned);
if !is_valid_fastembed_model_code(&embedding_model) {
return Err(AppError::Validation(format!(
@@ -77,7 +77,7 @@ pub async fn extract_text_from_file(
let file_bytes = storage
.get(&file_info.path)
.await
.map_err(AppError::Storage)?;
.map_err(AppError::from)?;
let local_path = resolve_existing_local_path(storage, &file_info.path).await;
match file_info.mime_type.as_str() {