Every error response in the { error, code, details? } format (step 2)

Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.

- 500s: the body was the error's text (anyhow / diesel / {:?}); now
  ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
  statuses are unchanged. Shared helpers per module (faces_unavailable,
  person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
  duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.

Closes #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-01 13:29:13 -05:00
1 parent 1836f1b9ca
commit 431c3a7492
23 files changed
+894 -542

No files matched your search

+60 -50
View File
@@ -22,6 +22,7 @@
//! registry.
//! - The HTTP handlers under ` /persona_chat/*`.
use crate::error::ApiError;
use actix_web::{HttpResponse, Responder, delete, get, post, web};
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
@@ -85,6 +86,39 @@ impl std::fmt::Display for PersonaChatError {
impl std::error::Error for PersonaChatError {}
impl From<&PersonaChatError> for ApiError {
fn from(e: &PersonaChatError) -> Self {
match e {
PersonaChatError::UnknownPersona(_) => {
ApiError::not_found("persona_not_found", "That persona no longer exists")
}
PersonaChatError::UnknownConversation(_) => ApiError::not_found(
"conversation_not_found",
"That conversation no longer exists",
),
PersonaChatError::EmptyMessage => {
ApiError::bad_request("empty_message", "Type a message first")
}
PersonaChatError::MessageTooLong => ApiError::bad_request(
"message_too_long",
"That message is too long (8192 characters at most)",
),
PersonaChatError::ConcurrentTurn => ApiError::conflict(
"turn_in_progress",
"A reply is still being written. Wait for it to finish.",
),
PersonaChatError::SmsThread(detail) => {
log::warn!("persona chat: SMS thread unreadable: {detail}");
ApiError::bad_gateway(
"sms_thread_unavailable",
"Couldn't read that message thread",
)
}
PersonaChatError::Db(err) => ApiError::internal(err),
}
}
}
impl From<anyhow::Error> for PersonaChatError {
fn from(e: anyhow::Error) -> Self {
PersonaChatError::Db(e)
@@ -1463,12 +1497,14 @@ pub async fn persona_chat_history_handler(
) {
Ok(view) => HttpResponse::Ok().json(view),
Err(e) => {
let msg = format!("{e}");
if msg.contains("not found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
if format!("{e}").contains("not found") {
ApiError::not_found(
"conversation_not_found",
"That conversation no longer exists",
)
.into_response()
} else {
log::error!("persona chat history load failed: {msg}");
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(e).into_response()
}
}
}
@@ -1494,11 +1530,7 @@ pub async fn persona_chat_turn_handler(
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
Ok(Some(lib)) => lib,
Ok(None) => app_state.primary_library(),
Err(e) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": format!("invalid library: {e}")
}));
}
Err(e) => return crate::libraries::unknown_library(e).into_response(),
};
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
@@ -1519,21 +1551,7 @@ pub async fn persona_chat_turn_handler(
}
Err(e) => {
span.set_status(Status::error(format!("{e}")));
match &e {
PersonaChatError::UnknownPersona(_) | PersonaChatError::UnknownConversation(_) => {
HttpResponse::NotFound()
}
PersonaChatError::EmptyMessage | PersonaChatError::MessageTooLong => {
HttpResponse::BadRequest()
}
PersonaChatError::ConcurrentTurn => HttpResponse::Conflict(),
PersonaChatError::SmsThread(_) => HttpResponse::BadGateway(),
PersonaChatError::Db(_) => {
log::error!("persona chat dispatch failed: {e}");
HttpResponse::InternalServerError()
}
}
.json(serde_json::json!({ "error": format!("{e}") }))
ApiError::from(&e).into_response()
}
}
}
@@ -1567,20 +1585,7 @@ pub async fn persona_chat_create_conversation_handler(
Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({
"conversation_id": conversation_id,
})),
Err(e) => match &e {
PersonaChatError::UnknownPersona(_) => {
HttpResponse::NotFound().json(serde_json::json!({ "error": format!("{e}") }))
}
PersonaChatError::SmsThread(_) => {
log::warn!("anchored persona chat create failed: {e}");
HttpResponse::BadGateway().json(serde_json::json!({ "error": format!("{e}") }))
}
_ => {
log::error!("persona chat create failed: {e}");
HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": format!("{e}") }))
}
},
Err(e) => ApiError::from(&e).into_response(),
}
}
@@ -1600,9 +1605,7 @@ pub async fn persona_chat_delete_conversation_handler(
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "deleted": true })),
Err(e) => {
log::error!("persona chat delete failed: {e}");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("{e}")
}))
ApiError::internal(format!("{e}")).into_response()
}
}
}
@@ -1626,9 +1629,7 @@ pub async fn persona_chat_conversations_handler(
})),
Err(e) => {
log::error!("persona chat conversation list failed: {e}");
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("{e}")
}))
ApiError::internal(format!("{e}")).into_response()
}
}
}
@@ -1669,20 +1670,29 @@ pub struct PersonaChatBranchesQuery {
/// equivalents return, so the client can share its error handling.
fn branch_error_response(context: &str, e: &anyhow::Error) -> HttpResponse {
let msg = format!("{e}");
log::error!("{context}: {msg}");
log::warn!("{context}: {msg}");
if msg.contains("conversation not found") {
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
ApiError::not_found(
"conversation_not_found",
"That conversation no longer exists",
)
} else if msg.contains("no chat history") {
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
ApiError::conflict("no_chat_history", "There's no chat here yet")
} else if msg.contains("not found")
|| msg.contains("not a leaf")
|| msg.contains("out of range")
|| msg.contains("cannot rewind past")
{
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
// The client's view of the branches is stale.
ApiError::bad_request(
"stale_branch",
"This conversation changed. Reload it and try again.",
)
.with_details(serde_json::json!({ "reason": msg }))
} else {
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
ApiError::internal(msg)
}
.into_response()
}
/// POST /persona_chat/rewind — move the tail of the conversation off the