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:
1 parent
1836f1b9ca
commit
431c3a7492
23 files changed
+894
-542
No files matched your search
+77
-89
@@ -1,3 +1,4 @@
|
||||
use crate::error::ApiError;
|
||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||
use futures::StreamExt;
|
||||
use opentelemetry::trace::Status;
|
||||
@@ -110,15 +111,12 @@ pub async fn generation_status_handler(
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
return HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": format!("Job {} not found", jid)
|
||||
}));
|
||||
return ApiError::not_found("job_not_found", "That insight job no longer exists")
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to look up job {}: {:?}", jid, e);
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to look up job"
|
||||
}));
|
||||
return ApiError::internal("Failed to look up job").into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,16 +151,12 @@ pub async fn generation_status_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to look up active job for {}: {:?}", normalized, e);
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to look up active job"
|
||||
}));
|
||||
return ApiError::internal("Failed to look up active job").into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Provide either job_id or path query parameter"
|
||||
}))
|
||||
ApiError::bad_request("invalid_request", "Give a job_id or a path").into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -212,9 +206,7 @@ pub async fn cancel_generation_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cancel job {}: {:?}", jid, e);
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to cancel job"
|
||||
}));
|
||||
return ApiError::internal("Failed to cancel job").into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,9 +259,7 @@ pub async fn cancel_generation_handler(
|
||||
}));
|
||||
}
|
||||
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Provide either job_id or file_path in the request body"
|
||||
}))
|
||||
ApiError::bad_request("invalid_request", "Give a job_id or a file_path").into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -491,9 +481,7 @@ pub async fn generate_insight_handler(
|
||||
Err(e) => {
|
||||
log::error!("Failed to create generation job: {:?}", e);
|
||||
span.set_status(Status::error("Failed to create generation job"));
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to create generation job"
|
||||
}));
|
||||
return ApiError::internal("Failed to create generation job").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -640,14 +628,10 @@ pub async fn get_insight_handler(
|
||||
|
||||
match dao.get_insight_for_paths(&sibling_paths) {
|
||||
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "Insight not found"
|
||||
})),
|
||||
Ok(None) => insight_not_found().into_response(),
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch insight ({}): {:?}", &query.path, e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to fetch insight: {:?}", e)
|
||||
}))
|
||||
ApiError::internal(format!("Failed to fetch insight: {:?}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -671,9 +655,7 @@ pub async fn delete_insight_handler(
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Failed to delete insight: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to delete insight: {:?}", e)
|
||||
}))
|
||||
ApiError::internal(format!("Failed to delete insight: {:?}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -699,9 +681,7 @@ pub async fn get_all_insights_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch all insights: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to fetch insights: {:?}", e)
|
||||
}))
|
||||
ApiError::internal(format!("Failed to fetch insights: {:?}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -731,9 +711,7 @@ pub async fn get_insight_history_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch insight history ({}): {:?}", &query.path, e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to fetch insight history: {:?}", e)
|
||||
}))
|
||||
ApiError::internal(format!("Failed to fetch insight history: {:?}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -822,9 +800,7 @@ pub async fn generate_agentic_insight_handler(
|
||||
Err(e) => {
|
||||
log::error!("Failed to create agentic generation job: {:?}", e);
|
||||
span.set_status(Status::error("Failed to create generation job"));
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to create generation job"
|
||||
}));
|
||||
return ApiError::internal("Failed to create generation job").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1159,9 +1135,7 @@ pub async fn rate_insight_handler(
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Failed to rate insight: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to rate insight: {:?}", e)
|
||||
}))
|
||||
ApiError::internal(format!("Failed to rate insight: {:?}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1213,9 +1187,7 @@ pub async fn export_training_data_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to export training data: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to export training data: {:?}", e)
|
||||
}))
|
||||
ApiError::internal(format!("Failed to export training data: {:?}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1308,9 +1280,7 @@ pub async fn chat_turn_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
return libraries::unknown_library(e).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1361,17 +1331,17 @@ pub async fn chat_turn_handler(
|
||||
|
||||
// Map well-known errors to client-facing 4xx codes.
|
||||
if msg.contains("no insight found") {
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
insight_not_found().into_response()
|
||||
} else if msg.contains("no chat history") {
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
no_chat_history().into_response()
|
||||
} else if msg.contains("user_message")
|
||||
|| msg.contains("unknown backend")
|
||||
|| msg.contains("switching from local to hybrid")
|
||||
|| msg.contains("hybrid backend unavailable")
|
||||
{
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
ApiError::bad_request("invalid_chat_request", msg).into_response()
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
ApiError::internal(msg).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1456,9 +1426,7 @@ pub async fn chat_rewind_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
return libraries::unknown_library(e).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1476,13 +1444,18 @@ pub async fn chat_rewind_handler(
|
||||
let msg = format!("{}", e);
|
||||
log::error!("Chat rewind failed: {}", msg);
|
||||
if msg.contains("no insight found") {
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
insight_not_found().into_response()
|
||||
} else if msg.contains("no chat history") {
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
no_chat_history().into_response()
|
||||
} else if msg.contains("cannot discard the initial") || msg.contains("out of range") {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
ApiError::bad_request(
|
||||
"stale_branch",
|
||||
"This conversation changed. Reload it and try again.",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg }))
|
||||
.into_response()
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
ApiError::internal(msg).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1553,11 +1526,11 @@ pub async fn chat_history_handler(
|
||||
Err(e) => {
|
||||
let msg = format!("{}", e);
|
||||
if msg.contains("no insight found") {
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
insight_not_found().into_response()
|
||||
} else if msg.contains("no chat history") {
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
no_chat_history().into_response()
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
ApiError::internal(msg).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1606,14 +1579,18 @@ pub async fn chat_branches_handler(
|
||||
Err(e) => {
|
||||
let msg = format!("{}", e);
|
||||
if msg.contains("no insight found") {
|
||||
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
|
||||
return insight_not_found().into_response();
|
||||
} else if msg.contains("no chat history") {
|
||||
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
|
||||
return no_chat_history().into_response();
|
||||
} else if msg.contains("not found in tree") {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
|
||||
return ApiError::bad_request(
|
||||
"stale_branch",
|
||||
"This conversation changed. Reload it and try again.",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg }))
|
||||
.into_response();
|
||||
} else {
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": msg }));
|
||||
return ApiError::internal(msg).into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1638,9 +1615,7 @@ pub async fn chat_switch_branch_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
return libraries::unknown_library(e).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1654,13 +1629,18 @@ pub async fn chat_switch_branch_handler(
|
||||
let msg = format!("{}", e);
|
||||
log::error!("Switch branch failed: {}", msg);
|
||||
if msg.contains("no insight found") {
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
insight_not_found().into_response()
|
||||
} else if msg.contains("no chat history") {
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
no_chat_history().into_response()
|
||||
} else if msg.contains("not found") || msg.contains("not a leaf") {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
ApiError::bad_request(
|
||||
"stale_branch",
|
||||
"This conversation changed. Reload it and try again.",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg }))
|
||||
.into_response()
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
ApiError::internal(msg).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1698,9 +1678,7 @@ pub async fn chat_stream_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
return libraries::unknown_library(e).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1853,9 +1831,7 @@ pub async fn turn_async_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
return libraries::unknown_library(e).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1936,9 +1912,7 @@ pub(crate) async fn turn_replay_impl(
|
||||
Some(e) => e,
|
||||
None => {
|
||||
span.set_status(Status::error("turn not found"));
|
||||
return HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": format!("turn {} not found", turn_id)
|
||||
}));
|
||||
return turn_not_found().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1953,9 +1927,11 @@ pub(crate) async fn turn_replay_impl(
|
||||
let (initial_frames, start_skip) = match entry.replay_from(skip_before).await {
|
||||
ReplayOutcome::Gone => {
|
||||
span.set_status(Status::error("buffer evicted"));
|
||||
return HttpResponse::Gone().json(serde_json::json!({
|
||||
"error": "turn history has expired (buffer evicted)"
|
||||
}));
|
||||
return ApiError::gone(
|
||||
"turn_expired",
|
||||
"This reply is too old to resume. Reload the chat.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
ReplayOutcome::CaughtUp { next_skip } => (Vec::new(), next_skip),
|
||||
ReplayOutcome::Events { events, next_skip } => {
|
||||
@@ -2100,9 +2076,7 @@ pub(crate) async fn cancel_turn_impl(
|
||||
Some(e) => e,
|
||||
None => {
|
||||
span.set_status(Status::error("turn not found"));
|
||||
return HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": format!("turn {} not found", turn_id)
|
||||
}));
|
||||
return turn_not_found().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2441,3 +2415,17 @@ mod owner_defaults_tests {
|
||||
assert_eq!(persona.as_deref(), Some("journal"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- error responses (#202) ---------------------------------------------
|
||||
|
||||
fn insight_not_found() -> ApiError {
|
||||
ApiError::not_found("insight_not_found", "There's no insight for this photo yet")
|
||||
}
|
||||
|
||||
fn no_chat_history() -> ApiError {
|
||||
ApiError::conflict("no_chat_history", "There's no chat for this insight yet")
|
||||
}
|
||||
|
||||
fn turn_not_found() -> ApiError {
|
||||
ApiError::not_found("turn_not_found", "That reply is no longer running")
|
||||
}
|
||||
+60
-50
@@ -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
|
||||
|
||||
+64
-46
@@ -5,6 +5,7 @@
|
||||
// voice from either an uploaded clip (device) or an existing library file
|
||||
// (audio read directly; video has its audio track extracted via ffmpeg).
|
||||
|
||||
use crate::error::ApiError;
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||
use anyhow::Context;
|
||||
@@ -386,12 +387,11 @@ pub async fn tts_speech_handler(
|
||||
let text = prepare_for_tts(&req.text);
|
||||
if text.is_empty() {
|
||||
span.set_status(Status::error("text is required"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
|
||||
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
|
||||
}
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
|
||||
return tts_unavailable().into_response();
|
||||
};
|
||||
|
||||
let format = req
|
||||
@@ -419,9 +419,11 @@ pub async fn tts_speech_handler(
|
||||
// One synthesis at a time (see TTS_PERMIT) — fast-fail when busy.
|
||||
let Ok(_permit) = TTS_PERMIT.try_acquire() else {
|
||||
span.set_status(Status::error("tts busy"));
|
||||
return HttpResponse::TooManyRequests().json(json!({
|
||||
"error": "TTS is busy with another request — try again shortly"
|
||||
}));
|
||||
return ApiError::too_many_requests(
|
||||
"tts_busy",
|
||||
"Already reading something. Try again shortly.",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
// Wait for the LLM side to release the GPU before sending — the synthesis
|
||||
@@ -444,7 +446,7 @@ pub async fn tts_speech_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("tts synthesis failed"));
|
||||
log::error!("TTS synth failed: {:?}", e);
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("TTS failed: {e}") }))
|
||||
tts_failed(&e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,12 +518,11 @@ pub async fn create_speech_job_handler(
|
||||
let text = prepare_for_tts(&req.text);
|
||||
if text.is_empty() {
|
||||
span.set_status(Status::error("text is required"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
|
||||
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
|
||||
}
|
||||
if app_state.llamacpp.is_none() {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
|
||||
return tts_unavailable().into_response();
|
||||
}
|
||||
|
||||
let format = req
|
||||
@@ -660,7 +661,7 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
|
||||
|
||||
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
||||
span.set_status(Status::error("invalid job id"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
||||
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
|
||||
};
|
||||
let resp = {
|
||||
let jobs = TTS_JOBS.lock().unwrap();
|
||||
@@ -679,8 +680,7 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
|
||||
}
|
||||
None => {
|
||||
span.set_status(Status::error("job not found"));
|
||||
HttpResponse::NotFound()
|
||||
.json(json!({ "error": "TTS job not found (it may have expired)" }))
|
||||
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,7 +696,7 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
|
||||
|
||||
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
||||
span.set_status(Status::error("invalid job id"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
||||
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
|
||||
};
|
||||
let status = with_job(id, |job| {
|
||||
if !job.status.is_terminal() {
|
||||
@@ -715,8 +715,7 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
|
||||
}
|
||||
None => {
|
||||
span.set_status(Status::error("job not found"));
|
||||
HttpResponse::NotFound()
|
||||
.json(json!({ "error": "TTS job not found (it may have expired)" }))
|
||||
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -753,8 +752,7 @@ pub async fn list_voices_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
return tts_unavailable().into_response();
|
||||
};
|
||||
match client.list_voices().await {
|
||||
Ok(v) => {
|
||||
@@ -766,7 +764,7 @@ pub async fn list_voices_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("list_voices failed"));
|
||||
log::error!("list_voices failed: {:?}", e);
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
tts_failed(&e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,8 +781,7 @@ pub async fn delete_voice_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
return tts_unavailable().into_response();
|
||||
};
|
||||
// Same charset rule as creation — a name that sanitizes differently was
|
||||
// never a voice we created, and must not reach the upstream URL.
|
||||
@@ -793,7 +790,7 @@ pub async fn delete_voice_handler(
|
||||
Some(n) if n == raw => n,
|
||||
_ => {
|
||||
span.set_status(Status::error("invalid voice name"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid voice name" }));
|
||||
return invalid_voice_name().into_response();
|
||||
}
|
||||
};
|
||||
span.set_attribute("tts.voice_name", name.clone());
|
||||
@@ -807,7 +804,7 @@ pub async fn delete_voice_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("delete_voice failed"));
|
||||
log::error!("delete_voice failed: {:?}", e);
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
tts_failed(&e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -827,8 +824,7 @@ pub async fn create_voice_upload_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
return tts_unavailable().into_response();
|
||||
};
|
||||
|
||||
let mut voice_name: Option<String> = None;
|
||||
@@ -853,8 +849,12 @@ pub async fn create_voice_upload_handler(
|
||||
while let Some(Ok(data)) = part.next().await {
|
||||
if file_bytes.len() + data.len() > MAX_VOICE_UPLOAD_BYTES {
|
||||
span.set_status(Status::error("voice clip exceeds limit"));
|
||||
return HttpResponse::PayloadTooLarge()
|
||||
.json(json!({ "error": "voice clip exceeds 25 MB" }));
|
||||
return ApiError::new(
|
||||
actix_web::http::StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"body_too_large",
|
||||
"The voice clip is too big (25 MB at most)",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
file_bytes.put(data);
|
||||
}
|
||||
@@ -897,21 +897,20 @@ pub async fn create_voice_upload_handler(
|
||||
Ok(w) => w,
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error("invalid reference window"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
||||
return ApiError::bad_request("invalid_clip_window", msg).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let Some(name) = voice_name.as_deref().and_then(sanitize_voice_name) else {
|
||||
span.set_status(Status::error("voice_name is required"));
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
|
||||
return invalid_voice_name().into_response();
|
||||
};
|
||||
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
||||
// library shows which reference length produced each clone.
|
||||
let name = append_ref_window(&name, ref_start, ref_duration.round().max(1.0) as u32);
|
||||
if file_bytes.is_empty() {
|
||||
span.set_status(Status::error("voice_file is required"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "voice_file is required" }));
|
||||
return ApiError::bad_request("no_file", "Choose an audio file").into_response();
|
||||
}
|
||||
span.set_attribute("tts.voice_name", name.clone());
|
||||
span.set_attribute("tts.upload_bytes", file_bytes.len() as i64);
|
||||
@@ -925,8 +924,7 @@ pub async fn create_voice_upload_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("audio decode failed"));
|
||||
log::error!("voice upload transcode failed: {:?}", e);
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "couldn't decode that audio file" }));
|
||||
return undecodable_audio().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -942,7 +940,7 @@ pub async fn create_voice_upload_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("create_voice failed"));
|
||||
log::error!("create_voice (upload) failed: {:?}", e);
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
tts_failed(&e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -978,20 +976,18 @@ pub async fn create_voice_from_library_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
return tts_unavailable().into_response();
|
||||
};
|
||||
let Some(voice_name) = sanitize_voice_name(&req.voice_name) else {
|
||||
span.set_status(Status::error("voice_name is required"));
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
|
||||
return invalid_voice_name().into_response();
|
||||
};
|
||||
let (ref_start, ref_duration) =
|
||||
match resolve_ref_window(req.start_seconds, req.duration_seconds) {
|
||||
Ok(w) => w,
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error("invalid reference window"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
||||
return ApiError::bad_request("invalid_clip_window", msg).into_response();
|
||||
}
|
||||
};
|
||||
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
||||
@@ -1004,7 +1000,7 @@ pub async fn create_voice_from_library_handler(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error("invalid library"));
|
||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1013,7 +1009,8 @@ pub async fn create_voice_from_library_handler(
|
||||
Some(p) if p.exists() => p,
|
||||
_ => {
|
||||
span.set_status(Status::error("file not found"));
|
||||
return HttpResponse::NotFound().json(json!({ "error": "file not found in library" }));
|
||||
return ApiError::not_found("file_not_found", "That file doesn't exist")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1021,8 +1018,7 @@ pub async fn create_voice_from_library_handler(
|
||||
// slurp arbitrary library files into memory / ffmpeg.
|
||||
if !is_audio_file(&abs) && !is_video_file(&abs) {
|
||||
span.set_status(Status::error("not an audio/video file"));
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "file is not an audio or video file" }));
|
||||
return ApiError::bad_request("not_audio", "That file has no audio").into_response();
|
||||
}
|
||||
span.set_attribute("tts.voice_name", voice_name.clone());
|
||||
|
||||
@@ -1031,8 +1027,7 @@ pub async fn create_voice_from_library_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("audio decode failed"));
|
||||
log::error!("voice reference prep failed for {:?}: {:?}", abs, e);
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "couldn't decode that file's audio" }));
|
||||
return undecodable_audio().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1048,7 +1043,7 @@ pub async fn create_voice_from_library_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("create_voice failed"));
|
||||
log::error!("create_voice (from-library) failed: {:?}", e);
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
tts_failed(&e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1060,6 +1055,29 @@ async fn prepare_reference_audio(abs: &Path, start: f64, duration: f64) -> anyho
|
||||
run_ffmpeg_to_wav(&abs.to_string_lossy(), start, duration).await
|
||||
}
|
||||
|
||||
// ---- error responses (#202) ---------------------------------------------
|
||||
|
||||
fn tts_unavailable() -> ApiError {
|
||||
ApiError::unavailable("tts_unavailable", "Read-aloud isn't set up on this server")
|
||||
}
|
||||
|
||||
/// The voice backend failed; its message is kept in `details` for debugging.
|
||||
fn tts_failed(e: &impl std::fmt::Display) -> ApiError {
|
||||
ApiError::bad_gateway("tts_failed", "The voice service failed. Try again.")
|
||||
.with_details(json!({ "reason": e.to_string() }))
|
||||
}
|
||||
|
||||
fn invalid_voice_name() -> ApiError {
|
||||
ApiError::bad_request(
|
||||
"invalid_voice_name",
|
||||
"Voice names can only use letters, numbers, - and _",
|
||||
)
|
||||
}
|
||||
|
||||
fn undecodable_audio() -> ApiError {
|
||||
ApiError::bad_request("undecodable_audio", "Couldn't read the audio in that file")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in new issue
Block a user